Compare commits
919
Commits
main
..
2b3cc49d65
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b3cc49d65 | ||
|
|
6e80c3142e | ||
|
|
1eb9da64e2 | ||
|
|
89ca4716ee | ||
|
|
0ec40a7ccc | ||
|
|
7f2a5615b3 | ||
|
|
5b241038f1 | ||
|
|
8837e26b13 | ||
|
|
d713a4697c | ||
|
|
af62d24868 | ||
|
|
82ea496f0d | ||
|
|
70544cf488 | ||
|
|
1d0c835087 | ||
|
|
8f803b86d9 | ||
|
|
d5bff8b855 | ||
|
|
f129574b62 | ||
|
|
bfec6f9d69 | ||
|
|
20543245f8 | ||
|
|
38f027a9fb | ||
|
|
1bc7bbe81e | ||
|
|
da30590080 | ||
|
|
a59bbc213a | ||
|
|
3689775efc | ||
|
|
6250645fa4 | ||
|
|
6099fc651c | ||
|
|
74074093dc | ||
|
|
56d0ef01fa | ||
|
|
163828e213 | ||
|
|
401e558011 | ||
|
|
572d9610f2 | ||
|
|
1d3444d665 | ||
|
|
e0f54fb661 | ||
|
|
8bacd6aa3f | ||
|
|
b710008dbe | ||
|
|
70a405b231 | ||
|
|
bdc8f149dc | ||
|
|
2e886c7bc1 | ||
|
|
1eed22de5c | ||
|
|
c23ce6085a | ||
|
|
e798fd50a8 | ||
|
|
48b360bce1 | ||
|
|
0e083268c6 | ||
|
|
0ad8f6cb31 | ||
|
|
f8d650567a | ||
|
|
8db6f39046 | ||
|
|
e4ebd8b4fd | ||
|
|
efcd10c3c0 | ||
|
|
2a9b883e1b | ||
|
|
f770dcf2db | ||
|
|
2deb8a2069 | ||
|
|
87e19e3d02 | ||
|
|
31a754d264 | ||
|
|
767048d79b | ||
|
|
13181fa275 | ||
|
|
37e872809b | ||
|
|
b99339ea93 | ||
|
|
f0e68cb31e | ||
|
|
8fe372532b | ||
|
|
214ac33049 | ||
|
|
7b245869d5 | ||
|
|
f5a2928f1b | ||
|
|
f74146bb11 | ||
|
|
f6157bf879 | ||
|
|
704adf6fe8 | ||
|
|
f2575e04fb | ||
|
|
ba7bbbfc62 | ||
|
|
1db9947267 | ||
|
|
38d05da272 | ||
|
|
c740de95f9 | ||
|
|
483a63ede9 | ||
|
|
a817b9b61d | ||
|
|
0c6da9845c | ||
|
|
586c653e4f | ||
|
|
c8cc411aa5 | ||
|
|
71d967114d | ||
|
|
659c30f376 | ||
|
|
70d3b3b019 | ||
|
|
0b8c324944 | ||
|
|
cbbaa22f56 | ||
|
|
fb754cad92 | ||
|
|
0518cdce0b | ||
|
|
1af739cac2 | ||
|
|
b28ba3c47d | ||
|
|
fadc57a7c7 | ||
|
|
cca3946a35 | ||
|
|
18be3fdc19 | ||
|
|
939e63f244 | ||
|
|
b4c267d584 | ||
|
|
b9b31a494a | ||
|
|
b961954aa1 | ||
|
|
84287808c8 | ||
|
|
692272e3ca | ||
|
|
89d639e44e | ||
|
|
e62da0aef1 | ||
|
|
3759306e38 | ||
|
|
fcd91b4321 | ||
|
|
cf32a78ef5 | ||
|
|
7c43d957bc | ||
|
|
cc6a9ef9d3 | ||
|
|
1babfa0d64 | ||
|
|
ce940b39b8 | ||
|
|
6151fc1dac | ||
|
|
338bdb5932 | ||
|
|
bbcc845c26 | ||
|
|
1c080bc4be | ||
|
|
59b0494328 | ||
|
|
6337350b60 | ||
|
|
986acfac38 | ||
|
|
b27149b5ba | ||
|
|
eb1284060a | ||
|
|
4266ac1f12 | ||
|
|
6b4709d011 | ||
|
|
ef8d3068a8 | ||
|
|
a23c14389b | ||
|
|
6dfd8db0a6 | ||
|
|
abce2e03d1 | ||
|
|
54d7d5b74e | ||
|
|
17bb88050a | ||
|
|
8c2e20dfe8 | ||
|
|
3e2dd7ea04 | ||
|
|
70eebefac7 | ||
|
|
ac47d201d8 | ||
|
|
11e19f48e8 | ||
|
|
5ac49522d9 | ||
|
|
f9f1179db5 | ||
|
|
04527c286f | ||
|
|
e23d6571c8 | ||
|
|
0c331bbb93 | ||
|
|
a2d506cce9 | ||
|
|
3a4cf93bcc | ||
|
|
29b9fce07d | ||
|
|
9a0ba22fe8 | ||
|
|
8321761358 | ||
|
|
bdee3e844f | ||
|
|
081d66695c | ||
|
|
94c5ce4989 | ||
|
|
6a05b56481 | ||
|
|
6f34e96111 | ||
|
|
30e8c17000 | ||
|
|
62619f26b8 | ||
|
|
3e006e0bec | ||
|
|
3a4c46cc0d | ||
|
|
9ba6a28c5c | ||
|
|
5c6d837f10 | ||
|
|
634aa535de | ||
|
|
8ce607d787 | ||
|
|
85edc95652 | ||
|
|
a7d7cb5627 | ||
|
|
f3ef9d4052 | ||
|
|
f77ccd47aa | ||
|
|
006bb4e282 | ||
|
|
2af8ce9bb3 | ||
|
|
d67f9f3f3e | ||
|
|
fa89cde581 | ||
|
|
c05bd57fe1 | ||
|
|
88bb78fb23 | ||
|
|
e96cb5bdaa | ||
|
|
86844e330e | ||
|
|
cf95fea6c3 | ||
|
|
c366f8f0d3 | ||
|
|
1e6f8b9cd8 | ||
|
|
006882cd6f | ||
|
|
ceabf7d73b | ||
|
|
874dbe1e43 | ||
|
|
6e0aa3c46c | ||
|
|
ea047411ef | ||
|
|
d21201cdce | ||
|
|
c087832b74 | ||
|
|
ee11cde835 | ||
|
|
cc9fc0eeaf | ||
|
|
27de7a8b4e | ||
|
|
200b0178e7 | ||
|
|
9e237a1d98 | ||
|
|
db3b5a77bc | ||
|
|
8a6aa33974 | ||
|
|
3fc33eaa12 | ||
|
|
f2b35c108c | ||
|
|
52ddce5625 | ||
|
|
934f334b91 | ||
|
|
3130933ea4 | ||
|
|
481d7e3ab6 | ||
|
|
e76bf15135 | ||
|
|
05192a00cd | ||
|
|
0de95e3824 | ||
|
|
23055919cd | ||
|
|
6350a7851c | ||
|
|
5b3e12ec4b | ||
|
|
82dc018676 | ||
|
|
40d11dc66a | ||
|
|
b9a142ba48 | ||
|
|
afeb59d0c1 | ||
|
|
56ef299c5d | ||
|
|
fd5d1c3401 | ||
|
|
4a6bba7734 | ||
|
|
c32d3509b3 | ||
|
|
213136b92e | ||
|
|
0dc75452d6 | ||
|
|
9d1f6831df | ||
|
|
0341e2f2d8 | ||
|
|
c4739ff7d3 | ||
|
|
cac691e9ff | ||
|
|
0e12f7a75c | ||
|
|
3872dafaf1 | ||
|
|
6a905c1948 | ||
|
|
bf576bc0e3 | ||
|
|
2c182ad48d | ||
|
|
a4d29b9d6f | ||
|
|
52538d0181 | ||
|
|
75f12c1971 | ||
|
|
a41da84e3f | ||
|
|
c53b930554 | ||
|
|
1705575985 | ||
|
|
0c0742fce7 | ||
|
|
803ad3dfc6 | ||
|
|
f965cc4ecd | ||
|
|
ad3f46a9ae | ||
|
|
3ea4918ca2 | ||
|
|
26a54848f1 | ||
|
|
015337d75c | ||
|
|
5f06d7e04c | ||
|
|
1b2ad3965b | ||
|
|
7adb71efe5 | ||
|
|
f35fb12856 | ||
|
|
0295108e6b | ||
|
|
a2fa976065 | ||
|
|
b82db127ae | ||
|
|
f5e807cdbf | ||
|
|
06f28fd426 | ||
|
|
60afefac96 | ||
|
|
351fef504c | ||
|
|
3a8703d3c6 | ||
|
|
1c58165425 | ||
|
|
38640d8f75 | ||
|
|
bb1f7cdb88 | ||
|
|
4bb1b0997a | ||
|
|
f8f1235e60 | ||
|
|
fbd72f727a | ||
|
|
62eb1060d9 | ||
|
|
2fc18801a7 | ||
|
|
f0d2a7cc05 | ||
|
|
2808952153 | ||
|
|
149659064d | ||
|
|
cb310967cd | ||
|
|
3e7cb9387c | ||
|
|
cef451cb17 | ||
|
|
da9566c11f | ||
|
|
d3c0e138d8 | ||
|
|
7afc24ce51 | ||
|
|
92573ebeb4 | ||
|
|
987bd3a1c7 | ||
|
|
a95a09a062 | ||
|
|
7b2c93bcef | ||
|
|
f02058b0c0 | ||
|
|
f35742fec3 | ||
|
|
7b08e6a45e | ||
|
|
e99cceaa52 | ||
|
|
df8c3f1e09 | ||
|
|
b65ec449a0 | ||
|
|
bb9d763416 | ||
|
|
bdddbf678c | ||
|
|
389d417c1b | ||
|
|
f182c2209e | ||
|
|
8e9ee4bff0 | ||
|
|
7f47a21d40 | ||
|
|
7914511de5 | ||
|
|
efbc6a9b4c | ||
|
|
57ac8e772b | ||
|
|
3589f42651 | ||
|
|
1052010dd5 | ||
|
|
ae26181cf8 | ||
|
|
66b36509d2 | ||
|
|
e49062a9db | ||
|
|
9e56ff8494 | ||
|
|
831b5c17cd | ||
|
|
d04ea8549d | ||
|
|
35786703d5 | ||
|
|
10eec5fd6d | ||
|
|
d4debeab74 | ||
|
|
5c0ea360cd | ||
|
|
3b38e30df1 | ||
|
|
9a39bedd3a | ||
|
|
9e1eb9f1e5 | ||
|
|
82f8a1ef4a | ||
|
|
7c815898ea | ||
|
|
58a951eec9 | ||
|
|
ef75cb3341 | ||
|
|
1c71c0016b | ||
|
|
1a034041ab | ||
|
|
c60f9ff4d3 | ||
|
|
3efe388d3f | ||
|
|
7dc12c9e7f | ||
|
|
19c88d786e | ||
|
|
9937f532ec | ||
|
|
e380a1b9e7 | ||
|
|
13476bddf6 | ||
|
|
31d08ec973 | ||
|
|
deaa7716a2 | ||
|
|
157493b0f4 | ||
|
|
f7e1708039 | ||
|
|
20f817506f | ||
|
|
94b9d2c63b | ||
|
|
fcc2d7b748 | ||
|
|
24ddd4b294 | ||
|
|
557b34b71a | ||
|
|
e0255b28ec | ||
|
|
69855fa118 | ||
|
|
a07f2680d6 | ||
|
|
0738b1ff91 | ||
|
|
388f8bc508 | ||
|
|
a17bdc7e13 | ||
|
|
5711618e6e | ||
|
|
d022cff499 | ||
|
|
d4a480b5ea | ||
|
|
161ff392d7 | ||
|
|
4e72fbf84b | ||
|
|
bde3819510 | ||
|
|
097889ba3f | ||
|
|
20dd16734f | ||
|
|
b01665f02c | ||
|
|
aec2da11f2 | ||
|
|
272998f757 | ||
|
|
744d0ace84 | ||
|
|
326c549670 | ||
|
|
d966c9529b | ||
|
|
4350714534 | ||
|
|
1a26cacb66 | ||
|
|
0057792802 | ||
|
|
4854d40508 | ||
|
|
7dd3133475 | ||
|
|
24dfa39f91 | ||
|
|
7ec65f7c12 | ||
|
|
4f8edef42b | ||
|
|
7818410d55 | ||
|
|
1762191b03 | ||
|
|
2df92e809e | ||
|
|
59a8d32e40 | ||
|
|
c3b3963760 | ||
|
|
a0cd39e3bc | ||
|
|
e48b2258e0 | ||
|
|
35aaf8824f | ||
|
|
76c69ca3ec | ||
|
|
9994225911 | ||
|
|
03f699e448 | ||
|
|
2fd01a5ab7 | ||
|
|
96629113f1 | ||
|
|
973afa0cc2 | ||
|
|
9bc55e771a | ||
|
|
40a292d05e | ||
|
|
bf723db2cc | ||
|
|
46052172b2 | ||
|
|
4f777f0003 | ||
|
|
b5e1ba72d0 | ||
|
|
df120098f9 | ||
|
|
1c1d578db7 | ||
|
|
69352fe0b5 | ||
|
|
fb3980dad0 | ||
|
|
6c21a1e619 | ||
|
|
112c0dc70a | ||
|
|
538a9ce25d | ||
|
|
cdc3d10df5 | ||
|
|
1b150e3e64 | ||
|
|
27dccc324a | ||
|
|
1bb68ab33b | ||
|
|
3c6ea15d47 | ||
|
|
36e663e1ed | ||
|
|
8d2e0381a7 | ||
|
|
f67d7b35f1 | ||
|
|
9ec62f7471 | ||
|
|
6bbbc41360 | ||
|
|
d3844ac7a7 | ||
|
|
9378e95a5b | ||
|
|
5e4c4ce228 | ||
|
|
ffc373db62 | ||
|
|
60266bf0dc | ||
|
|
8393a80022 | ||
|
|
234edf4756 | ||
|
|
5fd401bfb6 | ||
|
|
5663a5f376 | ||
|
|
81327a9e20 | ||
|
|
2a5b9ad276 | ||
|
|
662e71c621 | ||
|
|
636adfd997 | ||
|
|
b94f7a9532 | ||
|
|
f954a34384 | ||
|
|
6a74263606 | ||
|
|
0bf714061c | ||
|
|
e4e2e919c2 | ||
|
|
7fe4289f42 | ||
|
|
3ce866b7da | ||
|
|
43982c16fa | ||
|
|
e33e4196ab | ||
|
|
2c506db4e4 | ||
|
|
9b9d356849 | ||
|
|
30b7871583 | ||
|
|
1c873b7d02 | ||
|
|
539fb262b2 | ||
|
|
3bf09f9083 | ||
|
|
a55d15b635 | ||
|
|
431748c489 | ||
|
|
a0fb214332 | ||
|
|
f0545cbf02 | ||
|
|
a11c336cf5 | ||
|
|
6b083f8b1b | ||
|
|
89afbba165 | ||
|
|
b2a4887e23 | ||
|
|
9f577875f2 | ||
|
|
c322d6147a | ||
|
|
87b6b3362d | ||
|
|
d261f54327 | ||
|
|
59a815f85a | ||
|
|
2e837f96c5 | ||
|
|
00fce6bd68 | ||
|
|
8a85cd7990 | ||
|
|
db5431d77d | ||
|
|
527b010b24 | ||
|
|
e1727caa5f | ||
|
|
c45b61681d | ||
|
|
e09652413f | ||
|
|
0f337e569f | ||
|
|
59a2668c8c | ||
|
|
e79abf4a26 | ||
|
|
53811ca9b2 | ||
|
|
1bed47fbf5 | ||
|
|
ffb22165da | ||
|
|
48c3daf398 | ||
|
|
c0b4ba715c | ||
|
|
00557ec9ea | ||
|
|
c387225a6e | ||
|
|
93462d4c4b | ||
|
|
c5b55399a1 | ||
|
|
79c39828f0 | ||
|
|
dd80f3732b | ||
|
|
25d109beed | ||
|
|
db6d6c0106 | ||
|
|
af1cf4f5ae | ||
|
|
0ea0cd96db | ||
|
|
3858dcbd62 | ||
|
|
b55d74fb12 | ||
|
|
a21e3590ef | ||
|
|
319c1b1b52 | ||
|
|
964a747f42 | ||
|
|
12d2870424 | ||
|
|
015b188d5e | ||
|
|
8cd2f16c5c | ||
|
|
d09055986e | ||
|
|
2e324ff118 | ||
|
|
adad5ed4fe | ||
|
|
ba3152f553 | ||
|
|
a4bea94495 | ||
|
|
ac2f68f93f | ||
|
|
f156a153de | ||
|
|
c48b84bf3a | ||
|
|
01846bf23f | ||
|
|
2c90044185 | ||
|
|
4d7566de9b | ||
|
|
9133b7c3ce | ||
|
|
3412aa0bf0 | ||
|
|
f0591d4939 | ||
|
|
dd108c2004 | ||
|
|
44ac1d2bfa | ||
|
|
1616e4edb9 | ||
|
|
4c34d7eda5 | ||
|
|
17c6124a57 | ||
|
|
95ad49df43 | ||
|
|
7e8ae1632d | ||
|
|
e5d155e124 | ||
|
|
3ae30f1f76 | ||
|
|
adb59eff68 | ||
|
|
e06776d81d | ||
|
|
a5aac9a337 | ||
|
|
ee40c905d4 | ||
|
|
c56bf4fb49 | ||
|
|
5b28044d9e | ||
|
|
e359a8ebe2 | ||
|
|
b867b6ba78 | ||
|
|
ac570d036c | ||
|
|
165dda3210 | ||
|
|
02a0253c1d | ||
|
|
3c0fea6812 | ||
|
|
31062fddbf | ||
|
|
d0dd342e27 | ||
|
|
3c1d5d601f | ||
|
|
49ec99ef01 | ||
|
|
8799662159 | ||
|
|
f6706c165e | ||
|
|
0a3e151377 | ||
|
|
e153811ff3 | ||
|
|
fa59dbc095 | ||
|
|
d6d2f2892b | ||
|
|
707788583a | ||
|
|
b0666a0090 | ||
|
|
0f6eb5c043 | ||
|
|
529ebd23fc | ||
|
|
f7fda2d2c9 | ||
|
|
c709ee11c9 | ||
|
|
6312dfae47 | ||
|
|
061da150f9 | ||
|
|
46a8b612b4 | ||
|
|
1cd0b54656 | ||
|
|
4cc70640e4 | ||
|
|
c36ce17da5 | ||
|
|
3cfb79c8f5 | ||
|
|
d4f5a46409 | ||
|
|
0fa0488385 | ||
|
|
9fb6e64655 | ||
|
|
a3abd854bb | ||
|
|
3b05acffd2 | ||
|
|
bee7d828cd | ||
|
|
8ae9aac045 | ||
|
|
e4b0625799 | ||
|
|
4a770848a6 | ||
|
|
823892a302 | ||
|
|
9b49e659e5 | ||
|
|
ec9af49f29 | ||
|
|
22668f8a72 | ||
|
|
a1840cd034 | ||
|
|
bc65752ea2 | ||
|
|
3594ac1f59 | ||
|
|
0e6fbd523c | ||
|
|
743593affe | ||
|
|
44dd77cec5 | ||
|
|
8fa216c06c | ||
|
|
c529fc87fd | ||
|
|
656ea5f90e | ||
|
|
2582df360a | ||
|
|
6673f7b615 | ||
|
|
94e94cf7ca | ||
|
|
e6bd7aa152 | ||
|
|
087f9c10b4 | ||
|
|
6138cad782 | ||
|
|
c6575d8e52 | ||
|
|
b0a97ad267 | ||
|
|
b31c286a8b | ||
|
|
13f1d2f390 | ||
|
|
d23ed3711a | ||
|
|
d2e2bb8117 | ||
|
|
d71d5da6bc | ||
|
|
75593fd6bb | ||
|
|
c2b8061ac2 | ||
|
|
6948220746 | ||
|
|
d10768403d | ||
|
|
81479e7058 | ||
|
|
99b2beeab0 | ||
|
|
b365afc910 | ||
|
|
27c0abea31 | ||
|
|
e3c997302b | ||
|
|
d24627b35f | ||
|
|
fbd4fa4e66 | ||
|
|
7c52c2d9d5 | ||
|
|
18b1ec20b6 | ||
|
|
87b48af551 | ||
|
|
32e0c959e8 | ||
|
|
2a47c0ba5e | ||
|
|
8058e4a4b0 | ||
|
|
d5917b9454 | ||
|
|
3926b2d837 | ||
|
|
6dca3a96e1 | ||
|
|
886d21999c | ||
|
|
9a923f6bdd | ||
|
|
29139d5d0c | ||
|
|
fe1b3d6d19 | ||
|
|
5fbcadad8b | ||
|
|
9e50b2a6d3 | ||
|
|
6f043d2139 | ||
|
|
73e8779bdc | ||
|
|
6ad3844f03 | ||
|
|
8810679fd8 | ||
|
|
9d5815ed10 | ||
|
|
e6f702a6b4 | ||
|
|
145373399d | ||
|
|
13ce09a5c5 | ||
|
|
bd5bb5ae65 | ||
|
|
71a206a19f | ||
|
|
87c189b3fe | ||
|
|
877ef7970d | ||
|
|
5390b8bdc3 | ||
|
|
af4a70e894 | ||
|
|
529606955a | ||
|
|
1de2c55966 | ||
|
|
dec2281ac8 | ||
|
|
7b32a7eba4 | ||
|
|
c5c160baae | ||
|
|
c2d9af807a | ||
|
|
0bc24e8d2e | ||
|
|
c8461342fd | ||
|
|
78c631e6c7 | ||
|
|
c72b015073 | ||
|
|
604e27ce10 | ||
|
|
6e68408ddf | ||
|
|
013d4adce5 | ||
|
|
7dcabde2ed | ||
|
|
5ba239caa8 | ||
|
|
3960390ec4 | ||
|
|
70db15bf27 | ||
|
|
3c6a0944d6 | ||
|
|
825ece4e78 | ||
|
|
965dc930a9 | ||
|
|
7fa2817f77 | ||
|
|
39fa8fa0cd | ||
|
|
54416ee84f | ||
|
|
287e10d8aa | ||
|
|
2ba28c193a | ||
|
|
819cf8381c | ||
|
|
e62a855409 | ||
|
|
c9c070c497 | ||
|
|
4a8a614adb | ||
|
|
a9663c8170 | ||
|
|
a4d79b06c4 | ||
|
|
752f3df13a | ||
|
|
5b70bb9ea5 | ||
|
|
17c9731b9f | ||
|
|
d8b43dbd08 | ||
|
|
0f950218d6 | ||
|
|
0fad298fc0 | ||
|
|
c55927aa9c | ||
|
|
5cfcafe082 | ||
|
|
c6c2766381 | ||
|
|
c4e3f1fc1f | ||
|
|
91d8f3efd1 | ||
|
|
d9ac1813ba | ||
|
|
fd07001983 | ||
|
|
0266b2a559 | ||
|
|
11b8f0e744 | ||
|
|
24a504e3a7 | ||
|
|
d6061cb45b | ||
|
|
a219ce4d79 | ||
|
|
aedfe9aa94 | ||
|
|
dd5a9a23e8 | ||
|
|
e91ae4584a | ||
|
|
ad7eab9717 | ||
|
|
62aa15a4b4 | ||
|
|
c9113ca095 | ||
|
|
8e195a49e3 | ||
|
|
7c22a70722 | ||
|
|
804556b74d | ||
|
|
6ecd356cc0 | ||
|
|
0a9b66d2f7 | ||
|
|
98c2213a86 | ||
|
|
5a72c8cd83 | ||
|
|
1b72063a5b | ||
|
|
24260f9c37 | ||
|
|
9292e3b8f3 | ||
|
|
095be5892d | ||
|
|
37f6725f2f | ||
|
|
e3afc1ba6e | ||
|
|
a1afaacb2e | ||
|
|
5b78165fb7 | ||
|
|
1807cff67d | ||
|
|
0613f6f54d | ||
|
|
edb35b5707 | ||
|
|
30fe0bfae7 | ||
|
|
8f137cf8e6 | ||
|
|
dd11c8da5a | ||
|
|
4df6055566 | ||
|
|
8d3d7327d7 | ||
|
|
afbf53938b | ||
|
|
e1324e99bf | ||
|
|
d7851e6457 | ||
|
|
84d7b11f24 | ||
|
|
580ec5ab0d | ||
|
|
578c182f27 | ||
|
|
c3c94461c2 | ||
|
|
45e3239cc0 | ||
|
|
e469e27abf | ||
|
|
4620ebb800 | ||
|
|
66b85d146a | ||
|
|
da1be6301c | ||
|
|
162cfe3945 | ||
|
|
e280e87764 | ||
|
|
5af4e5754b | ||
|
|
076fbb30fb | ||
|
|
fbe95d6631 | ||
|
|
8ea41bb592 | ||
|
|
54920e1545 | ||
|
|
a7e0e5a3f8 | ||
|
|
3623286a9d | ||
|
|
821db3cb1a | ||
|
|
e06824f4ec | ||
|
|
2ad5a7b1f4 | ||
|
|
736123c4aa | ||
|
|
60404c6fd3 | ||
|
|
91a21db89b | ||
|
|
c6620ad70a | ||
|
|
ebb520dd4a | ||
|
|
3ccbe8be5c | ||
|
|
c745f60997 | ||
|
|
6c7266f204 | ||
|
|
e21880b4f5 | ||
|
|
2ab4341d00 | ||
|
|
6555e4f826 | ||
|
|
2595594c3a | ||
|
|
bb2b4b61b4 | ||
|
|
c042af8b80 | ||
|
|
6bdc6a7347 | ||
|
|
1b6ebf5008 | ||
|
|
f0d76bd46a | ||
|
|
1a608d8cfb | ||
|
|
2e86ca2a3f | ||
|
|
c77d2fb782 | ||
|
|
bc8a296223 | ||
|
|
6c58f4b26c | ||
|
|
3230c9f93b | ||
|
|
5b03ecda3f | ||
|
|
8b98935d11 | ||
|
|
24cd378c9d | ||
|
|
2fb6be753e | ||
|
|
60efe6ee8a | ||
|
|
104ee27766 | ||
|
|
162f89f9d0 | ||
|
|
95a8a45842 | ||
|
|
3ee7c6d802 | ||
|
|
1792686531 | ||
|
|
aa5703e62f | ||
|
|
33bc695cda | ||
|
|
9e3b9ae326 | ||
|
|
477ca5917a | ||
|
|
8c33bc63d6 | ||
|
|
2e69ac5921 | ||
|
|
f28be3ba55 | ||
|
|
ea4196af8f | ||
|
|
be956a13db | ||
|
|
7bcc67c6d3 | ||
|
|
263595fc7e | ||
|
|
c463dc6dca | ||
|
|
7c4334fe7b | ||
|
|
d1396801c0 | ||
|
|
9c3abdec26 | ||
|
|
53be4b060a | ||
|
|
1f8ebf71d0 | ||
|
|
37da903936 | ||
|
|
3baa6e53df | ||
|
|
b6eba60843 | ||
|
|
2541fc536a | ||
|
|
2cfb8fe308 | ||
|
|
be35a6caf0 | ||
|
|
bfe4b351c1 | ||
|
|
8fe24f711c | ||
|
|
ca463b79a8 | ||
|
|
8a59ddd210 | ||
|
|
3f75c8d5f9 | ||
|
|
087ab1a8a5 | ||
|
|
48891c4386 | ||
|
|
688e7fbf0e | ||
|
|
54c40c6b85 | ||
|
|
49c0f932ab | ||
|
|
0f400a0b6a | ||
|
|
e75836fe87 | ||
|
|
661eba7161 | ||
|
|
a6555dc069 | ||
|
|
cef83aefe7 | ||
|
|
dc2a31abee | ||
|
|
83121f7fa9 | ||
|
|
bfca2bdf73 | ||
|
|
ecb77cf361 | ||
|
|
feb5234b3b | ||
|
|
e7cd397e0f | ||
|
|
78f9679f30 | ||
|
|
a3cec5bce0 | ||
|
|
e2a8efe5ca | ||
|
|
a42c2bdf5d | ||
|
|
afa40ada77 | ||
|
|
bda5cfd52d | ||
|
|
ad4847a78e | ||
|
|
b169fa4792 | ||
|
|
06b539b884 | ||
|
|
a8fea31a32 | ||
|
|
5154811b29 | ||
|
|
60ca3ec791 | ||
|
|
49eb76dc8b | ||
|
|
d7b943dc8c | ||
|
|
8d0d709e18 | ||
|
|
fcb05903f3 | ||
|
|
f291c0f2e4 | ||
|
|
7ca2bc5776 | ||
|
|
9840c8eb03 | ||
|
|
51f1b1d86e | ||
|
|
cfa2af61b8 | ||
|
|
a301e2c5bf | ||
|
|
d4aaa2d66b | ||
|
|
386d9c3aae | ||
|
|
203314b209 | ||
|
|
b6185a95f3 | ||
|
|
6f9adfe67f | ||
|
|
084f8dba20 | ||
|
|
f395d16173 | ||
|
|
b48a901e33 | ||
|
|
fe707dca4e | ||
|
|
f4a5536dcf | ||
|
|
079187e1b4 | ||
|
|
83cc0f6137 | ||
|
|
38a24e9a12 | ||
|
|
23c8ebca73 | ||
|
|
3ae43c84e7 | ||
|
|
7d750db1f8 | ||
|
|
99fc9118b3 | ||
|
|
a06e3f404a | ||
|
|
98d89dbc5f | ||
|
|
e0ed4491b4 | ||
|
|
feeb94c9cf | ||
|
|
928969b8b6 | ||
|
|
4185bb3a69 | ||
|
|
0c68c4e622 | ||
|
|
f6f9968430 | ||
|
|
3796c7c547 | ||
|
|
663ab41510 | ||
|
|
ae5fffe5e0 | ||
|
|
1999a6c8d8 | ||
|
|
7526bcc816 | ||
|
|
efe12644ed | ||
|
|
cfd3e7881e | ||
|
|
495543144d | ||
|
|
2ae2e8450c | ||
|
|
88749ce05c | ||
|
|
10c7232a8f | ||
|
|
8f502af84e | ||
|
|
010f3b03a0 | ||
|
|
bfdfa6c8bb | ||
|
|
0a70e80668 | ||
|
|
cc3b896d2c | ||
|
|
780c178d95 | ||
|
|
5fd03efc30 | ||
|
|
c6f4393166 | ||
|
|
312b9eeeca | ||
|
|
1c53a90e00 | ||
|
|
4de93489ef | ||
|
|
8eff6dd6cb | ||
|
|
15de277a5b | ||
|
|
b06a10f6ec | ||
|
|
03e9003969 | ||
|
|
99cea506de | ||
|
|
a8e3ad1af9 | ||
|
|
5c4c579389 | ||
|
|
3d6e1d2e94 | ||
|
|
20d8d27f03 | ||
|
|
5b88350ff2 | ||
|
|
bca39a612c | ||
|
|
f69586ccf7 | ||
|
|
4fbfe90a13 | ||
|
|
75cb7605cd | ||
|
|
3be25285f4 | ||
|
|
af399e3b72 | ||
|
|
9e89e27c66 | ||
|
|
80f1bbc05e | ||
|
|
82de0f3044 | ||
|
|
b562d17167 | ||
|
|
f9fed90e86 | ||
|
|
d7c003c409 | ||
|
|
561a915e30 | ||
|
|
03c72e85f7 | ||
|
|
1f5dc57d6f | ||
|
|
3e4b6b0062 | ||
|
|
438fad3014 | ||
|
|
030942db09 | ||
|
|
4442f75ec5 | ||
|
|
5f3dac8bc6 | ||
|
|
c558dc2d79 | ||
|
|
931aae5134 | ||
|
|
0e821f8b58 | ||
|
|
75ec590be5 | ||
|
|
41927b7ef4 | ||
|
|
16afbb4e15 | ||
|
|
9f652ece1b | ||
|
|
93b2f6cc41 | ||
|
|
284d38096c | ||
|
|
84e5bac1b9 | ||
|
|
9e94210bc3 | ||
|
|
d69c75c619 | ||
|
|
4da635502b | ||
|
|
7d05bd9da4 | ||
|
|
b2ca373081 | ||
|
|
14c59ba5c0 | ||
|
|
5aee606d5d | ||
|
|
4f1a48c197 | ||
|
|
99d335ac24 | ||
|
|
da72a15068 | ||
|
|
d93d48ef7e | ||
|
|
01d8093e72 | ||
|
|
8e825a90c6 | ||
|
|
f3d12a257e | ||
|
|
374db23669 | ||
|
|
095e30a92f | ||
|
|
26210f8c09 | ||
|
|
188a1e6178 | ||
|
|
2a3e225e1d | ||
|
|
4f71f74574 | ||
|
|
aec9ffd1a1 | ||
|
|
87895a72d3 | ||
|
|
36c69eb8bb | ||
|
|
fb7cb35921 | ||
|
|
4ae846cf8b | ||
|
|
4c601e8333 | ||
|
|
488afdcc74 | ||
|
|
f25feeeca3 | ||
|
|
b4f9ff2c62 | ||
|
|
bb6bcf41d1 | ||
|
|
12ca8e4296 | ||
|
|
352d2deb12 | ||
|
|
5c69e14d7c | ||
|
|
dae877113c | ||
|
|
b56371994f | ||
|
|
9b93403a93 | ||
|
|
18b76ebd5e | ||
|
|
2ba55f692d | ||
|
|
be9489f939 | ||
|
|
aecd9f844e | ||
|
|
8486c22c32 | ||
|
|
21ab5628b0 | ||
|
|
c1eeacc0b4 | ||
|
|
6ba6121988 | ||
|
|
757b67b78c | ||
|
|
bda93e354f | ||
|
|
0271e3f971 | ||
|
|
4b48485bcc | ||
|
|
d20079f3ed | ||
|
|
ba83922660 | ||
|
|
2e3b85d7f7 | ||
|
|
a7446d1314 | ||
|
|
46a27405ae | ||
|
|
66f8942936 |
@@ -33,30 +33,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## Version 1.36.0 - 2026-02-14
|
||||
|
||||
Increases the websocket heartbeat interval to reduce network overhead and improve connection efficiency. Loads subscriptions asynchronously to enhance responsiveness during data retrieval.
|
||||
|
||||
**Changes:** 1 files, 29 lines
|
||||
**Languages:** Python (29 lines)
|
||||
|
||||
## Version 1.35.0 - 2026-01-31
|
||||
|
||||
update py files
|
||||
|
||||
**Changes:** 1 files, 2 lines
|
||||
**Languages:** Python (2 lines)
|
||||
|
||||
## Version 1.34.0 - 2026-01-31
|
||||
|
||||
update css, html, py files
|
||||
|
||||
**Changes:** 44 files, 3802 lines
|
||||
**Languages:** CSS (712 lines), HTML (1300 lines), Python (1790 lines)
|
||||
|
||||
## Version 1.33.0 - 2026-01-24
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "Snek"
|
||||
version = "1.36.0"
|
||||
version = "1.33.0"
|
||||
readme = "README.md"
|
||||
#license = { file = "LICENSE", content-type="text/markdown" }
|
||||
description = "Snek Chat Application by Molodetz"
|
||||
|
||||
@@ -54,8 +54,7 @@ def load_env(file_path: str = '.env') -> None:
|
||||
# Skip lines without '='
|
||||
if '=' not in line:
|
||||
continue
|
||||
if line.startswith('export '):
|
||||
line = line[7:]
|
||||
# Split into key and value at the first '='
|
||||
key, value = line.split('=', 1)
|
||||
# Set environment variable
|
||||
os.environ[key.strip()] = value.strip()
|
||||
|
||||
@@ -79,29 +79,6 @@ from snek.view.settings.profile_pages import (
|
||||
)
|
||||
from snek.view.profile_page import ProfilePageView
|
||||
from snek.view.stats import StatsView
|
||||
from snek.view.admin import (
|
||||
AdminIndexView,
|
||||
AdminUsersIndexView,
|
||||
AdminUserEditView,
|
||||
AdminUserBanView,
|
||||
AdminChannelsIndexView,
|
||||
AdminChannelMembersView,
|
||||
AdminChannelEditView,
|
||||
AdminChannelClearView,
|
||||
AdminChannelDeleteView,
|
||||
AdminMessagesIndexView,
|
||||
AdminForumsIndexView,
|
||||
AdminThreadsView,
|
||||
AdminPostsView,
|
||||
AdminFilesIndexView,
|
||||
AdminDriveItemsView,
|
||||
AdminRepositoriesView,
|
||||
AdminNotificationsIndexView,
|
||||
AdminNotificationCreateView,
|
||||
AdminSystemIndexView,
|
||||
AdminKVView,
|
||||
AdminPushView,
|
||||
)
|
||||
from snek.view.status import StatusView
|
||||
from snek.view.terminal import TerminalSocketView, TerminalView
|
||||
from snek.view.upload import UploadView
|
||||
@@ -393,27 +370,6 @@ class Application(BaseApplication):
|
||||
self.router.add_view(
|
||||
"/settings/containers/container/{uid}/delete.html", ContainersDeleteView
|
||||
)
|
||||
self.router.add_view("/admin/index.html", AdminIndexView)
|
||||
self.router.add_view("/admin/users/index.html", AdminUsersIndexView)
|
||||
self.router.add_view("/admin/users/{user_uid}/edit.html", AdminUserEditView)
|
||||
self.router.add_view("/admin/users/{user_uid}/ban.html", AdminUserBanView)
|
||||
self.router.add_view("/admin/channels/index.html", AdminChannelsIndexView)
|
||||
self.router.add_view("/admin/channels/{channel_uid}/members.html", AdminChannelMembersView)
|
||||
self.router.add_view("/admin/channels/{channel_uid}/edit.html", AdminChannelEditView)
|
||||
self.router.add_view("/admin/channels/{channel_uid}/clear.html", AdminChannelClearView)
|
||||
self.router.add_view("/admin/channels/{channel_uid}/delete.html", AdminChannelDeleteView)
|
||||
self.router.add_view("/admin/messages/index.html", AdminMessagesIndexView)
|
||||
self.router.add_view("/admin/forums/index.html", AdminForumsIndexView)
|
||||
self.router.add_view("/admin/forums/{forum_uid}/threads.html", AdminThreadsView)
|
||||
self.router.add_view("/admin/forums/threads/{thread_uid}/posts.html", AdminPostsView)
|
||||
self.router.add_view("/admin/files/index.html", AdminFilesIndexView)
|
||||
self.router.add_view("/admin/files/{drive_uid}/items.html", AdminDriveItemsView)
|
||||
self.router.add_view("/admin/files/repositories.html", AdminRepositoriesView)
|
||||
self.router.add_view("/admin/notifications/index.html", AdminNotificationsIndexView)
|
||||
self.router.add_view("/admin/notifications/create.html", AdminNotificationCreateView)
|
||||
self.router.add_view("/admin/system/index.html", AdminSystemIndexView)
|
||||
self.router.add_view("/admin/system/kv.html", AdminKVView)
|
||||
self.router.add_view("/admin/system/push.html", AdminPushView)
|
||||
self.webdav = WebdavApplication(self)
|
||||
self.git = GitApplication(self)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import functools
|
||||
|
||||
from snek.service.admin import AdminService
|
||||
from snek.service.channel import ChannelService
|
||||
from snek.service.channel_attachment import ChannelAttachmentService
|
||||
from snek.service.channel_member import ChannelMemberService
|
||||
@@ -69,5 +68,4 @@ register_service("post", PostService)
|
||||
register_service("post_like", PostLikeService)
|
||||
register_service("profile_page", ProfilePageService)
|
||||
register_service("mention", MentionService)
|
||||
register_service("admin", AdminService)
|
||||
|
||||
|
||||
@@ -1,577 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminService(BaseService):
|
||||
|
||||
mapper_name = None
|
||||
|
||||
async def _require_admin(self, user_uid: str) -> bool:
|
||||
user = await self.services.user.get(uid=user_uid)
|
||||
if not user or not user["is_admin"]:
|
||||
raise PermissionError("Admin access required")
|
||||
return True
|
||||
|
||||
async def get_dashboard_stats(self) -> dict[str, Any]:
|
||||
logger.info("Fetching dashboard statistics")
|
||||
user_count = await self.services.user.count(deleted_at=None)
|
||||
channel_count = await self.services.channel.count(deleted_at=None)
|
||||
message_count = await self.services.channel_message.count(deleted_at=None)
|
||||
forum_count = await self.services.forum.count(deleted_at=None)
|
||||
thread_count = await self.services.thread.count(deleted_at=None)
|
||||
post_count = await self.services.post.count(deleted_at=None)
|
||||
connected_users = await self.services.socket.get_connected_users()
|
||||
online_count = len(connected_users)
|
||||
drive_count = await self.services.drive.count(deleted_at=None)
|
||||
repository_count = await self.services.repository.count(deleted_at=None)
|
||||
notification_count = await self.services.notification.count(deleted_at=None)
|
||||
logger.debug(f"Dashboard stats: users={user_count}, channels={channel_count}, messages={message_count}")
|
||||
return {
|
||||
"users": user_count,
|
||||
"channels": channel_count,
|
||||
"messages": message_count,
|
||||
"forums": forum_count,
|
||||
"threads": thread_count,
|
||||
"posts": post_count,
|
||||
"online": online_count,
|
||||
"drives": drive_count,
|
||||
"repositories": repository_count,
|
||||
"notifications": notification_count,
|
||||
}
|
||||
|
||||
async def get_recent_activity(self, limit: int = 20) -> list[dict]:
|
||||
logger.info(f"Fetching recent activity, limit={limit}")
|
||||
messages = []
|
||||
async for message in self.services.channel_message.find(_limit=limit, _order_by="-created_at", deleted_at=None):
|
||||
user = await self.services.user.get(uid=message["user_uid"])
|
||||
channel = await self.services.channel.get(uid=message["channel_uid"])
|
||||
messages.append({
|
||||
"uid": message["uid"],
|
||||
"content": message["message"][:100] if message["message"] else "",
|
||||
"created_at": str(message["created_at"]) if message["created_at"] else None,
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"user_uid": message["user_uid"],
|
||||
"channel_name": channel["name"] if channel else "Unknown",
|
||||
"channel_uid": message["channel_uid"],
|
||||
})
|
||||
logger.debug(f"Fetched {len(messages)} recent messages")
|
||||
return messages
|
||||
|
||||
async def list_users(self, page: int = 1, per_page: int = 20, search: str = None) -> dict:
|
||||
logger.info(f"Listing users: page={page}, per_page={per_page}, search={search}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
users = []
|
||||
async for user in self.services.user.find(**filters):
|
||||
if search and search.lower() not in (user["username"] or "").lower() and search.lower() not in (user["nick"] or "").lower():
|
||||
continue
|
||||
users.append({
|
||||
"uid": user["uid"],
|
||||
"username": user["username"],
|
||||
"nick": user["nick"],
|
||||
"is_admin": user["is_admin"] or False,
|
||||
"is_banned": user["is_banned"] or False,
|
||||
"created_at": str(user["created_at"]) if user["created_at"] else None,
|
||||
"last_online": str(user["last_online"]) if user["last_online"] else None,
|
||||
"city": user["city"],
|
||||
"country_short": user["country_short"],
|
||||
})
|
||||
total = await self.services.user.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(users)} users, total={total}")
|
||||
return {"users": users, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def update_user(self, admin_uid: str, target_uid: str, updates: dict) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} updating user {target_uid}")
|
||||
user = await self.services.user.get(uid=target_uid)
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
allowed_fields = ["nick", "is_admin", "is_banned", "color"]
|
||||
for field in allowed_fields:
|
||||
if field in updates:
|
||||
if field == "is_admin" and target_uid == admin_uid:
|
||||
raise ValueError("Cannot modify own admin status")
|
||||
user[field] = updates[field]
|
||||
await self.services.user.save(user)
|
||||
logger.info(f"User {target_uid} updated by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def ban_user(self, admin_uid: str, target_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} banning user {target_uid}")
|
||||
if admin_uid == target_uid:
|
||||
raise ValueError("Cannot ban yourself")
|
||||
user = await self.services.user.get(uid=target_uid)
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
user["is_banned"] = True
|
||||
await self.services.user.save(user)
|
||||
logger.info(f"User {target_uid} banned by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def unban_user(self, admin_uid: str, target_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} unbanning user {target_uid}")
|
||||
user = await self.services.user.get(uid=target_uid)
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
user["is_banned"] = False
|
||||
await self.services.user.save(user)
|
||||
logger.info(f"User {target_uid} unbanned by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def reset_password(self, admin_uid: str, target_uid: str, new_password: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} resetting password for user {target_uid}")
|
||||
if len(new_password) != 6:
|
||||
raise ValueError("Password must be exactly 6 characters")
|
||||
user = await self.services.user.get(uid=target_uid)
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
user["password"] = new_password
|
||||
await self.services.user.save(user)
|
||||
logger.info(f"Password reset for user {target_uid} by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_channels(self, page: int = 1, per_page: int = 20, search: str = None) -> dict:
|
||||
logger.info(f"Listing channels: page={page}, per_page={per_page}, search={search}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
channels = []
|
||||
async for channel in self.services.channel.find(**filters):
|
||||
if search and search.lower() not in (channel["name"] or "").lower():
|
||||
continue
|
||||
member_count = await self.services.channel_member.count(channel_uid=channel["uid"], deleted_at=None)
|
||||
message_count = await self.services.channel_message.count(channel_uid=channel["uid"], deleted_at=None)
|
||||
channels.append({
|
||||
"uid": channel["uid"],
|
||||
"name": channel["name"],
|
||||
"description": channel["description"] or "",
|
||||
"tag": channel["tag"],
|
||||
"is_private": channel["is_private"] or False,
|
||||
"created_at": str(channel["created_at"]) if channel["created_at"] else None,
|
||||
"member_count": member_count,
|
||||
"message_count": message_count,
|
||||
})
|
||||
total = await self.services.channel.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(channels)} channels, total={total}")
|
||||
return {"channels": channels, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def get_channel_members(self, channel_uid: str) -> list[dict]:
|
||||
logger.info(f"Getting members for channel {channel_uid}")
|
||||
members = []
|
||||
async for member in self.services.channel_member.find(channel_uid=channel_uid, deleted_at=None):
|
||||
user = await self.services.user.get(uid=member["user_uid"])
|
||||
members.append({
|
||||
"uid": member["uid"],
|
||||
"user_uid": member["user_uid"],
|
||||
"username": user["username"] if user else "Unknown",
|
||||
"nick": user["nick"] if user else "Unknown",
|
||||
"is_owner": member["is_owner"] or False,
|
||||
"is_banned": member["is_banned"] or False,
|
||||
"joined_at": str(member["created_at"]) if member["created_at"] else None,
|
||||
})
|
||||
logger.debug(f"Found {len(members)} members for channel {channel_uid}")
|
||||
return members
|
||||
|
||||
async def update_channel(self, admin_uid: str, channel_uid: str, updates: dict) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} updating channel {channel_uid}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
raise ValueError("Channel not found")
|
||||
allowed_fields = ["name", "description", "is_private"]
|
||||
for field in allowed_fields:
|
||||
if field in updates:
|
||||
channel[field] = updates[field]
|
||||
await self.services.channel.save(channel)
|
||||
logger.info(f"Channel {channel_uid} updated by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def delete_channel(self, admin_uid: str, channel_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting channel {channel_uid}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
raise ValueError("Channel not found")
|
||||
if channel["name"] == "public":
|
||||
raise ValueError("Cannot delete the public channel")
|
||||
channel["deleted_at"] = datetime.now()
|
||||
await self.services.channel.save(channel)
|
||||
logger.info(f"Channel {channel_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def clear_channel_history(self, admin_uid: str, channel_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} clearing history for channel {channel_uid}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
raise ValueError("Channel not found")
|
||||
deleted_count = 0
|
||||
async for message in self.services.channel_message.find(channel_uid=channel_uid, deleted_at=None):
|
||||
message["deleted_at"] = datetime.now()
|
||||
await self.services.channel_message.save(message)
|
||||
deleted_count += 1
|
||||
logger.info(f"Cleared {deleted_count} messages from channel {channel_uid}")
|
||||
return {"success": True, "deleted_count": deleted_count}
|
||||
|
||||
async def search_messages(self, query: str = None, channel_uid: str = None, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Searching messages: query={query}, channel={channel_uid}, user={user_uid}, page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset, "_order_by": "-created_at"}
|
||||
if channel_uid:
|
||||
filters["channel_uid"] = channel_uid
|
||||
if user_uid:
|
||||
filters["user_uid"] = user_uid
|
||||
messages = []
|
||||
async for message in self.services.channel_message.find(**filters):
|
||||
if query and query.lower() not in (message["message"] or "").lower():
|
||||
continue
|
||||
user = await self.services.user.get(uid=message["user_uid"])
|
||||
channel = await self.services.channel.get(uid=message["channel_uid"])
|
||||
messages.append({
|
||||
"uid": message["uid"],
|
||||
"content": message["message"][:200] if message["message"] else "",
|
||||
"created_at": str(message["created_at"]) if message["created_at"] else None,
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"user_uid": message["user_uid"],
|
||||
"channel_name": channel["name"] if channel else "Unknown",
|
||||
"channel_uid": message["channel_uid"],
|
||||
})
|
||||
total = await self.services.channel_message.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(messages)} messages")
|
||||
return {"messages": messages, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def delete_message(self, admin_uid: str, message_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting message {message_uid}")
|
||||
message = await self.services.channel_message.get(uid=message_uid)
|
||||
if not message:
|
||||
raise ValueError("Message not found")
|
||||
message["deleted_at"] = datetime.now()
|
||||
await self.services.channel_message.save(message)
|
||||
logger.info(f"Message {message_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_forums(self, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing forums: page={page}, per_page={per_page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
forums = []
|
||||
async for forum in self.services.forum.find(**filters):
|
||||
thread_count = await self.services.thread.count(forum_uid=forum["uid"], deleted_at=None)
|
||||
forums.append({
|
||||
"uid": forum["uid"],
|
||||
"name": forum["name"],
|
||||
"description": forum["description"] or "",
|
||||
"is_active": forum["is_active"] if forum["is_active"] is not None else True,
|
||||
"created_at": str(forum["created_at"]) if forum["created_at"] else None,
|
||||
"thread_count": thread_count,
|
||||
})
|
||||
total = await self.services.forum.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(forums)} forums, total={total}")
|
||||
return {"forums": forums, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def update_forum(self, admin_uid: str, forum_uid: str, updates: dict) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} updating forum {forum_uid}")
|
||||
forum = await self.services.forum.get(uid=forum_uid)
|
||||
if not forum:
|
||||
raise ValueError("Forum not found")
|
||||
allowed_fields = ["name", "description", "is_active"]
|
||||
for field in allowed_fields:
|
||||
if field in updates:
|
||||
forum[field] = updates[field]
|
||||
await self.services.forum.save(forum)
|
||||
logger.info(f"Forum {forum_uid} updated by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def delete_forum(self, admin_uid: str, forum_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting forum {forum_uid}")
|
||||
forum = await self.services.forum.get(uid=forum_uid)
|
||||
if not forum:
|
||||
raise ValueError("Forum not found")
|
||||
forum["deleted_at"] = datetime.now()
|
||||
await self.services.forum.save(forum)
|
||||
logger.info(f"Forum {forum_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_threads(self, forum_uid: str, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing threads for forum {forum_uid}: page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"forum_uid": forum_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
threads = []
|
||||
async for thread in self.services.thread.find(**filters):
|
||||
user = await self.services.user.get(uid=thread["created_by_uid"])
|
||||
post_count = await self.services.post.count(thread_uid=thread["uid"], deleted_at=None)
|
||||
threads.append({
|
||||
"uid": thread["uid"],
|
||||
"title": thread["title"],
|
||||
"is_pinned": thread["is_pinned"] or False,
|
||||
"is_locked": thread["is_locked"] or False,
|
||||
"created_at": str(thread["created_at"]) if thread["created_at"] else None,
|
||||
"created_by_nick": user["nick"] if user else "Unknown",
|
||||
"created_by_uid": thread["created_by_uid"],
|
||||
"post_count": post_count,
|
||||
})
|
||||
total = await self.services.thread.count(forum_uid=forum_uid, deleted_at=None)
|
||||
logger.debug(f"Found {len(threads)} threads, total={total}")
|
||||
return {"threads": threads, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def toggle_thread_pin(self, admin_uid: str, thread_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} toggling pin for thread {thread_uid}")
|
||||
thread = await self.services.thread.get(uid=thread_uid)
|
||||
if not thread:
|
||||
raise ValueError("Thread not found")
|
||||
thread["is_pinned"] = not (thread["is_pinned"] or False)
|
||||
await self.services.thread.save(thread)
|
||||
logger.info(f"Thread {thread_uid} pin toggled to {thread['is_pinned']}")
|
||||
return {"success": True, "is_pinned": thread["is_pinned"]}
|
||||
|
||||
async def toggle_thread_lock(self, admin_uid: str, thread_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} toggling lock for thread {thread_uid}")
|
||||
thread = await self.services.thread.get(uid=thread_uid)
|
||||
if not thread:
|
||||
raise ValueError("Thread not found")
|
||||
thread["is_locked"] = not (thread["is_locked"] or False)
|
||||
await self.services.thread.save(thread)
|
||||
logger.info(f"Thread {thread_uid} lock toggled to {thread['is_locked']}")
|
||||
return {"success": True, "is_locked": thread["is_locked"]}
|
||||
|
||||
async def delete_thread(self, admin_uid: str, thread_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting thread {thread_uid}")
|
||||
thread = await self.services.thread.get(uid=thread_uid)
|
||||
if not thread:
|
||||
raise ValueError("Thread not found")
|
||||
thread["deleted_at"] = datetime.now()
|
||||
await self.services.thread.save(thread)
|
||||
logger.info(f"Thread {thread_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_posts(self, thread_uid: str, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing posts for thread {thread_uid}: page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"thread_uid": thread_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
posts = []
|
||||
async for post in self.services.post.find(**filters):
|
||||
user = await self.services.user.get(uid=post["created_by_uid"])
|
||||
posts.append({
|
||||
"uid": post["uid"],
|
||||
"content": post["content"][:200] if post["content"] else "",
|
||||
"created_at": str(post["created_at"]) if post["created_at"] else None,
|
||||
"created_by_nick": user["nick"] if user else "Unknown",
|
||||
"created_by_uid": post["created_by_uid"],
|
||||
})
|
||||
total = await self.services.post.count(thread_uid=thread_uid, deleted_at=None)
|
||||
logger.debug(f"Found {len(posts)} posts, total={total}")
|
||||
return {"posts": posts, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def delete_post(self, admin_uid: str, post_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting post {post_uid}")
|
||||
post = await self.services.post.get(uid=post_uid)
|
||||
if not post:
|
||||
raise ValueError("Post not found")
|
||||
post["deleted_at"] = datetime.now()
|
||||
await self.services.post.save(post)
|
||||
logger.info(f"Post {post_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_drives(self, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing drives: user_uid={user_uid}, page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
if user_uid:
|
||||
filters["user_uid"] = user_uid
|
||||
drives = []
|
||||
async for drive in self.services.drive.find(**filters):
|
||||
user = await self.services.user.get(uid=drive["user_uid"])
|
||||
item_count = await self.services.drive_item.count(drive_uid=drive["uid"], deleted_at=None)
|
||||
drives.append({
|
||||
"uid": drive["uid"],
|
||||
"name": drive["name"] or "Drive",
|
||||
"user_uid": drive["user_uid"],
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"created_at": str(drive["created_at"]) if drive["created_at"] else None,
|
||||
"item_count": item_count,
|
||||
})
|
||||
total = await self.services.drive.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(drives)} drives, total={total}")
|
||||
return {"drives": drives, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def list_drive_items(self, drive_uid: str, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing items for drive {drive_uid}: page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"drive_uid": drive_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
items = []
|
||||
async for item in self.services.drive_item.find(**filters):
|
||||
items.append({
|
||||
"uid": item["uid"],
|
||||
"name": item["name"] or "",
|
||||
"path": item["path"] or "",
|
||||
"size": item["size"] or 0,
|
||||
"mime_type": item["mime_type"] or "",
|
||||
"created_at": str(item["created_at"]) if item["created_at"] else None,
|
||||
})
|
||||
total = await self.services.drive_item.count(drive_uid=drive_uid, deleted_at=None)
|
||||
logger.debug(f"Found {len(items)} items, total={total}")
|
||||
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def delete_drive_item(self, admin_uid: str, item_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting drive item {item_uid}")
|
||||
item = await self.services.drive_item.get(uid=item_uid)
|
||||
if not item:
|
||||
raise ValueError("Drive item not found")
|
||||
item["deleted_at"] = datetime.now()
|
||||
await self.services.drive_item.save(item)
|
||||
logger.info(f"Drive item {item_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_repositories(self, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing repositories: user_uid={user_uid}, page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
if user_uid:
|
||||
filters["user_uid"] = user_uid
|
||||
repositories = []
|
||||
async for repo in self.services.repository.find(**filters):
|
||||
user = await self.services.user.get(uid=repo["user_uid"])
|
||||
repositories.append({
|
||||
"uid": repo["uid"],
|
||||
"name": repo["name"],
|
||||
"user_uid": repo["user_uid"],
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"is_private": repo["is_private"] or False,
|
||||
"created_at": str(repo["created_at"]) if repo["created_at"] else None,
|
||||
})
|
||||
total = await self.services.repository.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(repositories)} repositories, total={total}")
|
||||
return {"repositories": repositories, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def delete_repository(self, admin_uid: str, repo_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting repository {repo_uid}")
|
||||
repo = await self.services.repository.get(uid=repo_uid)
|
||||
if not repo:
|
||||
raise ValueError("Repository not found")
|
||||
repo["deleted_at"] = datetime.now()
|
||||
await self.services.repository.save(repo)
|
||||
logger.info(f"Repository {repo_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_notifications(self, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing notifications: page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset, "_order_by": "-created_at"}
|
||||
notifications = []
|
||||
async for notif in self.services.notification.find(**filters):
|
||||
user = await self.services.user.get(uid=notif["user_uid"])
|
||||
notifications.append({
|
||||
"uid": notif["uid"],
|
||||
"message": (notif["message"] or "")[:100],
|
||||
"type": notif["type"] or "",
|
||||
"user_uid": notif["user_uid"],
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"is_read": notif["is_read"] or False,
|
||||
"created_at": str(notif["created_at"]) if notif["created_at"] else None,
|
||||
})
|
||||
total = await self.services.notification.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(notifications)} notifications, total={total}")
|
||||
return {"notifications": notifications, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def mass_notify(self, admin_uid: str, message: str, user_uids: list[str] = None) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} sending mass notification")
|
||||
if not message or len(message) > 500:
|
||||
raise ValueError("Message must be between 1 and 500 characters")
|
||||
sent_count = 0
|
||||
if user_uids:
|
||||
target_users = user_uids
|
||||
else:
|
||||
target_users = []
|
||||
async for user in self.services.user.find(deleted_at=None, is_banned=False):
|
||||
target_users.append(user["uid"])
|
||||
for user_uid in target_users:
|
||||
await self.services.notification.create(
|
||||
user_uid=user_uid,
|
||||
message=message,
|
||||
notification_type="admin",
|
||||
)
|
||||
sent_count += 1
|
||||
logger.info(f"Mass notification sent to {sent_count} users by admin {admin_uid}")
|
||||
return {"success": True, "sent_count": sent_count}
|
||||
|
||||
async def get_kv_entries(self) -> list[dict]:
|
||||
logger.info("Fetching KV store entries")
|
||||
entries = []
|
||||
for row in self.app.db.query("SELECT key, value FROM kv ORDER BY key"):
|
||||
entries.append({"key": row["key"], "value": row["value"]})
|
||||
logger.debug(f"Found {len(entries)} KV entries")
|
||||
return entries
|
||||
|
||||
async def set_kv_entry(self, admin_uid: str, key: str, value: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} setting KV entry: key={key}")
|
||||
if not key or len(key) > 255:
|
||||
raise ValueError("Key must be between 1 and 255 characters")
|
||||
if len(value) > 10000:
|
||||
raise ValueError("Value must be less than 10000 characters")
|
||||
self.app.db["kv"].upsert({"key": key, "value": value}, ["key"])
|
||||
logger.info(f"KV entry set: key={key}")
|
||||
return {"success": True}
|
||||
|
||||
async def delete_kv_entry(self, admin_uid: str, key: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting KV entry: key={key}")
|
||||
self.app.db["kv"].delete(key=key)
|
||||
logger.info(f"KV entry deleted: key={key}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_push_registrations(self, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing push registrations: page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
registrations = []
|
||||
async for reg in self.services.push.find(**filters):
|
||||
user = await self.services.user.get(uid=reg["user_uid"])
|
||||
registrations.append({
|
||||
"uid": reg["uid"],
|
||||
"user_uid": reg["user_uid"],
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"endpoint": (reg["endpoint"] or "")[:50] + "..." if reg["endpoint"] else "",
|
||||
"created_at": str(reg["created_at"]) if reg["created_at"] else None,
|
||||
})
|
||||
total = await self.services.push.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(registrations)} push registrations, total={total}")
|
||||
return {"registrations": registrations, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def delete_push_registration(self, admin_uid: str, registration_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting push registration {registration_uid}")
|
||||
reg = await self.services.push.get(uid=registration_uid)
|
||||
if not reg:
|
||||
raise ValueError("Push registration not found")
|
||||
reg["deleted_at"] = datetime.now()
|
||||
await self.services.push.save(reg)
|
||||
logger.info(f"Push registration {registration_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def run_maintenance(self, admin_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} running maintenance")
|
||||
self.app.db.query("VACUUM")
|
||||
self.app.db.query("ANALYZE")
|
||||
logger.info("Maintenance completed: VACUUM and ANALYZE")
|
||||
return {"success": True, "message": "Database maintenance completed"}
|
||||
@@ -70,7 +70,7 @@ class SocketService(BaseService):
|
||||
self.subscribed_channels.clear()
|
||||
try:
|
||||
if self.ws and not self.ws.closed:
|
||||
await asyncio.wait_for(self.ws.close(), timeout=300.0)
|
||||
await asyncio.wait_for(self.ws.close(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Socket close timed out")
|
||||
except Exception as ex:
|
||||
|
||||
@@ -102,8 +102,6 @@ class UserService(BaseService):
|
||||
model.email.value = email
|
||||
model.username.value = username
|
||||
model.password.value = await security.hash(password)
|
||||
if not await self.count():
|
||||
model["is_admin"] = True
|
||||
if await self.save(model):
|
||||
if model:
|
||||
channel = await self.services.channel.ensure_public_channel(
|
||||
|
||||
@@ -1,696 +0,0 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
html:has(body.admin-page) {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body.admin-page {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
body.admin-page main {
|
||||
overflow: visible;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--admin-bg: #000000;
|
||||
--admin-bg-secondary: #0f0f0f;
|
||||
--admin-bg-tertiary: #1a1a1a;
|
||||
--admin-accent: #f05a28;
|
||||
--admin-accent-hover: #e04924;
|
||||
--admin-text: #e6e6e6;
|
||||
--admin-text-muted: #888;
|
||||
--admin-text-faded: #aaa;
|
||||
--admin-border: #333;
|
||||
--admin-border-light: #444;
|
||||
--admin-danger: #8b0000;
|
||||
--admin-danger-hover: #a00000;
|
||||
--admin-warning: #c9a227;
|
||||
--admin-success: #2e7d32;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 20px;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.admin-sidebar h2 {
|
||||
color: var(--admin-accent);
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-sidebar hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--admin-border);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.admin-stats {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.admin-stats h2 {
|
||||
margin-bottom: 20px;
|
||||
color: var(--admin-accent);
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.admin-stats h3 {
|
||||
margin-top: 25px;
|
||||
margin-bottom: 15px;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.9em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
font-size: 0.85em;
|
||||
color: var(--admin-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.stat-card.stat-highlight {
|
||||
border-color: var(--admin-accent);
|
||||
background: linear-gradient(135deg, var(--admin-bg-secondary) 0%, rgba(240, 90, 40, 0.1) 100%);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.75em;
|
||||
color: var(--admin-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 2.5em;
|
||||
font-weight: bold;
|
||||
color: var(--admin-accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
display: block;
|
||||
font-size: 0.8em;
|
||||
color: var(--admin-success);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
display: block;
|
||||
font-size: 0.8em;
|
||||
color: var(--admin-text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
display: block;
|
||||
font-size: 1.1em;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.admin-activity {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.admin-activity h2 {
|
||||
margin-bottom: 15px;
|
||||
color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--admin-bg-secondary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-table thead {
|
||||
background: var(--admin-bg-tertiary);
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
text-align: left;
|
||||
padding: 12px 15px;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.85em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
.admin-table td {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.admin-table tbody tr:hover {
|
||||
background: var(--admin-bg-tertiary);
|
||||
}
|
||||
|
||||
.admin-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.message-preview,
|
||||
.description-preview,
|
||||
.value-preview,
|
||||
.path-preview,
|
||||
.endpoint-preview {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-banned {
|
||||
background: rgba(139, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.row-inactive {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.row-pinned {
|
||||
background: rgba(240, 90, 40, 0.1);
|
||||
}
|
||||
|
||||
.row-locked {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.row-read {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--admin-border);
|
||||
background: var(--admin-bg-tertiary);
|
||||
color: var(--admin-text);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: var(--admin-border-light);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--admin-accent);
|
||||
border-color: var(--admin-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--admin-accent-hover);
|
||||
border-color: var(--admin-accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--admin-bg-tertiary);
|
||||
border-color: var(--admin-border);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--admin-danger);
|
||||
border-color: var(--admin-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--admin-danger-hover);
|
||||
border-color: var(--admin-danger-hover);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--admin-warning);
|
||||
border-color: var(--admin-warning);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #d4ab2a;
|
||||
border-color: #d4ab2a;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-search {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-search form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 4px;
|
||||
background: var(--admin-bg-secondary);
|
||||
color: var(--admin-text);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.search-select {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 4px;
|
||||
background: var(--admin-bg-secondary);
|
||||
color: var(--admin-text);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.search-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.admin-pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.pagination-pages {
|
||||
color: var(--admin-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
.admin-header h2 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.admin-header p {
|
||||
color: var(--admin-text-muted);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
max-width: 600px;
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-form h2 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="color"],
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 4px;
|
||||
background: var(--admin-bg);
|
||||
color: var(--admin-text);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.form-group.checkbox label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.form-group.checkbox input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-actions-inline {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.radio-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.radio-group input[type="radio"] {
|
||||
accent-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.user-checkboxes {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
background: var(--admin-bg);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: block;
|
||||
padding: 5px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
margin-right: 10px;
|
||||
accent-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
border-color: var(--admin-danger);
|
||||
background: rgba(139, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.danger-zone h2 {
|
||||
color: var(--admin-danger);
|
||||
}
|
||||
|
||||
.danger-zone p {
|
||||
color: var(--admin-text-muted);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-info {
|
||||
max-width: 600px;
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
.admin-info h3 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 15px;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.admin-info dl {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-info dt {
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.admin-info dd {
|
||||
color: var(--admin-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-list {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-list h2 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-actions-section {
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin-bottom: 20px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.admin-actions-section h2 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-actions-section p {
|
||||
color: var(--admin-text-muted);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-links {
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.admin-links h2 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-links ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.admin-links li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.admin-links a {
|
||||
color: var(--admin-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: rgba(139, 0, 0, 0.2);
|
||||
border: 1px solid var(--admin-danger);
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: rgba(46, 125, 50, 0.2);
|
||||
border: 1px solid var(--admin-success);
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--admin-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-input,
|
||||
.search-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -43,22 +43,6 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html:has(body.profile-page) {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body.profile-page {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
body.profile-page main {
|
||||
overflow: visible;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -860,41 +844,6 @@ dialog .dialog-button.danger:disabled {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.message-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
font-size: 0.9em;
|
||||
background-color: var(--bg-secondary, #1e1e1e);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-content thead th {
|
||||
background-color: var(--accent-color, #f05a28);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
th {
|
||||
min-width: 100px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.message-content tbody td {
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
.message-content tbody tr:nth-child(even) {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.message-content tbody tr:hover {
|
||||
background-color: rgba(240, 90, 40, 0.08);
|
||||
}
|
||||
|
||||
.message-content .table-wrapper {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export class Socket extends EventHandler {
|
||||
_maxReconnectAttempts = 50;
|
||||
_reconnectDelay = 4000;
|
||||
_pendingCalls = new Map();
|
||||
_callTimeout = 300000;
|
||||
_callTimeout = 30000;
|
||||
_isDestroyed = false;
|
||||
|
||||
get isConnected() {
|
||||
|
||||
@@ -7,7 +7,6 @@ from app.cache import time_cache_async, time_cache
|
||||
from mistune import HTMLRenderer, Markdown
|
||||
from mistune.plugins.formatting import strikethrough
|
||||
from mistune.plugins.spoiler import spoiler
|
||||
from mistune.plugins.table import table
|
||||
from mistune.plugins.url import url
|
||||
from pygments import highlight
|
||||
from pygments.formatters import html
|
||||
@@ -81,7 +80,7 @@ class MarkdownRenderer(HTMLRenderer):
|
||||
|
||||
def render_markdown_sync(app, markdown_string):
|
||||
renderer = MarkdownRenderer(app, None)
|
||||
markdown = Markdown(renderer=renderer, plugins=[url, strikethrough, spoiler, table])
|
||||
markdown = Markdown(renderer=renderer, plugins=[url, strikethrough, spoiler])
|
||||
return markdown(markdown_string)
|
||||
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{% extends "app.html" %}
|
||||
|
||||
{% block body_class %}admin-page{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include "admin/sidebar.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="/admin.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Admin Panel</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
{% endblock main %}
|
||||
@@ -1,26 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Clear Channel History</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form danger-zone">
|
||||
<h2>{{ channel.name }}</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<p>This action will permanently delete all messages in this channel. This cannot be undone.</p>
|
||||
|
||||
<form method="post" action="/admin/channels/{{ channel.uid }}/clear.html">
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-danger">Clear All Messages</button>
|
||||
<a href="/admin/channels/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,26 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Delete Channel</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form danger-zone">
|
||||
<h2>{{ channel.name }}</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<p>This action will permanently delete this channel and all its messages. This cannot be undone.</p>
|
||||
|
||||
<form method="post" action="/admin/channels/{{ channel.uid }}/delete.html">
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-danger">Delete Channel</button>
|
||||
<a href="/admin/channels/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,53 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Edit Channel</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form">
|
||||
<h2>{{ channel.name }}</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/channels/{{ channel.uid }}/edit.html">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{ channel.name }}" maxlength="50" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" rows="3" maxlength="500">{{ channel.description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="is_private" {% if channel.is_private %}checked{% endif %}>
|
||||
Private Channel
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<a href="/admin/channels/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-info">
|
||||
<h3>Channel Information</h3>
|
||||
<dl>
|
||||
<dt>UID</dt>
|
||||
<dd>{{ channel.uid }}</dd>
|
||||
<dt>Type</dt>
|
||||
<dd>{{ channel.tag or 'channel' }}</dd>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ channel.created_at }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,70 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Channels</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-search">
|
||||
<form method="get" action="/admin/channels/index.html">
|
||||
<input type="text" name="search" placeholder="Search channels..." value="{{ search }}" class="search-input">
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
{% if search %}
|
||||
<a href="/admin/channels/index.html" class="btn btn-secondary">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Private</th>
|
||||
<th>Members</th>
|
||||
<th>Messages</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for channel in channels %}
|
||||
<tr>
|
||||
<td>{{ channel.name }}</td>
|
||||
<td>{{ channel.tag or 'channel' }}</td>
|
||||
<td>{% if channel.is_private %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ channel.member_count }}</td>
|
||||
<td>{{ channel.message_count }}</td>
|
||||
<td>{{ channel.created_at }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/channels/{{ channel.uid }}/members.html" class="btn btn-small">Members</a>
|
||||
<a href="/admin/channels/{{ channel.uid }}/edit.html" class="btn btn-small">Edit</a>
|
||||
<a href="/admin/channels/{{ channel.uid }}/clear.html" class="btn btn-small btn-warning">Clear</a>
|
||||
<a href="/admin/channels/{{ channel.uid }}/delete.html" class="btn btn-small btn-danger">Delete</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7">No channels found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="admin-pagination">
|
||||
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }} channels</span>
|
||||
<div class="pagination-controls">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/channels/index.html?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/channels/index.html?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,48 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Channel Members</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-header">
|
||||
<h2>{{ channel.name }}</h2>
|
||||
<p>{{ channel.description }}</p>
|
||||
<a href="/admin/channels/index.html" class="btn btn-secondary">Back to Channels</a>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Nick</th>
|
||||
<th>Owner</th>
|
||||
<th>Banned</th>
|
||||
<th>Joined</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for member in members %}
|
||||
<tr class="{% if member.is_banned %}row-banned{% endif %}">
|
||||
<td>{{ member.username }}</td>
|
||||
<td>{{ member.nick }}</td>
|
||||
<td>{% if member.is_owner %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{% if member.is_banned %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ member.joined_at }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/users/{{ member.user_uid }}/edit.html" class="btn btn-small">Edit User</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6">No members found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,67 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Drives</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-search">
|
||||
<form method="get" action="/admin/files/index.html">
|
||||
<select name="user_uid" class="search-select">
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.uid }}" {% if user.uid == user_uid %}selected{% endif %}>{{ user.nick }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary">Filter</button>
|
||||
{% if user_uid %}
|
||||
<a href="/admin/files/index.html" class="btn btn-secondary">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Owner</th>
|
||||
<th>Items</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for drive in drives %}
|
||||
<tr>
|
||||
<td>{{ drive.name }}</td>
|
||||
<td><a href="/admin/users/{{ drive.user_uid }}/edit.html">{{ drive.user_nick }}</a></td>
|
||||
<td>{{ drive.item_count }}</td>
|
||||
<td>{{ drive.created_at }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/files/{{ drive.uid }}/items.html" class="btn btn-small">View Items</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">No drives found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/files/index.html?page={{ page - 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/files/index.html?page={{ page + 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,63 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Drive Items</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-header">
|
||||
<h2>{{ drive.name or 'Drive' }}</h2>
|
||||
<p>Owner: {{ user.nick if user else 'Unknown' }}</p>
|
||||
<a href="/admin/files/index.html" class="btn btn-secondary">Back to Drives</a>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Path</th>
|
||||
<th>Size</th>
|
||||
<th>Type</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>{{ item.name }}</td>
|
||||
<td class="path-preview">{{ item.path }}</td>
|
||||
<td>{{ item.size }}</td>
|
||||
<td>{{ item.mime_type }}</td>
|
||||
<td>{{ item.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/files/{{ drive.uid }}/items.html" style="display:inline;">
|
||||
<input type="hidden" name="item_uid" value="{{ item.uid }}">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this item?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6">No items found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/files/{{ drive.uid }}/items.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/files/{{ drive.uid }}/items.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,70 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Repositories</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-search">
|
||||
<form method="get" action="/admin/files/repositories.html">
|
||||
<select name="user_uid" class="search-select">
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.uid }}" {% if user.uid == user_uid %}selected{% endif %}>{{ user.nick }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary">Filter</button>
|
||||
{% if user_uid %}
|
||||
<a href="/admin/files/repositories.html" class="btn btn-secondary">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Owner</th>
|
||||
<th>Private</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for repo in repositories %}
|
||||
<tr>
|
||||
<td>{{ repo.name }}</td>
|
||||
<td><a href="/admin/users/{{ repo.user_uid }}/edit.html">{{ repo.user_nick }}</a></td>
|
||||
<td>{% if repo.is_private %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ repo.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/files/repositories.html?{% if user_uid %}user_uid={{ user_uid }}{% endif %}" style="display:inline;">
|
||||
<input type="hidden" name="repo_uid" value="{{ repo.uid }}">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this repository?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">No repositories found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/files/repositories.html?page={{ page - 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/files/repositories.html?page={{ page + 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,64 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Forums</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Active</th>
|
||||
<th>Threads</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for forum in forums %}
|
||||
<tr class="{% if not forum.is_active %}row-inactive{% endif %}">
|
||||
<td>{{ forum.name }}</td>
|
||||
<td class="description-preview">{{ forum.description }}</td>
|
||||
<td>{% if forum.is_active %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ forum.thread_count }}</td>
|
||||
<td>{{ forum.created_at }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/forums/{{ forum.uid }}/threads.html" class="btn btn-small">Threads</a>
|
||||
<form method="post" action="/admin/forums/index.html" style="display:inline;">
|
||||
<input type="hidden" name="forum_uid" value="{{ forum.uid }}">
|
||||
<input type="hidden" name="action" value="toggle_active">
|
||||
<button type="submit" class="btn btn-small">{% if forum.is_active %}Deactivate{% else %}Activate{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/forums/index.html" style="display:inline;">
|
||||
<input type="hidden" name="forum_uid" value="{{ forum.uid }}">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this forum?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6">No forums found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/forums/index.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/forums/index.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,60 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Posts</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-header">
|
||||
<h2>{{ thread.title }}</h2>
|
||||
<p>Forum: {{ forum.name }}</p>
|
||||
<a href="/admin/forums/{{ forum.uid }}/threads.html" class="btn btn-secondary">Back to Threads</a>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Author</th>
|
||||
<th>Content</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for post in posts %}
|
||||
<tr>
|
||||
<td><a href="/admin/users/{{ post.created_by_uid }}/edit.html">{{ post.created_by_nick }}</a></td>
|
||||
<td class="message-preview">{{ post.content }}</td>
|
||||
<td>{{ post.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/forums/threads/{{ thread.uid }}/posts.html" style="display:inline;">
|
||||
<input type="hidden" name="post_uid" value="{{ post.uid }}">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this post?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4">No posts found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/forums/threads/{{ thread.uid }}/posts.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/forums/threads/{{ thread.uid }}/posts.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,77 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Threads</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-header">
|
||||
<h2>{{ forum.name }}</h2>
|
||||
<p>{{ forum.description }}</p>
|
||||
<a href="/admin/forums/index.html" class="btn btn-secondary">Back to Forums</a>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Author</th>
|
||||
<th>Pinned</th>
|
||||
<th>Locked</th>
|
||||
<th>Posts</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for thread in threads %}
|
||||
<tr class="{% if thread.is_pinned %}row-pinned{% endif %} {% if thread.is_locked %}row-locked{% endif %}">
|
||||
<td>{{ thread.title }}</td>
|
||||
<td><a href="/admin/users/{{ thread.created_by_uid }}/edit.html">{{ thread.created_by_nick }}</a></td>
|
||||
<td>{% if thread.is_pinned %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{% if thread.is_locked %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ thread.post_count }}</td>
|
||||
<td>{{ thread.created_at }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/forums/threads/{{ thread.uid }}/posts.html" class="btn btn-small">Posts</a>
|
||||
<form method="post" action="/admin/forums/{{ forum.uid }}/threads.html" style="display:inline;">
|
||||
<input type="hidden" name="thread_uid" value="{{ thread.uid }}">
|
||||
<input type="hidden" name="action" value="toggle_pin">
|
||||
<button type="submit" class="btn btn-small">{% if thread.is_pinned %}Unpin{% else %}Pin{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/forums/{{ forum.uid }}/threads.html" style="display:inline;">
|
||||
<input type="hidden" name="thread_uid" value="{{ thread.uid }}">
|
||||
<input type="hidden" name="action" value="toggle_lock">
|
||||
<button type="submit" class="btn btn-small">{% if thread.is_locked %}Unlock{% else %}Lock{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/forums/{{ forum.uid }}/threads.html" style="display:inline;">
|
||||
<input type="hidden" name="thread_uid" value="{{ thread.uid }}">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this thread?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7">No threads found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/forums/{{ forum.uid }}/threads.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/forums/{{ forum.uid }}/threads.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,88 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Dashboard</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-stats">
|
||||
<h2>System Overview</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card stat-highlight">
|
||||
<span class="stat-label">Total Users</span>
|
||||
<span class="stat-value">{{ stats.users }}</span>
|
||||
<span class="stat-sub">{{ stats.online }} currently online</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Channels</span>
|
||||
<span class="stat-value">{{ stats.channels }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Messages</span>
|
||||
<span class="stat-value">{{ stats.messages }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Notifications</span>
|
||||
<span class="stat-value">{{ stats.notifications }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Forums</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Forums</span>
|
||||
<span class="stat-value">{{ stats.forums }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Threads</span>
|
||||
<span class="stat-value">{{ stats.threads }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Posts</span>
|
||||
<span class="stat-value">{{ stats.posts }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Storage</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">User Drives</span>
|
||||
<span class="stat-value">{{ stats.drives }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Git Repositories</span>
|
||||
<span class="stat-value">{{ stats.repositories }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-activity">
|
||||
<h2>Recent Messages</h2>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Channel</th>
|
||||
<th>Message</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in activity %}
|
||||
<tr>
|
||||
<td><a href="/admin/users/{{ item.user_uid }}/edit.html">{{ item.user_nick }}</a></td>
|
||||
<td><a href="/admin/channels/{{ item.channel_uid }}/edit.html">{{ item.channel_name }}</a></td>
|
||||
<td class="message-preview">{{ item.content }}</td>
|
||||
<td class="time-cell">{{ item.created_at }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4">No recent activity</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,80 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Messages</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-search">
|
||||
<form method="get" action="/admin/messages/index.html">
|
||||
<div class="search-row">
|
||||
<input type="text" name="query" placeholder="Search messages..." value="{{ query }}" class="search-input">
|
||||
<select name="channel_uid" class="search-select">
|
||||
<option value="">All Channels</option>
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.uid }}" {% if channel.uid == channel_uid %}selected{% endif %}>{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="user_uid" class="search-select">
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.uid }}" {% if user.uid == user_uid %}selected{% endif %}>{{ user.nick }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
{% if query or channel_uid or user_uid %}
|
||||
<a href="/admin/messages/index.html" class="btn btn-secondary">Clear</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Channel</th>
|
||||
<th>Message</th>
|
||||
<th>Time</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for message in messages %}
|
||||
<tr>
|
||||
<td><a href="/admin/users/{{ message.user_uid }}/edit.html">{{ message.user_nick }}</a></td>
|
||||
<td><a href="/admin/channels/{{ message.channel_uid }}/edit.html">{{ message.channel_name }}</a></td>
|
||||
<td class="message-preview">{{ message.content }}</td>
|
||||
<td>{{ message.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/messages/index.html?{{ request.query_string }}" style="display:inline;">
|
||||
<input type="hidden" name="message_uid" value="{{ message.uid }}">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this message?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">No messages found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="admin-pagination">
|
||||
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }} messages</span>
|
||||
<div class="pagination-controls">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/messages/index.html?page={{ page - 1 }}{% if query %}&query={{ query }}{% endif %}{% if channel_uid %}&channel_uid={{ channel_uid }}{% endif %}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/messages/index.html?page={{ page + 1 }}{% if query %}&query={{ query }}{% endif %}{% if channel_uid %}&channel_uid={{ channel_uid }}{% endif %}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,65 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Create Notification</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form">
|
||||
<h2>Mass Notification</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/notifications/create.html">
|
||||
<div class="form-group">
|
||||
<label for="message">Message</label>
|
||||
<textarea id="message" name="message" rows="4" maxlength="500" required placeholder="Enter notification message...">{{ message or '' }}</textarea>
|
||||
<small>Maximum 500 characters</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Recipients</label>
|
||||
<div class="radio-group">
|
||||
<label>
|
||||
<input type="radio" name="target" value="all" checked>
|
||||
All Users
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="target" value="selected">
|
||||
Selected Users
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="user-select-group" style="display:none;">
|
||||
<label>Select Users</label>
|
||||
<div class="user-checkboxes">
|
||||
{% for user in users %}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="user_uids" value="{{ user.uid }}">
|
||||
{{ user.nick }} ({{ user.username }})
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Send Notification</button>
|
||||
<a href="/admin/notifications/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('input[name="target"]').forEach(function(radio) {
|
||||
radio.addEventListener('change', function() {
|
||||
var userGroup = document.getElementById('user-select-group');
|
||||
userGroup.style.display = this.value === 'selected' ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock main %}
|
||||
@@ -1,55 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Notifications</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-header">
|
||||
<a href="/admin/notifications/create.html" class="btn btn-primary">Create Mass Notification</a>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Type</th>
|
||||
<th>Message</th>
|
||||
<th>Read</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for notif in notifications %}
|
||||
<tr class="{% if notif.is_read %}row-read{% endif %}">
|
||||
<td><a href="/admin/users/{{ notif.user_uid }}/edit.html">{{ notif.user_nick }}</a></td>
|
||||
<td>{{ notif.type }}</td>
|
||||
<td class="message-preview">{{ notif.message }}</td>
|
||||
<td>{% if notif.is_read %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ notif.created_at }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">No notifications found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="admin-pagination">
|
||||
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }}</span>
|
||||
<div class="pagination-controls">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/notifications/index.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/notifications/index.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,20 +0,0 @@
|
||||
<aside class="sidebar admin-sidebar" id="channelSidebar">
|
||||
<h2>Admin</h2>
|
||||
<ul>
|
||||
<li><a class="no-select" href="/admin/index.html">Dashboard</a></li>
|
||||
<li><a class="no-select" href="/admin/users/index.html">Users</a></li>
|
||||
<li><a class="no-select" href="/admin/channels/index.html">Channels</a></li>
|
||||
<li><a class="no-select" href="/admin/messages/index.html">Messages</a></li>
|
||||
<li><a class="no-select" href="/admin/forums/index.html">Forums</a></li>
|
||||
<li><a class="no-select" href="/admin/files/index.html">Files</a></li>
|
||||
<li><a class="no-select" href="/admin/files/repositories.html">Repositories</a></li>
|
||||
<li><a class="no-select" href="/admin/notifications/index.html">Notifications</a></li>
|
||||
<li><a class="no-select" href="/admin/system/index.html">System</a></li>
|
||||
<li><a class="no-select" href="/admin/system/kv.html">KV Store</a></li>
|
||||
<li><a class="no-select" href="/admin/system/push.html">Push</a></li>
|
||||
</ul>
|
||||
<hr>
|
||||
<ul>
|
||||
<li><a class="no-select" href="/settings/index.html">Back to Settings</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
@@ -1,52 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>System</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
{% if message %}
|
||||
<div class="alert alert-success">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="admin-stats">
|
||||
<h2>System Information</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Uptime</span>
|
||||
<span class="info-value">{{ uptime }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Total Users</span>
|
||||
<span class="info-value">{{ stats.users }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Online Users</span>
|
||||
<span class="info-value">{{ stats.online }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Total Messages</span>
|
||||
<span class="info-value">{{ stats.messages }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-actions-section">
|
||||
<h2>Maintenance</h2>
|
||||
<form method="post" action="/admin/system/index.html">
|
||||
<input type="hidden" name="action" value="maintenance">
|
||||
<p>Run database maintenance (VACUUM and ANALYZE) to optimize performance.</p>
|
||||
<button type="submit" class="btn btn-primary">Run Maintenance</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-links">
|
||||
<h2>Quick Links</h2>
|
||||
<ul>
|
||||
<li><a href="/admin/system/kv.html">KV Store Editor</a></li>
|
||||
<li><a href="/admin/system/push.html">Push Registrations</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,65 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>KV Store</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="admin-form">
|
||||
<h2>Add / Update Entry</h2>
|
||||
<form method="post" action="/admin/system/kv.html">
|
||||
<input type="hidden" name="action" value="set">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="key">Key</label>
|
||||
<input type="text" id="key" name="key" maxlength="255" required placeholder="key_name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="value">Value</label>
|
||||
<input type="text" id="value" name="value" maxlength="10000" required placeholder="value">
|
||||
</div>
|
||||
<div class="form-group form-actions-inline">
|
||||
<button type="submit" class="btn btn-primary">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<h2>Current Entries</h2>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
<td><code>{{ entry.key }}</code></td>
|
||||
<td class="value-preview">{{ entry.value }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/system/kv.html" style="display:inline;">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="key" value="{{ entry.key }}">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this entry?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3">No entries found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,53 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Push Registrations</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for reg in registrations %}
|
||||
<tr>
|
||||
<td><a href="/admin/users/{{ reg.user_uid }}/edit.html">{{ reg.user_nick }}</a></td>
|
||||
<td class="endpoint-preview">{{ reg.endpoint }}</td>
|
||||
<td>{{ reg.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/system/push.html" style="display:inline;">
|
||||
<input type="hidden" name="registration_uid" value="{{ reg.uid }}">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this registration?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4">No push registrations found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/system/push.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/system/push.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,37 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>{% if target_user.is_banned %}Unban{% else %}Ban{% endif %} User</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form danger-zone">
|
||||
<h2>{{ target_user.username }} ({{ target_user.nick }})</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if target_user.is_banned %}
|
||||
<p>This user is currently banned. Unbanning will restore their access to the system.</p>
|
||||
<form method="post" action="/admin/users/{{ target_user.uid }}/ban.html">
|
||||
<input type="hidden" name="action" value="unban">
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Unban User</button>
|
||||
<a href="/admin/users/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>Banning this user will prevent them from logging in and accessing the system.</p>
|
||||
<form method="post" action="/admin/users/{{ target_user.uid }}/ban.html">
|
||||
<input type="hidden" name="action" value="ban">
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-danger">Ban User</button>
|
||||
<a href="/admin/users/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,64 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Edit User</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form">
|
||||
<h2>{{ target_user.username }}</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/users/{{ target_user.uid }}/edit.html">
|
||||
<div class="form-group">
|
||||
<label for="nick">Nickname</label>
|
||||
<input type="text" id="nick" name="nick" value="{{ target_user.nick }}" maxlength="20" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="color">Color</label>
|
||||
<input type="color" id="color" name="color" value="{{ target_user.color or '#ffffff' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="is_admin" {% if target_user.is_admin %}checked{% endif %}>
|
||||
Administrator
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="is_banned" {% if target_user.is_banned %}checked{% endif %}>
|
||||
Banned
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<a href="/admin/users/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-info">
|
||||
<h3>User Information</h3>
|
||||
<dl>
|
||||
<dt>UID</dt>
|
||||
<dd>{{ target_user.uid }}</dd>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ target_user.created_at }}</dd>
|
||||
<dt>Last Online</dt>
|
||||
<dd>{{ target_user.last_online or 'Never' }}</dd>
|
||||
<dt>Location</dt>
|
||||
<dd>{{ target_user.city }}{% if target_user.country_long %}, {{ target_user.country_long }}{% endif %}</dd>
|
||||
<dt>IP</dt>
|
||||
<dd>{{ target_user.ip or 'Unknown' }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -1,68 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Users</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-search">
|
||||
<form method="get" action="/admin/users/index.html">
|
||||
<input type="text" name="search" placeholder="Search users..." value="{{ search }}" class="search-input">
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
{% if search %}
|
||||
<a href="/admin/users/index.html" class="btn btn-secondary">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Nick</th>
|
||||
<th>Admin</th>
|
||||
<th>Banned</th>
|
||||
<th>Location</th>
|
||||
<th>Last Online</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr class="{% if user.is_banned %}row-banned{% endif %}">
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.nick }}</td>
|
||||
<td>{% if user.is_admin %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{% if user.is_banned %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ user.city }}{% if user.country_short %}, {{ user.country_short }}{% endif %}</td>
|
||||
<td>{{ user.last_online or 'Never' }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/users/{{ user.uid }}/edit.html" class="btn btn-small">Edit</a>
|
||||
<a href="/admin/users/{{ user.uid }}/ban.html" class="btn btn-small btn-danger">{% if user.is_banned %}Unban{% else %}Ban{% endif %}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7">No users found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="admin-pagination">
|
||||
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }} users</span>
|
||||
<div class="pagination-controls">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/users/index.html?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/users/index.html?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@@ -40,9 +40,8 @@
|
||||
<link rel="stylesheet" href="/mention-nav.css">
|
||||
<link rel="icon" type="image/png" href="/image/snek_logo_32x32.png" sizes="32x32">
|
||||
<link rel="icon" type="image/png" href="/image/snek_logo_64x64.png" sizes="64x64">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo no-select">{% block header_text %}{% endblock %}</div>
|
||||
<channel-menu></channel-menu>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
{% extends "app.html" %}
|
||||
|
||||
{% block body_class %}profile-page{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<aside class="sidebar" id="channelSidebar">
|
||||
<h2>Navigation</h2>
|
||||
|
||||
@@ -5,10 +5,4 @@
|
||||
<li><a class="no-select" href="/settings/profile_pages/index.html">Profile Pages</a></li>
|
||||
<li><a class="no-select" href="/settings/repositories/index.html">Repositories</a></li>
|
||||
</ul>
|
||||
{% if user and user.is_admin %}
|
||||
<h2>Administration</h2>
|
||||
<ul>
|
||||
<li><a class="no-select" href="/admin/index.html">Admin Panel</a></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</aside>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.view.admin.index import AdminIndexView
|
||||
from snek.view.admin.users import AdminUsersIndexView, AdminUserEditView, AdminUserBanView
|
||||
from snek.view.admin.channels import AdminChannelsIndexView, AdminChannelMembersView, AdminChannelEditView, AdminChannelClearView, AdminChannelDeleteView
|
||||
from snek.view.admin.messages import AdminMessagesIndexView
|
||||
from snek.view.admin.forums import AdminForumsIndexView, AdminThreadsView, AdminPostsView
|
||||
from snek.view.admin.files import AdminFilesIndexView, AdminDriveItemsView, AdminRepositoriesView
|
||||
from snek.view.admin.notifications import AdminNotificationsIndexView, AdminNotificationCreateView
|
||||
from snek.view.admin.system import AdminSystemIndexView, AdminKVView, AdminPushView
|
||||
|
||||
__all__ = [
|
||||
"AdminIndexView",
|
||||
"AdminUsersIndexView",
|
||||
"AdminUserEditView",
|
||||
"AdminUserBanView",
|
||||
"AdminChannelsIndexView",
|
||||
"AdminChannelMembersView",
|
||||
"AdminChannelEditView",
|
||||
"AdminChannelClearView",
|
||||
"AdminChannelDeleteView",
|
||||
"AdminMessagesIndexView",
|
||||
"AdminForumsIndexView",
|
||||
"AdminThreadsView",
|
||||
"AdminPostsView",
|
||||
"AdminFilesIndexView",
|
||||
"AdminDriveItemsView",
|
||||
"AdminRepositoriesView",
|
||||
"AdminNotificationsIndexView",
|
||||
"AdminNotificationCreateView",
|
||||
"AdminSystemIndexView",
|
||||
"AdminKVView",
|
||||
"AdminPushView",
|
||||
]
|
||||
@@ -1,24 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.system.view import BaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminBaseView(BaseView):
|
||||
|
||||
login_required = True
|
||||
|
||||
async def _iter(self):
|
||||
if not self.session.get("logged_in") or not self.session.get("uid"):
|
||||
logger.warning("Unauthorized admin access attempt: not logged in")
|
||||
return web.HTTPFound("/login.html")
|
||||
user = await self.services.user.get(uid=self.session.get("uid"))
|
||||
if not user or not user["is_admin"]:
|
||||
logger.warning(f"Forbidden admin access attempt by user {self.session.get('uid')}")
|
||||
return web.HTTPForbidden(text="Admin access required")
|
||||
return await super()._iter()
|
||||
@@ -1,148 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminChannelsIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin channels list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
search = self.request.query.get("search", "")
|
||||
result = await self.services.admin.list_channels(page=page, search=search if search else None)
|
||||
return await self.render_template(
|
||||
"admin/channels/index.html",
|
||||
{
|
||||
"channels": result["channels"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
"search": search,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminChannelMembersView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel members accessed for {channel_uid} by {self.session.get('uid')}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
return web.HTTPNotFound(text="Channel not found")
|
||||
members = await self.services.admin.get_channel_members(channel_uid)
|
||||
return await self.render_template(
|
||||
"admin/channels/members.html",
|
||||
{
|
||||
"channel": channel,
|
||||
"members": members,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminChannelEditView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel edit accessed for {channel_uid} by {self.session.get('uid')}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
return web.HTTPNotFound(text="Channel not found")
|
||||
return await self.render_template(
|
||||
"admin/channels/edit.html",
|
||||
{"channel": channel}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel edit POST for {channel_uid} by {self.session.get('uid')}")
|
||||
try:
|
||||
data = await self.request.post()
|
||||
updates = {}
|
||||
if "name" in data:
|
||||
updates["name"] = data["name"]
|
||||
if "description" in data:
|
||||
updates["description"] = data["description"]
|
||||
if "is_private" in data:
|
||||
updates["is_private"] = data["is_private"] == "on"
|
||||
await self.services.admin.update_channel(
|
||||
admin_uid=self.session.get("uid"),
|
||||
channel_uid=channel_uid,
|
||||
updates=updates
|
||||
)
|
||||
return web.HTTPFound("/admin/channels/index.html")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Channel update error: {ex}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
return await self.render_template(
|
||||
"admin/channels/edit.html",
|
||||
{"channel": channel, "error": str(ex)}
|
||||
)
|
||||
|
||||
|
||||
class AdminChannelClearView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel clear accessed for {channel_uid} by {self.session.get('uid')}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
return web.HTTPNotFound(text="Channel not found")
|
||||
return await self.render_template(
|
||||
"admin/channels/clear.html",
|
||||
{"channel": channel}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel clear POST for {channel_uid} by {self.session.get('uid')}")
|
||||
try:
|
||||
result = await self.services.admin.clear_channel_history(
|
||||
admin_uid=self.session.get("uid"),
|
||||
channel_uid=channel_uid
|
||||
)
|
||||
return web.HTTPFound(f"/admin/channels/index.html?cleared={result['deleted_count']}")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Channel clear error: {ex}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
return await self.render_template(
|
||||
"admin/channels/clear.html",
|
||||
{"channel": channel, "error": str(ex)}
|
||||
)
|
||||
|
||||
|
||||
class AdminChannelDeleteView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel delete accessed for {channel_uid} by {self.session.get('uid')}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
return web.HTTPNotFound(text="Channel not found")
|
||||
return await self.render_template(
|
||||
"admin/channels/delete.html",
|
||||
{"channel": channel}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel delete POST for {channel_uid} by {self.session.get('uid')}")
|
||||
try:
|
||||
await self.services.admin.delete_channel(
|
||||
admin_uid=self.session.get("uid"),
|
||||
channel_uid=channel_uid
|
||||
)
|
||||
return web.HTTPFound("/admin/channels/index.html")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Channel delete error: {ex}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
return await self.render_template(
|
||||
"admin/channels/delete.html",
|
||||
{"channel": channel, "error": str(ex)}
|
||||
)
|
||||
@@ -1,110 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminFilesIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin files list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
user_uid = self.request.query.get("user_uid", "")
|
||||
result = await self.services.admin.list_drives(
|
||||
user_uid=user_uid if user_uid else None,
|
||||
page=page
|
||||
)
|
||||
users_result = await self.services.admin.list_users(per_page=100)
|
||||
return await self.render_template(
|
||||
"admin/files/index.html",
|
||||
{
|
||||
"drives": result["drives"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
"user_uid": user_uid,
|
||||
"users": users_result["users"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminDriveItemsView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
drive_uid = self.request.match_info.get("drive_uid")
|
||||
logger.info(f"Admin drive items accessed for {drive_uid} by {self.session.get('uid')}")
|
||||
drive = await self.services.drive.get(uid=drive_uid)
|
||||
if not drive:
|
||||
return web.HTTPNotFound(text="Drive not found")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_drive_items(drive_uid=drive_uid, page=page)
|
||||
user = await self.services.user.get(uid=drive["user_uid"])
|
||||
return await self.render_template(
|
||||
"admin/files/items.html",
|
||||
{
|
||||
"drive": drive,
|
||||
"user": user,
|
||||
"items": result["items"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
drive_uid = self.request.match_info.get("drive_uid")
|
||||
logger.info(f"Admin drive item delete POST for {drive_uid} by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
item_uid = data.get("item_uid")
|
||||
if item_uid:
|
||||
try:
|
||||
await self.services.admin.delete_drive_item(
|
||||
admin_uid=self.session.get("uid"),
|
||||
item_uid=item_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Drive item delete error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
|
||||
|
||||
class AdminRepositoriesView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin repositories list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
user_uid = self.request.query.get("user_uid", "")
|
||||
result = await self.services.admin.list_repositories(
|
||||
user_uid=user_uid if user_uid else None,
|
||||
page=page
|
||||
)
|
||||
users_result = await self.services.admin.list_users(per_page=100)
|
||||
return await self.render_template(
|
||||
"admin/files/repositories.html",
|
||||
{
|
||||
"repositories": result["repositories"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
"user_uid": user_uid,
|
||||
"users": users_result["users"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin repository delete POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
repo_uid = data.get("repo_uid")
|
||||
if repo_uid:
|
||||
try:
|
||||
await self.services.admin.delete_repository(
|
||||
admin_uid=self.session.get("uid"),
|
||||
repo_uid=repo_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Repository delete error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
@@ -1,139 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminForumsIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin forums list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_forums(page=page)
|
||||
return await self.render_template(
|
||||
"admin/forums/index.html",
|
||||
{
|
||||
"forums": result["forums"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin forum action POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
forum_uid = data.get("forum_uid")
|
||||
action = data.get("action")
|
||||
if forum_uid and action:
|
||||
try:
|
||||
if action == "delete":
|
||||
await self.services.admin.delete_forum(
|
||||
admin_uid=self.session.get("uid"),
|
||||
forum_uid=forum_uid
|
||||
)
|
||||
elif action == "toggle_active":
|
||||
forum = await self.services.forum.get(uid=forum_uid)
|
||||
if forum:
|
||||
await self.services.admin.update_forum(
|
||||
admin_uid=self.session.get("uid"),
|
||||
forum_uid=forum_uid,
|
||||
updates={"is_active": not forum.get("is_active", True)}
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Forum action error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
|
||||
|
||||
class AdminThreadsView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
forum_uid = self.request.match_info.get("forum_uid")
|
||||
logger.info(f"Admin threads list accessed for forum {forum_uid} by {self.session.get('uid')}")
|
||||
forum = await self.services.forum.get(uid=forum_uid)
|
||||
if not forum:
|
||||
return web.HTTPNotFound(text="Forum not found")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_threads(forum_uid=forum_uid, page=page)
|
||||
return await self.render_template(
|
||||
"admin/forums/threads.html",
|
||||
{
|
||||
"forum": forum,
|
||||
"threads": result["threads"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
forum_uid = self.request.match_info.get("forum_uid")
|
||||
logger.info(f"Admin thread action POST for forum {forum_uid} by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
thread_uid = data.get("thread_uid")
|
||||
action = data.get("action")
|
||||
if thread_uid and action:
|
||||
try:
|
||||
if action == "delete":
|
||||
await self.services.admin.delete_thread(
|
||||
admin_uid=self.session.get("uid"),
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
elif action == "toggle_pin":
|
||||
await self.services.admin.toggle_thread_pin(
|
||||
admin_uid=self.session.get("uid"),
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
elif action == "toggle_lock":
|
||||
await self.services.admin.toggle_thread_lock(
|
||||
admin_uid=self.session.get("uid"),
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Thread action error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
|
||||
|
||||
class AdminPostsView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
thread_uid = self.request.match_info.get("thread_uid")
|
||||
logger.info(f"Admin posts list accessed for thread {thread_uid} by {self.session.get('uid')}")
|
||||
thread = await self.services.thread.get(uid=thread_uid)
|
||||
if not thread:
|
||||
return web.HTTPNotFound(text="Thread not found")
|
||||
forum = await self.services.forum.get(uid=thread["forum_uid"])
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_posts(thread_uid=thread_uid, page=page)
|
||||
return await self.render_template(
|
||||
"admin/forums/posts.html",
|
||||
{
|
||||
"forum": forum,
|
||||
"thread": thread,
|
||||
"posts": result["posts"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
thread_uid = self.request.match_info.get("thread_uid")
|
||||
logger.info(f"Admin post action POST for thread {thread_uid} by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
post_uid = data.get("post_uid")
|
||||
action = data.get("action")
|
||||
if post_uid and action == "delete":
|
||||
try:
|
||||
await self.services.admin.delete_post(
|
||||
admin_uid=self.session.get("uid"),
|
||||
post_uid=post_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Post delete error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
@@ -1,22 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin dashboard accessed by user {self.session.get('uid')}")
|
||||
stats = await self.services.admin.get_dashboard_stats()
|
||||
activity = await self.services.admin.get_recent_activity(limit=10)
|
||||
return await self.render_template(
|
||||
"admin/index.html",
|
||||
{
|
||||
"stats": stats,
|
||||
"activity": activity,
|
||||
}
|
||||
)
|
||||
@@ -1,55 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminMessagesIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin messages list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
query = self.request.query.get("query", "")
|
||||
channel_uid = self.request.query.get("channel_uid", "")
|
||||
user_uid = self.request.query.get("user_uid", "")
|
||||
result = await self.services.admin.search_messages(
|
||||
query=query if query else None,
|
||||
channel_uid=channel_uid if channel_uid else None,
|
||||
user_uid=user_uid if user_uid else None,
|
||||
page=page
|
||||
)
|
||||
channels_result = await self.services.admin.list_channels(per_page=100)
|
||||
users_result = await self.services.admin.list_users(per_page=100)
|
||||
return await self.render_template(
|
||||
"admin/messages/index.html",
|
||||
{
|
||||
"messages": result["messages"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
"query": query,
|
||||
"channel_uid": channel_uid,
|
||||
"user_uid": user_uid,
|
||||
"channels": channels_result["channels"],
|
||||
"users": users_result["users"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin message delete POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
message_uid = data.get("message_uid")
|
||||
if message_uid:
|
||||
try:
|
||||
await self.services.admin.delete_message(
|
||||
admin_uid=self.session.get("uid"),
|
||||
message_uid=message_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Message delete error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
@@ -1,58 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminNotificationsIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin notifications list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_notifications(page=page)
|
||||
return await self.render_template(
|
||||
"admin/notifications/index.html",
|
||||
{
|
||||
"notifications": result["notifications"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminNotificationCreateView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin notification create accessed by user {self.session.get('uid')}")
|
||||
users_result = await self.services.admin.list_users(per_page=200)
|
||||
return await self.render_template(
|
||||
"admin/notifications/create.html",
|
||||
{"users": users_result["users"]}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin notification create POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
message = data.get("message", "").strip()
|
||||
target = data.get("target", "all")
|
||||
user_uids = data.getall("user_uids", []) if target == "selected" else None
|
||||
try:
|
||||
result = await self.services.admin.mass_notify(
|
||||
admin_uid=self.session.get("uid"),
|
||||
message=message,
|
||||
user_uids=user_uids
|
||||
)
|
||||
return web.HTTPFound(f"/admin/notifications/index.html?sent={result['sent_count']}")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Mass notify error: {ex}")
|
||||
users_result = await self.services.admin.list_users(per_page=200)
|
||||
return await self.render_template(
|
||||
"admin/notifications/create.html",
|
||||
{"users": users_result["users"], "error": str(ex), "message": message}
|
||||
)
|
||||
@@ -1,117 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminSystemIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin system index accessed by user {self.session.get('uid')}")
|
||||
stats = await self.services.admin.get_dashboard_stats()
|
||||
return await self.render_template(
|
||||
"admin/system/index.html",
|
||||
{
|
||||
"stats": stats,
|
||||
"uptime": self.app.uptime,
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin system action POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
action = data.get("action")
|
||||
message = None
|
||||
if action == "maintenance":
|
||||
try:
|
||||
result = await self.services.admin.run_maintenance(
|
||||
admin_uid=self.session.get("uid")
|
||||
)
|
||||
message = result.get("message", "Maintenance completed")
|
||||
except Exception as ex:
|
||||
logger.warning(f"Maintenance error: {ex}")
|
||||
message = f"Error: {ex}"
|
||||
stats = await self.services.admin.get_dashboard_stats()
|
||||
return await self.render_template(
|
||||
"admin/system/index.html",
|
||||
{
|
||||
"stats": stats,
|
||||
"uptime": self.app.uptime,
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminKVView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin KV store accessed by user {self.session.get('uid')}")
|
||||
entries = await self.services.admin.get_kv_entries()
|
||||
return await self.render_template(
|
||||
"admin/system/kv.html",
|
||||
{"entries": entries}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin KV store POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
action = data.get("action")
|
||||
try:
|
||||
if action == "set":
|
||||
key = data.get("key", "").strip()
|
||||
value = data.get("value", "").strip()
|
||||
await self.services.admin.set_kv_entry(
|
||||
admin_uid=self.session.get("uid"),
|
||||
key=key,
|
||||
value=value
|
||||
)
|
||||
elif action == "delete":
|
||||
key = data.get("key", "").strip()
|
||||
await self.services.admin.delete_kv_entry(
|
||||
admin_uid=self.session.get("uid"),
|
||||
key=key
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"KV store error: {ex}")
|
||||
entries = await self.services.admin.get_kv_entries()
|
||||
return await self.render_template(
|
||||
"admin/system/kv.html",
|
||||
{"entries": entries, "error": str(ex)}
|
||||
)
|
||||
return web.HTTPFound("/admin/system/kv.html")
|
||||
|
||||
|
||||
class AdminPushView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin push registrations accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_push_registrations(page=page)
|
||||
return await self.render_template(
|
||||
"admin/system/push.html",
|
||||
{
|
||||
"registrations": result["registrations"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin push registration delete POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
registration_uid = data.get("registration_uid")
|
||||
if registration_uid:
|
||||
try:
|
||||
await self.services.admin.delete_push_registration(
|
||||
admin_uid=self.session.get("uid"),
|
||||
registration_uid=registration_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Push registration delete error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
@@ -1,109 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminUsersIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin users list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
search = self.request.query.get("search", "")
|
||||
result = await self.services.admin.list_users(page=page, search=search if search else None)
|
||||
return await self.render_template(
|
||||
"admin/users/index.html",
|
||||
{
|
||||
"users": result["users"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
"search": search,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminUserEditView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
user_uid = self.request.match_info.get("user_uid")
|
||||
logger.info(f"Admin user edit accessed for {user_uid} by {self.session.get('uid')}")
|
||||
target_user = await self.services.user.get(uid=user_uid)
|
||||
if not target_user:
|
||||
return web.HTTPNotFound(text="User not found")
|
||||
return await self.render_template(
|
||||
"admin/users/edit.html",
|
||||
{"target_user": target_user}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
user_uid = self.request.match_info.get("user_uid")
|
||||
logger.info(f"Admin user edit POST for {user_uid} by {self.session.get('uid')}")
|
||||
try:
|
||||
data = await self.request.post()
|
||||
updates = {}
|
||||
if "nick" in data:
|
||||
updates["nick"] = data["nick"]
|
||||
if "is_admin" in data:
|
||||
updates["is_admin"] = data["is_admin"] == "on"
|
||||
if "is_banned" in data:
|
||||
updates["is_banned"] = data["is_banned"] == "on"
|
||||
if "color" in data:
|
||||
updates["color"] = data["color"]
|
||||
await self.services.admin.update_user(
|
||||
admin_uid=self.session.get("uid"),
|
||||
target_uid=user_uid,
|
||||
updates=updates
|
||||
)
|
||||
return web.HTTPFound("/admin/users/index.html")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"User update error: {ex}")
|
||||
target_user = await self.services.user.get(uid=user_uid)
|
||||
return await self.render_template(
|
||||
"admin/users/edit.html",
|
||||
{"target_user": target_user, "error": str(ex)}
|
||||
)
|
||||
|
||||
|
||||
class AdminUserBanView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
user_uid = self.request.match_info.get("user_uid")
|
||||
logger.info(f"Admin user ban accessed for {user_uid} by {self.session.get('uid')}")
|
||||
target_user = await self.services.user.get(uid=user_uid)
|
||||
if not target_user:
|
||||
return web.HTTPNotFound(text="User not found")
|
||||
return await self.render_template(
|
||||
"admin/users/ban.html",
|
||||
{"target_user": target_user}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
user_uid = self.request.match_info.get("user_uid")
|
||||
data = await self.request.post()
|
||||
action = data.get("action", "ban")
|
||||
logger.info(f"Admin user {action} POST for {user_uid} by {self.session.get('uid')}")
|
||||
try:
|
||||
if action == "unban":
|
||||
await self.services.admin.unban_user(
|
||||
admin_uid=self.session.get("uid"),
|
||||
target_uid=user_uid
|
||||
)
|
||||
else:
|
||||
await self.services.admin.ban_user(
|
||||
admin_uid=self.session.get("uid"),
|
||||
target_uid=user_uid
|
||||
)
|
||||
return web.HTTPFound("/admin/users/index.html")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"User ban error: {ex}")
|
||||
target_user = await self.services.user.get(uid=user_uid)
|
||||
return await self.render_template(
|
||||
"admin/users/ban.html",
|
||||
{"target_user": target_user, "error": str(ex)}
|
||||
)
|
||||
@@ -12,7 +12,6 @@ from snek.system.view import BaseView
|
||||
from snek.view.rpc.base import BaseAction
|
||||
from snek.view.rpc.utils import safe_get, safe_str
|
||||
from snek.view.rpc.actions import (
|
||||
AdminActions,
|
||||
AuthActions,
|
||||
UserActions,
|
||||
ChannelActions,
|
||||
@@ -28,7 +27,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class RPCApi(
|
||||
BaseAction,
|
||||
AdminActions,
|
||||
AuthActions,
|
||||
UserActions,
|
||||
ChannelActions,
|
||||
@@ -155,7 +153,7 @@ class RPCView(BaseView):
|
||||
logger.debug(f"Schedule failed: {safe_str(ex)}")
|
||||
|
||||
try:
|
||||
ws = web.WebSocketResponse(heartbeat=300.0, autoping=True)
|
||||
ws = web.WebSocketResponse(heartbeat=30.0, autoping=True)
|
||||
await ws.prepare(self.request)
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to prepare WebSocket: {safe_str(ex)}")
|
||||
@@ -168,21 +166,18 @@ class RPCView(BaseView):
|
||||
user_uid = session.get("uid")
|
||||
if user_uid and self.services:
|
||||
await self.services.socket.add(ws, user_uid)
|
||||
# Load subscriptions in background to avoid blocking WebSocket handshake
|
||||
async def load_subscriptions():
|
||||
try:
|
||||
async for subscription in self.services.channel_member.find(
|
||||
user_uid=user_uid,
|
||||
deleted_at=None,
|
||||
is_banned=False,
|
||||
):
|
||||
if subscription:
|
||||
channel_uid = safe_get(subscription, "channel_uid")
|
||||
if channel_uid:
|
||||
await self.services.socket.subscribe(ws, channel_uid, user_uid)
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to subscribe to channels: {safe_str(ex)}")
|
||||
asyncio.create_task(load_subscriptions())
|
||||
try:
|
||||
async for subscription in self.services.channel_member.find(
|
||||
user_uid=user_uid,
|
||||
deleted_at=None,
|
||||
is_banned=False,
|
||||
):
|
||||
if subscription:
|
||||
channel_uid = safe_get(subscription, "channel_uid")
|
||||
if channel_uid:
|
||||
await self.services.socket.subscribe(ws, channel_uid, user_uid)
|
||||
except Exception as ex:
|
||||
logger.warning(f"Failed to subscribe to channels: {safe_str(ex)}")
|
||||
except Exception as ex:
|
||||
logger.warning(f"Session initialization failed: {safe_str(ex)}")
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.view.rpc.actions.admin import AdminActions
|
||||
from snek.view.rpc.actions.auth import AuthActions
|
||||
from snek.view.rpc.actions.user import UserActions
|
||||
from snek.view.rpc.actions.channel import ChannelActions
|
||||
@@ -11,7 +10,6 @@ from snek.view.rpc.actions.database import DatabaseActions
|
||||
from snek.view.rpc.actions.misc import MiscActions
|
||||
|
||||
__all__ = [
|
||||
"AdminActions",
|
||||
"AuthActions",
|
||||
"UserActions",
|
||||
"ChannelActions",
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from snek.view.rpc.utils import safe_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminActions:
|
||||
|
||||
async def _require_admin(self):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
user = await self.services.user.get(uid=self.user_uid)
|
||||
if not user or not user["is_admin"]:
|
||||
raise PermissionError("Admin access required")
|
||||
return True
|
||||
|
||||
async def admin_get_stats(self):
|
||||
try:
|
||||
await self._require_admin()
|
||||
stats = await self.services.admin.get_dashboard_stats()
|
||||
return {"success": True, "data": stats}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_get_stats failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to get stats"}
|
||||
|
||||
async def admin_get_recent_activity(self, limit=20):
|
||||
try:
|
||||
await self._require_admin()
|
||||
activity = await self.services.admin.get_recent_activity(limit=limit)
|
||||
return {"success": True, "data": activity}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_get_recent_activity failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to get activity"}
|
||||
|
||||
async def admin_list_users(self, page=1, per_page=20, search=None):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.list_users(page=page, per_page=per_page, search=search)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_list_users failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to list users"}
|
||||
|
||||
async def admin_update_user(self, target_uid, updates):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.update_user(
|
||||
admin_uid=self.user_uid,
|
||||
target_uid=target_uid,
|
||||
updates=updates
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_update_user failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to update user"}
|
||||
|
||||
async def admin_ban_user(self, target_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.ban_user(
|
||||
admin_uid=self.user_uid,
|
||||
target_uid=target_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_ban_user failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to ban user"}
|
||||
|
||||
async def admin_unban_user(self, target_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.unban_user(
|
||||
admin_uid=self.user_uid,
|
||||
target_uid=target_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_unban_user failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to unban user"}
|
||||
|
||||
async def admin_list_channels(self, page=1, per_page=20, search=None):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.list_channels(page=page, per_page=per_page, search=search)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_list_channels failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to list channels"}
|
||||
|
||||
async def admin_get_channel_members(self, channel_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.get_channel_members(channel_uid)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_get_channel_members failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to get channel members"}
|
||||
|
||||
async def admin_update_channel(self, channel_uid, updates):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.update_channel(
|
||||
admin_uid=self.user_uid,
|
||||
channel_uid=channel_uid,
|
||||
updates=updates
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_update_channel failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to update channel"}
|
||||
|
||||
async def admin_delete_channel(self, channel_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_channel(
|
||||
admin_uid=self.user_uid,
|
||||
channel_uid=channel_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_channel failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete channel"}
|
||||
|
||||
async def admin_clear_channel(self, channel_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.clear_channel_history(
|
||||
admin_uid=self.user_uid,
|
||||
channel_uid=channel_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_clear_channel failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to clear channel"}
|
||||
|
||||
async def admin_search_messages(self, query=None, channel_uid=None, user_uid=None, page=1):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.search_messages(
|
||||
query=query,
|
||||
channel_uid=channel_uid,
|
||||
user_uid=user_uid,
|
||||
page=page
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_search_messages failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to search messages"}
|
||||
|
||||
async def admin_delete_message(self, message_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_message(
|
||||
admin_uid=self.user_uid,
|
||||
message_uid=message_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_message failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete message"}
|
||||
|
||||
async def admin_list_forums(self, page=1):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.list_forums(page=page)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_list_forums failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to list forums"}
|
||||
|
||||
async def admin_delete_forum(self, forum_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_forum(
|
||||
admin_uid=self.user_uid,
|
||||
forum_uid=forum_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_forum failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete forum"}
|
||||
|
||||
async def admin_list_threads(self, forum_uid, page=1):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.list_threads(forum_uid=forum_uid, page=page)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_list_threads failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to list threads"}
|
||||
|
||||
async def admin_toggle_thread_pin(self, thread_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.toggle_thread_pin(
|
||||
admin_uid=self.user_uid,
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_toggle_thread_pin failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to toggle pin"}
|
||||
|
||||
async def admin_toggle_thread_lock(self, thread_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.toggle_thread_lock(
|
||||
admin_uid=self.user_uid,
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_toggle_thread_lock failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to toggle lock"}
|
||||
|
||||
async def admin_delete_thread(self, thread_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_thread(
|
||||
admin_uid=self.user_uid,
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_thread failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete thread"}
|
||||
|
||||
async def admin_delete_post(self, post_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_post(
|
||||
admin_uid=self.user_uid,
|
||||
post_uid=post_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_post failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete post"}
|
||||
|
||||
async def admin_mass_notify(self, message, user_uids=None):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.mass_notify(
|
||||
admin_uid=self.user_uid,
|
||||
message=message,
|
||||
user_uids=user_uids
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_mass_notify failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to send notifications"}
|
||||
|
||||
async def admin_get_kv_entries(self):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.get_kv_entries()
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_get_kv_entries failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to get KV entries"}
|
||||
|
||||
async def admin_set_kv_entry(self, key, value):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.set_kv_entry(
|
||||
admin_uid=self.user_uid,
|
||||
key=key,
|
||||
value=value
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_set_kv_entry failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to set KV entry"}
|
||||
|
||||
async def admin_delete_kv_entry(self, key):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_kv_entry(
|
||||
admin_uid=self.user_uid,
|
||||
key=key
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_kv_entry failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete KV entry"}
|
||||
|
||||
async def admin_run_maintenance(self):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.run_maintenance(admin_uid=self.user_uid)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_run_maintenance failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to run maintenance"}
|
||||
Reference in New Issue
Block a user