597 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
Mateusz Gruszczyński
78700c48c5 zmrestore old login 2025-07-11 13:22:43 +02:00
Mateusz Gruszczyński
9193a94c0d zmrestore old login 2025-07-11 13:20:13 +02:00
Mateusz Gruszczyński
a35adf6101 zmiana w base.hmtl i formuarzy logowania 2025-07-11 13:10:10 +02:00
Mateusz Gruszczyński
3069f38623 fix in css 2025-07-11 12:49:03 +02:00
Mateusz Gruszczyński
194287aa39 poprawki w autoryzacji 2025-07-11 11:35:57 +02:00
Mateusz Gruszczyński
96546a3a9d poprawki w autoryzacji 2025-07-11 11:34:27 +02:00
Mateusz Gruszczyński
6d197eb1fe poprawki w autoryzacji 2025-07-11 11:30:27 +02:00
Mateusz Gruszczyński
7134de3e1f poprawki w autoryzacji 2025-07-11 11:24:06 +02:00
Mateusz Gruszczyński
add29fbb30 poprawki w autoryzacji 2025-07-11 11:21:17 +02:00
Mateusz Gruszczyński
18c34d8093 poprawki w autoryzacji 2025-07-11 11:02:43 +02:00
Mateusz Gruszczyński
7aa5c43c5a poprawki w autoryzacji 2025-07-11 10:56:57 +02:00
Mateusz Gruszczyński
7786310de3 poprawki w autoryzacji 2025-07-11 10:38:24 +02:00
Mateusz Gruszczyński
d91a46bf22 decydowanie o zyciu cookie 2025-07-11 00:03:09 +02:00
Mateusz Gruszczyński
153d50f875 decydowanie o zyciu cookie 2025-07-10 23:58:11 +02:00
Mateusz Gruszczyński
5e3146aa6a decydowanie o zyciu cookie 2025-07-10 23:57:27 +02:00
Mateusz Gruszczyński
404cc7a9bf fix serwowanie toasts.js i error handlery 2025-07-10 23:18:19 +02:00
Mateusz Gruszczyński
120b08efd0 fix serwowanie toasts.js i error handlery 2025-07-10 23:11:35 +02:00
Mateusz Gruszczyński
c219cd2691 fix serwowanie toasts.js i error handlery 2025-07-10 23:06:43 +02:00
149 changed files with 21212 additions and 3880 deletions

View File

@@ -1,17 +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"
# 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
.gitattributes vendored Normal file
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

9
.gitignore vendored
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
API_OPIS.txt Normal file
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
CLI_OPIS.txt Normal file
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.

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"]

106
README.md
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
_tools/add_products.py Normal file
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.")

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
_tools/db/migrate.txt Normal file
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$$;

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
_tools/migrate_to_webp.py Normal file
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()

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
_tools/wait_for_db.py Normal file
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)

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.")

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;

1333
app.py

File diff suppressed because it is too large Load Diff

116
config.py
View File

@@ -1,11 +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')
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
deploy/app/Dockerfile Normal file
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"]

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"));
}

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)"

View File

@@ -1,17 +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}
volumes:
- .:/app

28
docker/Dockerfile Normal file
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"]

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"]

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
docker/compose.yml Normal file
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

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
docker/requirements.txt Normal file
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

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

View File

@@ -1,9 +0,0 @@
Flask
Flask-SQLAlchemy
Flask-Login
Flask-SocketIO
Flask-Compress
eventlet
Werkzeug
Pillow
psutil

11
shopping_app/__init__.py Normal file
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
shopping_app/app_setup.py Normal file
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
shopping_app/deps.py Normal file
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

1642
shopping_app/helpers.py Normal file

File diff suppressed because it is too large Load Diff

216
shopping_app/models.py Normal file
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"

1443
shopping_app/routes_admin.py Normal file

File diff suppressed because it is too large Load Diff

878
shopping_app/routes_main.py Normal file
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'))

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
shopping_app/sockets.py Normal file
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]))

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")

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

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

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;
}
}

View File

@@ -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.

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.

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);
});
})();

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();
})();
})();

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();
}

View File

@@ -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');
})();

View File

@@ -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'
});
});
});

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();
}
}
});
});
});

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();
});

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);
}
});
});

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();
});
});

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();
});

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;
});

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();
}
}
});

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();
});

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();
});
});

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);
})();

View File

@@ -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 });
}

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 });
};
}
});
});
});

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;
});

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();
}
}

View File

@@ -0,0 +1,140 @@
document.addEventListener("DOMContentLoaded", function () {
const modalElement = document.getElementById("productPreviewModal");
if (!modalElement || typeof bootstrap === "undefined") return;
const modal = new bootstrap.Modal(modalElement);
const modalTitle = document.getElementById("previewModalLabel");
const productList = document.getElementById("product-list");
if (!modalTitle || !productList) return;
const renderState = (message, extraClass = "text-white") => {
productList.innerHTML = "";
const wrapper = document.createElement("div");
wrapper.className = "preview-modal-items";
const item = document.createElement("div");
item.className = `preview-modal-list-item ${extraClass}`.trim();
item.textContent = message;
wrapper.appendChild(item);
productList.appendChild(wrapper);
};
const createSection = (titleText) => {
const section = document.createElement("section");
section.className = "preview-product-section";
const title = document.createElement("h6");
title.className = "preview-product-section-title";
title.textContent = titleText;
const items = document.createElement("div");
items.className = "preview-modal-items";
section.appendChild(title);
section.appendChild(items);
return { section, items };
};
const createItem = (itemData) => {
const row = document.createElement("div");
row.className = "preview-modal-list-item";
const name = document.createElement("span");
name.className = "preview-modal-list-item__name";
name.textContent = itemData.name;
const badge = document.createElement("span");
badge.className = "badge";
if (itemData.purchased) {
badge.classList.add("bg-success");
} else if (itemData.not_purchased) {
badge.classList.add("bg-warning", "text-dark");
} else {
badge.classList.add("bg-secondary");
}
badge.textContent = `x${itemData.quantity}`;
row.appendChild(name);
row.appendChild(badge);
return row;
};
modalElement.addEventListener("hidden.bs.modal", function () {
document.querySelectorAll(".modal-backdrop").forEach((el) => el.remove());
document.body.classList.remove("modal-open");
document.body.style.overflow = "";
});
document.querySelectorAll(".preview-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
const listId = btn.dataset.listId;
modalTitle.textContent = "Ładowanie...";
renderState("⏳ Ładowanie produktów...");
modal.show();
try {
const res = await fetch(`/admin/list_items/${listId}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
const totalCount = Number(data.total_count || 0);
const purchasedCount = Number(data.purchased_count || 0);
const totalExpense = Number(data.total_expense || 0);
const percent = totalCount > 0 ? Math.round((purchasedCount / totalCount) * 100) : 0;
modalTitle.textContent = `🛒 ${data.title}`;
productList.innerHTML = "";
const summary = document.createElement("div");
summary.className = "preview-product-summary";
summary.innerHTML = `
<p class="mb-1">📦 <strong>${totalCount}</strong> produktów</p>
<p class="mb-1">✅ Kupione: <strong>${purchasedCount}</strong> (${percent}%)</p>
<p class="mb-0">💸 Wydatek: <strong>${totalExpense.toFixed(2)} zł</strong></p>`;
productList.appendChild(summary);
const purchased = createSection("✔️ Kupione");
const pending = createSection("🚫 Niekupione / Nieoznaczone");
let hasPurchased = false;
let hasPending = false;
(data.items || []).forEach((item) => {
const row = createItem(item);
if (item.purchased) {
purchased.items.appendChild(row);
hasPurchased = true;
} else {
pending.items.appendChild(row);
hasPending = true;
}
});
if (hasPurchased) {
productList.appendChild(purchased.section);
}
if (hasPending) {
productList.appendChild(pending.section);
}
if (!hasPurchased && !hasPending) {
renderState("Brak produktów", "text-muted fst-italic");
}
} catch (error) {
modalTitle.textContent = "Błąd";
renderState("❌ Błąd podczas ładowania", "text-danger");
}
});
});
});

View File

@@ -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);
});
});

View File

@@ -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);
}

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);
});
}
})();

View File

@@ -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,
};
})();

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();
});

View File

@@ -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;

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"
}
});
});

View File

@@ -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);
});
});

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();
});
});

View File

@@ -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();
});
});

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 });
});

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();
});

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();
});
}
});

View File

@@ -8,5 +8,5 @@ function showToast(message, type = 'primary') {
toast.innerHTML = `<div class="d-flex"><div class="toast-body">${message}</div></div>`;
toastContainer.appendChild(toast);
setTimeout(() => { toast.remove(); }, 4000);
setTimeout(() => { toast.remove(); }, 2000);
}

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);
});
});

View File

@@ -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;

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}

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

View File

Before

Width:  |  Height:  |  Size: 280 B

After

Width:  |  Height:  |  Size: 280 B

View File

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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