mirror of
https://github.com/apricote/Listory.git
synced 2026-01-13 13:11:02 +00:00
Compare commits
853 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eae4df7d06 | ||
|
|
7a2be2e8a7 | ||
|
|
0731b8775b | ||
|
|
6f65299dba | ||
|
|
c55f4b392e | ||
|
|
b7f59a37cf | ||
|
|
cb255fa417 | ||
|
|
eecd241970 | ||
|
|
716265de5b | ||
|
|
afeb363742 | ||
|
|
1809527fd2 | ||
|
|
cb2dcdee6f | ||
|
|
da9780a4c9 | ||
|
|
f43ff30f65 | ||
|
|
1575932035 | ||
|
|
590bdc6dd7 | ||
|
|
725458141c | ||
|
|
b79a18c7f3 | ||
|
|
287d2e2055 | ||
|
|
9e1ae77b06 | ||
|
|
f80946425d | ||
|
|
870893d066 | ||
|
|
cd47efce04 | ||
|
|
7943ba4ed8 | ||
|
|
058948812b | ||
|
|
abbd3d6dd6 | ||
|
|
ace650aaeb | ||
|
|
c6cda6213c | ||
|
|
602bbb21b3 | ||
|
|
8486ddfb06 | ||
|
|
e8f27d836b | ||
|
|
bcdd0cbfee | ||
|
|
cd749139d0 | ||
|
|
37f54786f2 | ||
|
|
b7dbdbd8b0 | ||
|
|
a8302e175a | ||
|
|
292054c46e | ||
|
|
f6f17833bc | ||
|
|
3a74a1cc2f | ||
|
|
b3b2d9f09a | ||
|
|
07c00c2e9d | ||
|
|
147ee19921 | ||
|
|
dc78a4dcb9 | ||
|
|
da8c778470 | ||
|
|
6c19584e28 | ||
|
|
4b46bf227e | ||
|
|
335f90b90d | ||
|
|
38432afb95 | ||
|
|
0fab34227d | ||
|
|
ba0eefdcea | ||
|
|
c81fa2b217 | ||
|
|
228f26dada | ||
|
|
529454fbf3 | ||
|
|
4d3780211b | ||
|
|
4f84edb26a | ||
|
|
31a22951cb | ||
|
|
99a8c32486 | ||
|
|
716e3f3879 | ||
|
|
6e0fcd8e29 | ||
|
|
0b7b94282c | ||
|
|
d1a317aceb | ||
|
|
d5b384098b | ||
|
|
d97ac14b89 | ||
|
|
77931d94a4 | ||
|
|
4dbc49277c | ||
|
|
ab12aa70a4 | ||
|
|
60b538ef00 | ||
|
|
6363cf8567 | ||
|
|
16f82dcffc | ||
|
|
58d4282723 | ||
|
|
805aa2b0b6 | ||
|
|
2e572d526f | ||
|
|
927a54f247 | ||
|
|
8dc79d4b22 | ||
|
|
f034b1cd1a | ||
|
|
c188bd030f | ||
|
|
9036547105 | ||
|
|
01f6043fdf | ||
|
|
6123b62e94 | ||
|
|
aed7d408f4 | ||
|
|
593bc32b96 | ||
|
|
eee615bec0 | ||
|
|
5badd50e6b | ||
|
|
cdb9c95d8e | ||
|
|
8f1adc3811 | ||
|
|
e02b29d98d | ||
|
|
a7dfa6b3b3 | ||
|
|
f93dec6bc3 | ||
|
|
fb0c367b09 | ||
|
|
2de40e010b | ||
|
|
4aec5c2576 | ||
|
|
5225764be9 | ||
|
|
31ae8743e6 | ||
|
|
169af3d246 | ||
|
|
c05a8e84f5 | ||
|
|
ebadc6498e | ||
|
|
1dc59c3e2c | ||
|
|
714467e43d | ||
|
|
e7fd293459 | ||
|
|
d34d6b2c6a | ||
|
|
33d8e29cb2 | ||
|
|
00f3883930 | ||
|
|
a08ab594ab | ||
|
|
fd6d7905e4 | ||
|
|
0a9ab9815e | ||
|
|
a81bf71d12 | ||
|
|
3912aef540 | ||
|
|
cbb44f0000 | ||
|
|
ebac91e007 | ||
|
|
7bce9b9ae2 | ||
|
|
6f883f3388 | ||
|
|
a8b1664d81 | ||
|
|
7812b21766 | ||
|
|
d429c0b05a | ||
|
|
326ec79732 | ||
|
|
d940b2cb40 | ||
|
|
a015c70f32 | ||
|
|
a273d9d74e | ||
|
|
3eb53a1f95 | ||
|
|
74ff62da23 | ||
|
|
ac6e17b0ae | ||
|
|
c29d382b57 | ||
|
|
7b6274ad58 | ||
|
|
f0a9c60dac | ||
|
|
06d76ff954 | ||
|
|
1d9f2d4dfa | ||
|
|
66f9f966a8 | ||
|
|
66321a0e97 | ||
|
|
827fc8ee3b | ||
|
|
9cc7ad417c | ||
|
|
5b8fa3a5fd | ||
|
|
66ff74767c | ||
|
|
43c1fa772a | ||
|
|
d175c81f76 | ||
|
|
5861cda91a | ||
|
|
a7e09b65e7 | ||
|
|
e7a38d319f | ||
|
|
ab9df4519c | ||
|
|
72d054d230 | ||
|
|
ebb12805da | ||
|
|
e1904d44a9 | ||
|
|
ee1b52736d | ||
|
|
5c4ffa69cb | ||
|
|
6d5cae2646 | ||
|
|
37fc1bc3ab | ||
|
|
eeeb4b1b86 | ||
|
|
af2268a285 | ||
|
|
c336c80941 | ||
|
|
f9926d31e9 | ||
|
|
6fc1583d89 | ||
|
|
3001b25908 | ||
|
|
7017dd8f2d | ||
|
|
fd2db3144d | ||
|
|
464799e6d7 | ||
|
|
894d35fde6 | ||
|
|
ed8d432c0f | ||
|
|
df92e3e252 | ||
|
|
e50a9df39f | ||
|
|
3a9b9c1588 | ||
|
|
96d83ee36d | ||
|
|
292c8fcfdb | ||
|
|
f3dac1720b | ||
|
|
16181a21e6 | ||
|
|
1ae3c73214 | ||
|
|
b6b47836ae | ||
|
|
78f9f8fbe7 | ||
|
|
b8e7c0b4a4 | ||
|
|
b85818fec4 | ||
|
|
109995ff68 | ||
|
|
888deaac26 | ||
|
|
617a2e5b2f | ||
|
|
930347e83f | ||
|
|
4a1ec3791b | ||
|
|
1657b2c835 | ||
|
|
2657c84a84 | ||
|
|
379a4b854a | ||
|
|
f2c907dc18 | ||
|
|
42404e8c39 | ||
|
|
501f728faa | ||
|
|
f392d700b1 | ||
|
|
d3321ce2d5 | ||
|
|
28ed747e49 | ||
|
|
04f9408563 | ||
|
|
75f891a4b9 | ||
|
|
32fe7202a0 | ||
|
|
3eb8c22f80 | ||
|
|
60baf98615 | ||
|
|
3789b7328b | ||
|
|
e5339a588d | ||
|
|
fd6810b2e5 | ||
|
|
22da9a204f | ||
|
|
78abea9fb8 | ||
|
|
0d3438fc20 | ||
|
|
60750ebd28 | ||
|
|
39d29d813b | ||
|
|
99f413aa6a | ||
|
|
781db64f83 | ||
|
|
b3d4c4e561 | ||
|
|
fb494879cd | ||
|
|
5e4e850f7e | ||
|
|
e15d032458 | ||
|
|
edfb506d8d | ||
|
|
44d46a9456 | ||
|
|
bd9e44a335 | ||
|
|
17e185584a | ||
|
|
163b23dca9 | ||
|
|
36d0320eee | ||
|
|
7dc1d091c7 | ||
|
|
90827600fc | ||
|
|
0399ded420 | ||
|
|
99beeefc50 | ||
|
|
d85b400813 | ||
|
|
428d7cf6d2 | ||
|
|
a7424faf53 | ||
|
|
bbffccaaf6 | ||
|
|
ffeede779f | ||
|
|
9cea913af7 | ||
|
|
c585f395d8 | ||
|
|
9db08e17aa | ||
|
|
7aaac70657 | ||
|
|
601501c9e2 | ||
|
|
3eaac9a102 | ||
|
|
b64c3050db | ||
|
|
ff1e1a3aaf | ||
|
|
ad5705ec93 | ||
|
|
60b3eb3740 | ||
|
|
6c59303481 | ||
|
|
cc91a97d08 | ||
|
|
9f52138e5c | ||
|
|
4798f7277f | ||
|
|
f8cb1b58b8 | ||
|
|
911284b1c1 | ||
|
|
21b520807e | ||
|
|
80ac3480b2 | ||
|
|
d0718f1789 | ||
|
|
aea78142e5 | ||
|
|
950f103fe0 | ||
|
|
fb21bd8e8c | ||
|
|
53d6578042 | ||
|
|
e677a37eaa | ||
|
|
76f959463f | ||
|
|
38bebbcf34 | ||
|
|
067b557092 | ||
|
|
26e201e64e | ||
|
|
2079e4fdfe | ||
|
|
da9bb7773c | ||
|
|
b31d0963bc | ||
|
|
9f61ad0688 | ||
|
|
9a4320fea7 | ||
|
|
7d76e5c633 | ||
|
|
cee277370d | ||
|
|
720e408c3c | ||
|
|
56a988241d | ||
|
|
0c7881304e | ||
|
|
be28251218 | ||
|
|
99fbee8549 | ||
|
|
9dccc96135 | ||
|
|
31805a714f | ||
|
|
c7750831d0 | ||
|
|
d849d57d03 | ||
|
|
683c33ddc3 | ||
|
|
e4e0ee1562 | ||
|
|
1fe398d914 | ||
|
|
1176d1cdad | ||
|
|
e9c23f215d | ||
|
|
798f288b72 | ||
|
|
953138f382 | ||
|
|
accf4bb0ed | ||
|
|
61d9ba4c2e | ||
|
|
db2844b37e | ||
|
|
f5d880c5b6 | ||
|
|
f83e117062 | ||
|
|
b4b90d8d36 | ||
|
|
c80ad31cf1 | ||
|
|
ab5ffdb006 | ||
|
|
89edd6ebc4 | ||
|
|
70e5068d15 | ||
|
|
3cd97d597a | ||
|
|
f65747d580 | ||
|
|
cb71525141 | ||
|
|
a82822ee5e | ||
|
|
1f36e23686 | ||
|
|
a64cf83367 | ||
|
|
7017294d14 | ||
|
|
1cee3538f2 | ||
|
|
033bc37d06 | ||
|
|
6631da6af7 | ||
|
|
8d4907f4c9 | ||
|
|
694d9b0740 | ||
|
|
49fd8040e6 | ||
|
|
8168a7d811 | ||
|
|
2ae7afb11e | ||
|
|
02d0c850a2 | ||
|
|
c5664db5a5 | ||
|
|
e06b14c20f | ||
|
|
61623480ed | ||
|
|
4b309ba72e | ||
|
|
863cefadc1 | ||
|
|
50c8852115 | ||
|
|
6ce8ff5365 | ||
|
|
51efd75cb2 | ||
|
|
605992743d | ||
|
|
ad659a11fb | ||
|
|
3b791fdf6c | ||
|
|
728af1b0be | ||
|
|
88da7afa6c | ||
|
|
11f9ab90c1 | ||
|
|
ec92de06b6 | ||
|
|
5e535ee1d7 | ||
|
|
07d6e75f4f | ||
|
|
13bd5b57c2 | ||
|
|
49649aa7e7 | ||
|
|
aa3d5093b7 | ||
|
|
7506c21736 | ||
|
|
2aaaeecd63 | ||
|
|
b752afe21e | ||
|
|
2d79ffee4e | ||
|
|
2ef1b81329 | ||
|
|
067cdbb80e | ||
|
|
503c49395e | ||
|
|
d074e24947 | ||
|
|
9abaac1cf9 | ||
|
|
d88762edc5 | ||
|
|
5b72304092 | ||
|
|
85c37d4bf7 | ||
|
|
e2c46d1a9e | ||
|
|
3481c263d2 | ||
|
|
ee80c0cd7e | ||
|
|
6ec8f1b0a3 | ||
|
|
afa770b3aa | ||
|
|
943b7f8203 | ||
|
|
5895b655dd | ||
|
|
a006143e37 | ||
|
|
8c3cf443e6 | ||
|
|
51038d6d32 | ||
|
|
79100f461a | ||
|
|
bc541bbe90 | ||
|
|
23d5c06cdb | ||
|
|
25846e083f | ||
|
|
6ae5a14c47 | ||
|
|
00c5447940 | ||
|
|
cc1a279b40 | ||
|
|
24460d1ec2 | ||
|
|
61fc4ab180 | ||
|
|
602d48b532 | ||
|
|
2b910dcf6d | ||
|
|
f7535059cd | ||
|
|
0853c92791 | ||
|
|
c89bbaa240 | ||
|
|
7e6f033ede | ||
|
|
6757f6d4f8 | ||
|
|
eea28e42c9 | ||
|
|
f3f94bfeee | ||
|
|
40501f21ee | ||
|
|
c3eaaf5b99 | ||
|
|
6e9519c051 | ||
|
|
66e6557fc1 | ||
|
|
56d37986c3 | ||
|
|
f2a41241ae | ||
|
|
6095375f6b | ||
|
|
7903975fcf | ||
|
|
e310802a53 | ||
|
|
b0e1cc5c0f | ||
|
|
961a8928c1 | ||
|
|
ba05034f91 | ||
|
|
efa6abfca4 | ||
|
|
7e526128b0 | ||
|
|
5998c39090 | ||
|
|
54fecfb482 | ||
|
|
64de0d8df1 | ||
|
|
fc4e14f1e3 | ||
|
|
1bd3542ecf | ||
|
|
1ed26fe252 | ||
|
|
051761e6c7 | ||
|
|
5a07521720 | ||
|
|
0b4785b41c | ||
|
|
391307852b | ||
|
|
d3ec56363d | ||
|
|
7c64e8c2b9 | ||
|
|
d2e2dd76ea | ||
|
|
b51963b1db | ||
|
|
afb7d5321e | ||
|
|
cb79ed56e9 | ||
|
|
fd1e7e261a | ||
|
|
ee909563c3 | ||
|
|
ac89b15133 | ||
|
|
9a26b85588 | ||
|
|
8915f25c5c | ||
|
|
8763ed33fc | ||
|
|
5b3bdb339e | ||
|
|
ad2f690cf6 | ||
|
|
253360c01a | ||
|
|
97bc4375d6 | ||
|
|
9de059348d | ||
|
|
bfa83ef354 | ||
|
|
222a4bfd8e | ||
|
|
aad6bae46c | ||
|
|
13218e6402 | ||
|
|
7a93cadde4 | ||
|
|
df66666921 | ||
|
|
16956c89b5 | ||
|
|
843ad41cb6 | ||
|
|
19ab536cf0 | ||
|
|
27ef4cc0c8 | ||
|
|
7e94806f63 | ||
|
|
c40c27aa8e | ||
|
|
a8dad23613 | ||
|
|
c1bf104735 | ||
|
|
c4c9e9d777 | ||
|
|
b83526a3d3 | ||
|
|
bcfca028a9 | ||
|
|
bb002389e2 | ||
|
|
c35907637d | ||
|
|
d93cd6e833 | ||
|
|
39fe04fd00 | ||
|
|
2ba25417d7 | ||
|
|
8b8685b288 | ||
|
|
01b6416ba0 | ||
|
|
b401cd7a2f | ||
|
|
400f51dd2c | ||
|
|
cf97a54d5d | ||
|
|
141775dfb7 | ||
|
|
0df10184a6 | ||
|
|
fbdbbb2c6b | ||
|
|
71b233e553 | ||
|
|
396977497f | ||
|
|
e138f79e62 | ||
|
|
35bc182f49 | ||
|
|
7000354df2 | ||
|
|
b51979f3f6 | ||
|
|
a436f2a5e5 | ||
|
|
b4b65022bc | ||
|
|
cb729c6251 | ||
|
|
00fb1d0376 | ||
|
|
bf6968006d | ||
|
|
65a80421a7 | ||
|
|
f931c52a70 | ||
|
|
f67cfe0fc0 | ||
|
|
ee55436a17 | ||
|
|
fde1e52285 | ||
|
|
b6f32c4afa | ||
|
|
ad1e34db41 | ||
|
|
120b7148f7 | ||
|
|
d9c0ba2f3b | ||
|
|
20bd61d18a | ||
|
|
6b66331e07 | ||
|
|
5596cddec0 | ||
|
|
122f386f5a | ||
|
|
de8908aa95 | ||
|
|
dc7c1f54e9 | ||
|
|
b0eb189dd0 | ||
|
|
878fbc8c22 | ||
|
|
b9f4fdeb3a | ||
|
|
07d8fe82b8 | ||
|
|
bb407ebeec | ||
|
|
814e121cbd | ||
|
|
43c15a46dd | ||
|
|
71bb635d78 | ||
|
|
6df5236ed1 | ||
|
|
f4aec012d7 | ||
|
|
f0234699d0 | ||
|
|
10371f4b94 | ||
|
|
6655fe3279 | ||
|
|
62fcb25e15 | ||
|
|
eef6497803 | ||
|
|
ad8792142b | ||
|
|
7fb5377801 | ||
|
|
af0223c2e3 | ||
|
|
a99bc05912 | ||
|
|
37b1d88c1a | ||
|
|
e90eb97f5f | ||
|
|
ffdda278a5 | ||
|
|
331da2f12d | ||
|
|
661842ae37 | ||
|
|
4f6602e6f6 | ||
|
|
7c5821c7ec | ||
|
|
221e88154e | ||
|
|
dcce338d61 | ||
|
|
e03d86a607 | ||
|
|
98f1c132dd | ||
|
|
dd2ac8deca | ||
|
|
8663855e67 | ||
|
|
edb467c0fb | ||
|
|
69ba853b78 | ||
|
|
004b1f8368 | ||
|
|
9d69150820 | ||
|
|
8ceee63c7b | ||
|
|
1c9282346e | ||
|
|
836eae8ca8 | ||
|
|
b1e07f49b8 | ||
|
|
1d12c00fee | ||
|
|
e94dd3fc09 | ||
|
|
1d1ad2350f | ||
|
|
fe232da0dd | ||
|
|
cfc359681d | ||
|
|
678c07a81c | ||
|
|
42d4af1862 | ||
|
|
d75a92f9d3 | ||
|
|
e72d87e7b5 | ||
|
|
8e74951013 | ||
|
|
687155b325 | ||
|
|
2cf6a6d629 | ||
|
|
703b14750f | ||
|
|
76edebfcf2 | ||
|
|
e5dfaa7b86 | ||
|
|
522a33af8e | ||
|
|
37962db96a | ||
|
|
25b738a246 | ||
|
|
dd060a4c47 | ||
|
|
f78c00211e | ||
|
|
f2d112e3be | ||
|
|
0d0071805d | ||
|
|
a8a9375c19 | ||
|
|
82bd1fdfac | ||
|
|
955696b39a | ||
|
|
40074c2088 | ||
|
|
3b769086ff | ||
|
|
be2c54cded | ||
|
|
c77b84f9d9 | ||
|
|
83235d944c | ||
|
|
c2371537b8 | ||
|
|
4c3a2c44c7 | ||
|
|
7c7b284ad0 | ||
|
|
497323f7ad | ||
|
|
849410dd96 | ||
|
|
756ac80836 | ||
|
|
c7d7a14d0d | ||
|
|
8f444dc653 | ||
|
|
b943469f3a | ||
|
|
879cedeafb | ||
|
|
dae0b4f896 | ||
|
|
59e5255128 | ||
|
|
a66d02b9e6 | ||
|
|
4c957afefc | ||
|
|
41fdac9148 | ||
|
|
a5b6925870 | ||
|
|
2b67cda038 | ||
|
|
38234d9979 | ||
|
|
d7a1c2c743 | ||
|
|
72c424b4c6 | ||
|
|
e576d739a5 | ||
|
|
ae8a895ee2 | ||
|
|
ec12c71ba7 | ||
|
|
3de1e5df79 | ||
|
|
7b1dfb63ab | ||
|
|
c7149fba41 | ||
|
|
160c8ba2ee | ||
|
|
f414c3cc9a | ||
|
|
ccfdbd1805 | ||
|
|
1aa0ffb07c | ||
|
|
d641731868 | ||
|
|
a830ad66b7 | ||
|
|
d0536ad9ad | ||
|
|
eec7e89a3f | ||
|
|
bb6051df9b | ||
|
|
4acf3df74f | ||
|
|
c2f598d99a | ||
|
|
073f98498b | ||
|
|
369c455ccd | ||
|
|
04fe3f99b1 | ||
|
|
1ca0b32ace | ||
|
|
9f34f781da | ||
|
|
23c42138b4 | ||
|
|
9f8c9cee25 | ||
|
|
16b0ef562d | ||
|
|
0b4a4a897c | ||
|
|
ee6c885fb6 | ||
|
|
ece6cf40fc | ||
|
|
cd06b8d8b8 | ||
|
|
303045fe40 | ||
|
|
ddb677df10 | ||
|
|
67a394b1e8 | ||
|
|
f91b1ccbb7 | ||
|
|
b75ae80884 | ||
|
|
81beb1ff50 | ||
|
|
485fc53db4 | ||
|
|
5457984905 | ||
|
|
7e3a59e7e7 | ||
|
|
b37172cbcd | ||
|
|
76da8c9892 | ||
|
|
82465251e1 | ||
|
|
9f93cebb8d | ||
|
|
2e58ff129d | ||
|
|
cbfb9e2ec1 | ||
|
|
f1e970f12c | ||
|
|
5bbef6b518 | ||
|
|
325b8c12d4 | ||
|
|
f484d4d2a8 | ||
|
|
4b9ce7e0cd | ||
|
|
12e220d7fc | ||
|
|
641506d0cb | ||
|
|
84b6847f08 | ||
|
|
e0a0d49efb | ||
|
|
8742ad58b3 | ||
|
|
47beb1dd04 | ||
|
|
465190be77 | ||
|
|
2fe32137ea | ||
|
|
1f65d051ba | ||
|
|
8349a9c936 | ||
|
|
7827d3dca5 | ||
|
|
1cacff9143 | ||
|
|
ef76efe5ca | ||
|
|
9ffa802fbd | ||
|
|
43a872fe25 | ||
|
|
a5fd7ad945 | ||
|
|
d4f220a722 | ||
|
|
871f9e57c1 | ||
|
|
0832193bda | ||
|
|
e9bf058b63 | ||
|
|
d3ac13fe41 | ||
|
|
44b3b76886 | ||
| 4cef4f75ac | |||
|
|
9a729ed588 | ||
|
|
112a9c10cd | ||
|
|
0a087c2861 | ||
|
|
43c5748bc8 | ||
|
|
985785f15f | ||
|
|
99464064f1 | ||
|
|
5481a9b3b2 | ||
|
|
ba6695719a | ||
|
|
dfb4cf5975 | ||
|
|
0de28e6e58 | ||
|
|
65d6c8d381 | ||
|
|
8775f7629e | ||
|
|
3c031979ee | ||
|
|
a3b44f4c55 | ||
|
|
839663b547 | ||
|
|
f725dbc716 | ||
|
|
2284592197 | ||
|
|
b014a7f1a5 | ||
|
|
2f037d8c54 | ||
|
|
d357f0a86a | ||
|
|
57bc019d21 | ||
|
|
d369a115a1 | ||
|
|
5260119165 | ||
| 9af3115cab | |||
|
|
db240bfa45 | ||
|
|
3f904fb310 | ||
|
|
c1b4e11cce | ||
|
|
d50fae9e24 | ||
|
|
d923c393a2 | ||
|
|
afbf3ff5ca | ||
|
|
0b305163ab | ||
|
|
41b77f782b | ||
|
|
3c04787877 | ||
|
|
c98aedb6de | ||
|
|
0f58aead43 | ||
|
|
dea3ecad61 | ||
|
|
863503b150 | ||
|
|
39b2458380 | ||
|
|
b511b4b784 | ||
|
|
d5ed16b5cc | ||
|
|
1e5a55d5bd | ||
|
|
b4cb2579a8 | ||
|
|
f580fb0146 | ||
|
|
0d971e6b14 | ||
|
|
c2b2403077 | ||
|
|
67bd26acd3 | ||
|
|
69b2b5b657 | ||
|
|
acec89ad30 | ||
|
|
1b719c9de9 | ||
|
|
9eb36a6a78 | ||
| 7140cb0679 | |||
|
|
23d7ea0995 | ||
|
|
deab2526ee | ||
| ecb00e898d | |||
|
|
c3eae1ef34 | ||
|
|
6c90b0b398 | ||
|
|
8f2ed636ee | ||
| 4b1dd10846 | |||
|
|
f08633587d | ||
|
|
fd486f49df | ||
|
|
3ee8ea9bdc | ||
|
|
3618b14068 | ||
|
|
314626ce8e | ||
|
|
729984b553 | ||
|
|
43d88870a5 | ||
|
|
72c3ca8fd9 | ||
|
|
1ca4cd463b | ||
|
|
332e1ff886 | ||
|
|
abdc3aa7a1 | ||
|
|
5982dd9b07 | ||
|
|
7ad2fd3c2a | ||
|
|
871c65d098 | ||
|
|
3862fd4b58 | ||
|
|
88cf69a2cc | ||
|
|
de7d1d3c00 | ||
|
|
59cefc7421 | ||
|
|
4bf538aa5c | ||
|
|
2a4bcaf841 | ||
|
|
36027e4b15 | ||
|
|
3087c444eb | ||
|
|
9d4ad46b19 | ||
|
|
ad1c207f20 | ||
|
|
b474c5b3e8 | ||
|
|
8da8e89d3a | ||
|
|
a206696365 | ||
|
|
4317b89e26 | ||
|
|
b5de05dad7 | ||
|
|
baae603b74 | ||
|
|
441779d6b9 | ||
|
|
e4f0d872c0 | ||
|
|
ec22990ff0 | ||
|
|
cfae9c9da7 | ||
|
|
47b1b63904 | ||
|
|
78ca3a3d64 | ||
|
|
ff66b07040 | ||
|
|
35b48740e0 | ||
| 625db7dbe7 | |||
|
|
7f5328e538 | ||
|
|
b63d3f63c4 | ||
| 9ba47a560c | |||
|
|
faa07c1297 | ||
| dd57a52ab6 | |||
|
|
fd28daa37a | ||
| 89440daf7b | |||
|
|
26087e4e6d | ||
|
|
ab91d1640c | ||
|
|
d61e047adf | ||
|
|
a0ac434e94 | ||
|
|
c42c7fae06 | ||
| 38cf2ff549 | |||
|
|
1979d924c9 | ||
|
|
8704e73365 | ||
|
|
f76954b021 | ||
|
|
45f8d762c2 | ||
|
|
6e7c947ab5 | ||
| 689868798d | |||
|
|
ba241f9784 | ||
|
|
5772680c2b | ||
|
|
111943d3c2 | ||
|
|
c503803dbd | ||
|
|
ad9f337ced | ||
|
|
ad86e8791f | ||
|
|
6d2a83f3a7 | ||
|
|
0ef41d1fa6 | ||
|
|
89335a0f38 | ||
|
|
801ae16c9a | ||
|
|
04ba36d7e3 | ||
|
|
cd6872d08a | ||
|
|
a8e75e1520 | ||
|
|
7f5210701e | ||
|
|
611b841921 | ||
|
|
d59dcbb9b3 | ||
|
|
9627467e39 | ||
|
|
7d420aa80f | ||
|
|
f3f898ba84 | ||
|
|
79aec24454 | ||
|
|
e5d98b9077 | ||
|
|
d764157391 | ||
|
|
987379be50 | ||
|
|
576c666fcb | ||
|
|
f37129f3c2 | ||
|
|
a84483f6ec | ||
|
|
f9e31338c5 | ||
|
|
150016345b | ||
|
|
23a27bec02 | ||
|
|
b3ea52838f | ||
|
|
56a93e3e30 | ||
|
|
b4313ef2ff | ||
|
|
cea8c96df6 | ||
|
|
47c077956e | ||
|
|
81da63a214 | ||
|
|
8b3834af85 | ||
|
|
4fb71b2d03 | ||
|
|
b8f1e399db | ||
|
|
6b8878d68b | ||
|
|
19f2dd07e0 | ||
|
|
46405f5a47 | ||
|
|
fe1e806852 | ||
|
|
a14d6bf6f7 | ||
|
|
cd94d42e09 | ||
|
|
54549ff1e6 | ||
|
|
9334f10bc2 | ||
|
|
78ab4f3ad5 | ||
|
|
8a1730a90b | ||
|
|
aea77f2b35 | ||
|
|
92d89d8cb3 | ||
|
|
41cabe006c | ||
|
|
041b4f3e71 | ||
|
|
48d7aa3e14 | ||
|
|
f814b07403 | ||
|
|
699b3cc965 | ||
|
|
23bfce30a9 | ||
|
|
0afde05a63 | ||
|
|
7282a22ad2 | ||
|
|
3150594532 | ||
|
|
7de4f9b353 | ||
|
|
e05be17878 | ||
|
|
2c78397cdf | ||
|
|
fa679ef5aa | ||
|
|
717dc1aafa | ||
|
|
b845547917 | ||
|
|
958aed1ef2 | ||
|
|
1c4df0b4e6 | ||
|
|
0efcfb5fe2 | ||
|
|
13a32d81c8 | ||
|
|
73baf8f8cb | ||
|
|
84b484a925 | ||
|
|
c0fc72f32b | ||
|
|
3e36b7f078 | ||
|
|
7f6db3acea | ||
|
|
46659a10b6 | ||
|
|
f830c00135 | ||
|
|
b0fc002965 | ||
|
|
39f07c08d9 | ||
|
|
f6de0a59c1 | ||
|
|
0d5df5a22e | ||
| 2acd84a1ec | |||
|
|
8103c1bd21 | ||
|
|
c7cfb7e40a | ||
|
|
3601b3d2c9 | ||
|
|
31dba14bb3 | ||
|
|
94a8b68de1 | ||
|
|
e6cdeb4079 | ||
|
|
6841c57c98 | ||
|
|
f1e82d9633 | ||
|
|
c0cd1e243e | ||
|
|
7c402b0e8c | ||
|
|
8c449b11f5 | ||
|
|
841ac5f65b | ||
|
|
e0a680cdcc | ||
|
|
fd7344a229 | ||
|
|
4b320c3e14 | ||
|
|
a0d3974180 | ||
|
|
98abcb9ca6 | ||
|
|
d6692a724c | ||
|
|
51d4ac7e69 | ||
|
|
4b12a63600 | ||
|
|
7200ed4cb0 | ||
|
|
087b1ca2bc | ||
|
|
94a611d4e4 | ||
|
|
4f6234ef64 | ||
|
|
8532d36031 | ||
|
|
4acf5b9a34 | ||
|
|
67972daad8 | ||
|
|
a87b9ea5e9 | ||
|
|
c810061068 | ||
|
|
988b4b748d | ||
|
|
4a5fb1db70 | ||
|
|
9d95d5437d | ||
|
|
5af50e4f9f | ||
|
|
5e2a9d6414 | ||
|
|
b15bfe15ad | ||
|
|
822c63388e | ||
|
|
1d7faca7fc | ||
|
|
9045ca8ddc | ||
|
|
749db1cfe3 | ||
|
|
a798d1a8d3 | ||
|
|
a483c13fed | ||
|
|
fc5dda25ef | ||
| 4da01e1d83 | |||
| 23de660f8b |
125 changed files with 13023 additions and 23264 deletions
|
|
@ -19,5 +19,6 @@
|
|||
!frontend/tsconfig.json
|
||||
!frontend/vite.config.js
|
||||
!frontend/index.html
|
||||
!frontend/*.d.ts
|
||||
!frontend/src/**/*
|
||||
!frontend/public/**/*
|
||||
|
|
|
|||
29
.github/workflows/ci.yaml
vendored
29
.github/workflows/ci.yaml
vendored
|
|
@ -3,27 +3,18 @@ name: CI
|
|||
on:
|
||||
push:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
api:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: npm Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
|
|
@ -39,20 +30,12 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: npm Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
|
||||
- run: npm ci
|
||||
|
||||
|
|
|
|||
2
.github/workflows/helm-repo-index.yaml
vendored
2
.github/workflows/helm-repo-index.yaml
vendored
|
|
@ -6,7 +6,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install chart-releaser
|
||||
env:
|
||||
|
|
|
|||
14
.github/workflows/release.yaml
vendored
14
.github/workflows/release.yaml
vendored
|
|
@ -15,27 +15,27 @@ jobs:
|
|||
needs: tests
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
version: "v0.10.4"
|
||||
version: "v0.11.2"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v3
|
||||
with:
|
||||
version: "v3.9.0"
|
||||
version: "v3.11.1"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
|
|
|||
63
CHANGELOG.md
63
CHANGELOG.md
|
|
@ -1,3 +1,66 @@
|
|||
# [1.31.0](https://github.com/apricote/Listory/compare/v1.30.1...v1.31.0) (2023-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** hide revoked api tokens ([#307](https://github.com/apricote/Listory/issues/307)) ([4cef4f7](https://github.com/apricote/Listory/commit/4cef4f75ace6a38ba19c1d2f93d81389ec2b7cb8))
|
||||
|
||||
## [1.30.1](https://github.com/apricote/Listory/compare/v1.30.0...v1.30.1) (2023-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* no listens are being crawled ([#306](https://github.com/apricote/Listory/issues/306)) ([9af3115](https://github.com/apricote/Listory/commit/9af3115cab19cc4ac4a6cd0fb680371154069aa2))
|
||||
|
||||
# [1.30.0](https://github.com/apricote/Listory/compare/v1.29.0...v1.30.0) (2023-10-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* import listens from spotify extended streaming history ([#305](https://github.com/apricote/Listory/issues/305)) ([7140cb0](https://github.com/apricote/Listory/commit/7140cb0679ec3aac8a2102197d9edb070cf0e6c0))
|
||||
|
||||
# [1.29.0](https://github.com/apricote/Listory/compare/v1.28.2...v1.29.0) (2023-09-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** general revamp of navigation & pages ([#303](https://github.com/apricote/Listory/issues/303)) ([4b1dd10](https://github.com/apricote/Listory/commit/4b1dd10846d741cd39fe9b0f41150e68510f2220))
|
||||
|
||||
## [1.28.2](https://github.com/apricote/Listory/compare/v1.28.1...v1.28.2) (2023-09-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* slow query taking up 66% of db time ([#298](https://github.com/apricote/Listory/issues/298)) ([625db7d](https://github.com/apricote/Listory/commit/625db7dbe71a7315921562bfab82420c04aa6c17))
|
||||
|
||||
## [1.28.1](https://github.com/apricote/Listory/compare/v1.28.0...v1.28.1) (2023-09-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* invalid database migration ([9ba47a5](https://github.com/apricote/Listory/commit/9ba47a560c9c98866cd2dc34c3997f96f027f65e))
|
||||
|
||||
# [1.28.0](https://github.com/apricote/Listory/compare/v1.27.0...v1.28.0) (2023-09-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* optimize db queries ([#297](https://github.com/apricote/Listory/issues/297)) ([dd57a52](https://github.com/apricote/Listory/commit/dd57a52ab66684e713c5d0766a8fed281b472e40))
|
||||
|
||||
# [1.27.0](https://github.com/apricote/Listory/compare/v1.26.2...v1.27.0) (2023-09-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* improve listens report response time ([89440da](https://github.com/apricote/Listory/commit/89440daf7ba38ff97fabd19cf3a9d11ab21efb45))
|
||||
|
||||
## [1.26.2](https://github.com/apricote/Listory/compare/v1.26.1...v1.26.2) (2023-09-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* failing healthcheck for spotify api ([6898687](https://github.com/apricote/Listory/commit/689868798dbbcf2e3c0077ece154b5511edd73c4))
|
||||
|
||||
## [1.26.1](https://github.com/apricote/Listory/compare/v1.26.0...v1.26.1) (2023-05-07)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
# syntax=docker/dockerfile:1.5
|
||||
|
||||
FROM scratch as ignore
|
||||
|
||||
WORKDIR /listory
|
||||
COPY . /listory/
|
||||
# syntax=docker/dockerfile:1.12
|
||||
|
||||
##################
|
||||
## common
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ type: application
|
|||
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
version: 1.26.1
|
||||
version: 1.31.0
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application.
|
||||
appVersion: 1.26.1
|
||||
appVersion: 1.31.0
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ services:
|
|||
#####
|
||||
|
||||
db:
|
||||
image: postgres:15.2
|
||||
image: postgres:16.6
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_PASSWORD: listory
|
||||
|
|
@ -18,7 +18,7 @@ services:
|
|||
- db
|
||||
|
||||
api:
|
||||
image: apricote/listory:1.26.1
|
||||
image: apricote/listory:1.31.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DB_HOST: db
|
||||
|
|
@ -32,7 +32,7 @@ services:
|
|||
# make sure to restart the container if you made any changes.
|
||||
env_file: .env
|
||||
ports:
|
||||
- 3000:3000 # API
|
||||
- "3000:3000" # API
|
||||
networks:
|
||||
- web
|
||||
- db
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ services:
|
|||
#####
|
||||
|
||||
db:
|
||||
image: postgres:15.2
|
||||
image: postgres:16.6
|
||||
environment:
|
||||
POSTGRES_PASSWORD: listory
|
||||
POSTGRES_USER: listory
|
||||
|
|
@ -37,7 +37,8 @@ services:
|
|||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- ./src:/app/src:ro
|
||||
- ./dist:/app/dist # build cache
|
||||
ports:
|
||||
- 3000 # API
|
||||
- "9464:9464" # Metrics
|
||||
|
|
@ -72,7 +73,7 @@ services:
|
|||
- web
|
||||
|
||||
proxy:
|
||||
image: traefik:v2.10.1
|
||||
image: traefik:v2.11.15
|
||||
command:
|
||||
#- --log.level=debug
|
||||
#- --accesslog=true
|
||||
|
|
@ -115,7 +116,8 @@ services:
|
|||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- ./src:/app/src:ro
|
||||
- ./dist:/app/dist # build cache
|
||||
ports:
|
||||
- "9464:9464" # Metrics
|
||||
networks:
|
||||
|
|
@ -129,7 +131,7 @@ services:
|
|||
|
||||
prometheus:
|
||||
profiles: ["observability"]
|
||||
image: prom/prometheus:v2.43.1
|
||||
image: prom/prometheus:v2.55.1
|
||||
volumes:
|
||||
- ./observability/prometheus:/etc/prometheus
|
||||
- prometheus_data:/prometheus
|
||||
|
|
@ -139,14 +141,14 @@ services:
|
|||
- "--storage.tsdb.retention.time=200h"
|
||||
- "--web.enable-lifecycle"
|
||||
ports:
|
||||
- 9090:9090
|
||||
- "9090:9090"
|
||||
networks:
|
||||
- observability
|
||||
- web
|
||||
|
||||
loki:
|
||||
profiles: ["observability"]
|
||||
image: grafana/loki:2.8.2
|
||||
image: grafana/loki:2.9.11
|
||||
command: ["-config.file=/etc/loki/loki.yaml"]
|
||||
ports:
|
||||
- "3100" # loki needs to be exposed so it receives logs
|
||||
|
|
@ -157,7 +159,7 @@ services:
|
|||
|
||||
promtail:
|
||||
profiles: ["observability"]
|
||||
image: grafana/promtail:2.8.2
|
||||
image: grafana/promtail:2.9.11
|
||||
command: ["-config.file=/etc/promtail.yaml"]
|
||||
volumes:
|
||||
- ./observability/promtail/promtail.yaml:/etc/promtail.yaml
|
||||
|
|
@ -175,7 +177,7 @@ services:
|
|||
|
||||
tempo:
|
||||
profiles: ["observability"]
|
||||
image: grafana/tempo:2.1.1
|
||||
image: grafana/tempo:2.6.1
|
||||
command: ["-config.file=/etc/tempo.yaml"]
|
||||
volumes:
|
||||
- ./observability/tempo/tempo.yaml:/etc/tempo.yaml
|
||||
|
|
@ -189,7 +191,7 @@ services:
|
|||
|
||||
grafana:
|
||||
profiles: ["observability"]
|
||||
image: grafana/grafana-oss:9.5.1
|
||||
image: grafana/grafana-oss:10.4.14
|
||||
volumes:
|
||||
- ./observability/grafana/provisioning:/etc/grafana/provisioning
|
||||
environment:
|
||||
|
|
@ -200,7 +202,7 @@ services:
|
|||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
- GF_SERVER_HTTP_PORT=2345
|
||||
ports:
|
||||
- 2345:2345
|
||||
- "2345:2345"
|
||||
networks:
|
||||
- observability
|
||||
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.<br />
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.<br />
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.<br />
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.<br />
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.<br />
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
16
frontend/components.json
Normal file
16
frontend/components.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "./src/index.css",
|
||||
"baseColor": "gray",
|
||||
"cssVariables": false
|
||||
},
|
||||
"aliases": {
|
||||
"components": "src/components",
|
||||
"utils": "src/lib/utils"
|
||||
}
|
||||
}
|
||||
13620
frontend/package-lock.json
generated
13620
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,32 +8,44 @@
|
|||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "14.0.0",
|
||||
"@testing-library/user-event": "14.4.3",
|
||||
"@types/jest": "29.5.1",
|
||||
"@types/node": "18.16.5",
|
||||
"@types/react": "18.2.6",
|
||||
"@types/react-dom": "18.2.4",
|
||||
"@radix-ui/react-avatar": "1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.3",
|
||||
"@radix-ui/react-label": "2.1.1",
|
||||
"@radix-ui/react-navigation-menu": "1.2.2",
|
||||
"@radix-ui/react-select": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.1.1",
|
||||
"@testing-library/jest-dom": "6.6.3",
|
||||
"@testing-library/react": "16.1.0",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "20.17.16",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/react-router-dom": "5.3.3",
|
||||
"@types/recharts": "1.8.24",
|
||||
"@vitejs/plugin-react": "4.0.0",
|
||||
"autoprefixer": "10.4.14",
|
||||
"axios": "0.27.2",
|
||||
"@types/recharts": "1.8.29",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"autoprefixer": "10.4.20",
|
||||
"axios": "1.7.9",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"date-fns": "2.30.0",
|
||||
"eslint-config-react-app": "7.0.1",
|
||||
"jsdom": "22.0.0",
|
||||
"jsdom": "22.1.0",
|
||||
"lucide-react": "0.468.0",
|
||||
"npm-run-all": "4.1.5",
|
||||
"postcss": "8.4.23",
|
||||
"prettier": "2.8.8",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "6.11.1",
|
||||
"recharts": "2.5.0",
|
||||
"tailwindcss": "3.3.2",
|
||||
"typescript": "5.0.4",
|
||||
"vite": "4.3.5",
|
||||
"vitest": "0.31.0"
|
||||
"postcss": "8.4.49",
|
||||
"prettier": "3.4.2",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-files": "3.0.3",
|
||||
"react-router-dom": "6.28.0",
|
||||
"recharts": "2.15.0",
|
||||
"tailwind-merge": "1.14.0",
|
||||
"tailwindcss": "3.4.16",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"typescript": "5.7.2",
|
||||
"vite": "5.4.12",
|
||||
"vitest": "1.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "prettier --write \"./*.js\" \"src/**/*.(tsx|ts|css)\"",
|
||||
|
|
|
|||
|
|
@ -3,4 +3,4 @@ module.exports = {
|
|||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,20 +2,21 @@ import React from "react";
|
|||
import { Route, Routes } from "react-router-dom";
|
||||
import { AuthApiTokens } from "./components/AuthApiTokens";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { ImportListens } from "./components/ImportListens";
|
||||
import { LoginFailure } from "./components/LoginFailure";
|
||||
import { LoginLoading } from "./components/LoginLoading";
|
||||
import { LoginSuccess } from "./components/LoginSuccess";
|
||||
import { NavBar } from "./components/NavBar";
|
||||
import { RecentListens } from "./components/RecentListens";
|
||||
import { ReportListens } from "./components/ReportListens";
|
||||
import { ReportTopAlbums } from "./components/ReportTopAlbums";
|
||||
import { ReportTopArtists } from "./components/ReportTopArtists";
|
||||
import { ReportTopGenres } from "./components/ReportTopGenres";
|
||||
import { ReportTopTracks } from "./components/ReportTopTracks";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { RecentListens } from "./components/reports/RecentListens";
|
||||
import { ReportListens } from "./components/reports/ReportListens";
|
||||
import { ReportTopAlbums } from "./components/reports/ReportTopAlbums";
|
||||
import { ReportTopArtists } from "./components/reports/ReportTopArtists";
|
||||
import { ReportTopGenres } from "./components/reports/ReportTopGenres";
|
||||
import { ReportTopTracks } from "./components/reports/ReportTopTracks";
|
||||
import { useAuth } from "./hooks/use-auth";
|
||||
|
||||
export function App() {
|
||||
const { isLoaded } = useAuth();
|
||||
const { isLoaded, user } = useAuth();
|
||||
|
||||
if (!isLoaded) {
|
||||
return <LoginLoading />;
|
||||
|
|
@ -27,18 +28,43 @@ export function App() {
|
|||
<NavBar />
|
||||
</header>
|
||||
<main className="mb-auto" /* mb-auto is for sticky footer */>
|
||||
<Routes>
|
||||
<Route path="/" />
|
||||
<Route path="/login/success" element={<LoginSuccess />} />
|
||||
<Route path="/login/failure" element={<LoginFailure />} />
|
||||
<Route path="/listens" element={<RecentListens />} />
|
||||
<Route path="/reports/listens" element={<ReportListens />} />
|
||||
<Route path="/reports/top-artists" element={<ReportTopArtists />} />
|
||||
<Route path="/reports/top-albums" element={<ReportTopAlbums />} />
|
||||
<Route path="/reports/top-tracks" element={<ReportTopTracks />} />
|
||||
<Route path="/reports/top-genres" element={<ReportTopGenres />} />
|
||||
<Route path="/auth/api-tokens" element={<AuthApiTokens />} />
|
||||
</Routes>
|
||||
<div className="md:flex md:justify-center p-4 text-gray-700 dark:text-gray-400">
|
||||
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 lg:max-w-screen-lg">
|
||||
{user && (
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="/listens" />} />
|
||||
<Route path="/login/success" element={<Navigate to="/" />} />
|
||||
<Route path="/login/failure" element={<LoginFailure />} />
|
||||
<Route path="/listens" element={<RecentListens />} />
|
||||
<Route path="/reports/listens" element={<ReportListens />} />
|
||||
<Route
|
||||
path="/reports/top-artists"
|
||||
element={<ReportTopArtists />}
|
||||
/>
|
||||
<Route
|
||||
path="/reports/top-albums"
|
||||
element={<ReportTopAlbums />}
|
||||
/>
|
||||
<Route
|
||||
path="/reports/top-tracks"
|
||||
element={<ReportTopTracks />}
|
||||
/>
|
||||
<Route
|
||||
path="/reports/top-genres"
|
||||
element={<ReportTopGenres />}
|
||||
/>
|
||||
<Route path="/auth/api-tokens" element={<AuthApiTokens />} />
|
||||
<Route path="/import" element={<ImportListens />} />
|
||||
</Routes>
|
||||
)}
|
||||
{!user && (
|
||||
<Routes>
|
||||
<Route index />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<Footer />
|
||||
|
|
|
|||
|
|
@ -14,12 +14,14 @@ import { TopGenresItem } from "./entities/top-genres-item";
|
|||
import { TopGenresOptions } from "./entities/top-genres-options";
|
||||
import { TopTracksItem } from "./entities/top-tracks-item";
|
||||
import { TopTracksOptions } from "./entities/top-tracks-options";
|
||||
import { SpotifyExtendedStreamingHistoryItem } from "./entities/spotify-extended-streaming-history-item";
|
||||
import { ExtendedStreamingHistoryStatus } from "./entities/extended-streaming-history-status";
|
||||
|
||||
export class UnauthenticatedError extends Error {}
|
||||
|
||||
export const getRecentListens = async (
|
||||
options: PaginationOptions = { page: 1, limit: 10 },
|
||||
client: AxiosInstance
|
||||
client: AxiosInstance,
|
||||
): Promise<Pagination<Listen>> => {
|
||||
const { page, limit } = options;
|
||||
|
||||
|
|
@ -44,7 +46,7 @@ export const getRecentListens = async (
|
|||
|
||||
export const getListensReport = async (
|
||||
options: ListenReportOptions,
|
||||
client: AxiosInstance
|
||||
client: AxiosInstance,
|
||||
): Promise<ListenReportItem[]> => {
|
||||
const {
|
||||
timeFrame,
|
||||
|
|
@ -60,7 +62,7 @@ export const getListensReport = async (
|
|||
customTimeStart: formatISO(customTimeStart),
|
||||
customTimeEnd: formatISO(customTimeEnd),
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
switch (res.status) {
|
||||
|
|
@ -83,7 +85,7 @@ export const getListensReport = async (
|
|||
|
||||
export const getTopArtists = async (
|
||||
options: TopArtistsOptions,
|
||||
client: AxiosInstance
|
||||
client: AxiosInstance,
|
||||
): Promise<TopArtistsItem[]> => {
|
||||
const {
|
||||
time: { timePreset, customTimeStart, customTimeEnd },
|
||||
|
|
@ -97,7 +99,7 @@ export const getTopArtists = async (
|
|||
customTimeStart: formatISO(customTimeStart),
|
||||
customTimeEnd: formatISO(customTimeEnd),
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
switch (res.status) {
|
||||
|
|
@ -120,7 +122,7 @@ export const getTopArtists = async (
|
|||
|
||||
export const getTopAlbums = async (
|
||||
options: TopAlbumsOptions,
|
||||
client: AxiosInstance
|
||||
client: AxiosInstance,
|
||||
): Promise<TopAlbumsItem[]> => {
|
||||
const {
|
||||
time: { timePreset, customTimeStart, customTimeEnd },
|
||||
|
|
@ -134,7 +136,7 @@ export const getTopAlbums = async (
|
|||
customTimeStart: formatISO(customTimeStart),
|
||||
customTimeEnd: formatISO(customTimeEnd),
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
switch (res.status) {
|
||||
|
|
@ -157,7 +159,7 @@ export const getTopAlbums = async (
|
|||
|
||||
export const getTopTracks = async (
|
||||
options: TopTracksOptions,
|
||||
client: AxiosInstance
|
||||
client: AxiosInstance,
|
||||
): Promise<TopTracksItem[]> => {
|
||||
const {
|
||||
time: { timePreset, customTimeStart, customTimeEnd },
|
||||
|
|
@ -171,7 +173,7 @@ export const getTopTracks = async (
|
|||
customTimeStart: formatISO(customTimeStart),
|
||||
customTimeEnd: formatISO(customTimeEnd),
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
switch (res.status) {
|
||||
|
|
@ -194,7 +196,7 @@ export const getTopTracks = async (
|
|||
|
||||
export const getTopGenres = async (
|
||||
options: TopGenresOptions,
|
||||
client: AxiosInstance
|
||||
client: AxiosInstance,
|
||||
): Promise<TopGenresItem[]> => {
|
||||
const {
|
||||
time: { timePreset, customTimeStart, customTimeEnd },
|
||||
|
|
@ -208,7 +210,7 @@ export const getTopGenres = async (
|
|||
customTimeStart: formatISO(customTimeStart),
|
||||
customTimeEnd: formatISO(customTimeEnd),
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
switch (res.status) {
|
||||
|
|
@ -230,7 +232,7 @@ export const getTopGenres = async (
|
|||
};
|
||||
|
||||
export const getApiTokens = async (
|
||||
client: AxiosInstance
|
||||
client: AxiosInstance,
|
||||
): Promise<ApiToken[]> => {
|
||||
const res = await client.get<ApiToken[]>(`/api/v1/auth/api-tokens`);
|
||||
|
||||
|
|
@ -251,7 +253,7 @@ export const getApiTokens = async (
|
|||
|
||||
export const createApiToken = async (
|
||||
description: string,
|
||||
client: AxiosInstance
|
||||
client: AxiosInstance,
|
||||
): Promise<NewApiToken> => {
|
||||
const res = await client.post<NewApiToken>(`/api/v1/auth/api-tokens`, {
|
||||
description,
|
||||
|
|
@ -274,9 +276,9 @@ export const createApiToken = async (
|
|||
|
||||
export const revokeApiToken = async (
|
||||
id: string,
|
||||
client: AxiosInstance
|
||||
client: AxiosInstance,
|
||||
): Promise<void> => {
|
||||
const res = await client.delete<NewApiToken>(`/api/v1/auth/api-tokens/${id}`);
|
||||
const res = await client.delete(`/api/v1/auth/api-tokens/${id}`);
|
||||
|
||||
switch (res.status) {
|
||||
case 200: {
|
||||
|
|
@ -290,3 +292,50 @@ export const revokeApiToken = async (
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const importExtendedStreamingHistory = async (
|
||||
listens: SpotifyExtendedStreamingHistoryItem[],
|
||||
client: AxiosInstance
|
||||
): Promise<void> => {
|
||||
const res = await client.post(`/api/v1/import/extended-streaming-history`, {
|
||||
listens,
|
||||
});
|
||||
|
||||
switch (res.status) {
|
||||
case 201: {
|
||||
break;
|
||||
}
|
||||
case 401: {
|
||||
throw new UnauthenticatedError(`No token or token expired`);
|
||||
}
|
||||
default: {
|
||||
throw new Error(
|
||||
`Unable to importExtendedStreamingHistory: ${res.status}`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getExtendedStreamingHistoryStatus = async (
|
||||
client: AxiosInstance
|
||||
): Promise<ExtendedStreamingHistoryStatus> => {
|
||||
const res = await client.get<ExtendedStreamingHistoryStatus>(
|
||||
`/api/v1/import/extended-streaming-history/status`
|
||||
);
|
||||
|
||||
switch (res.status) {
|
||||
case 200: {
|
||||
break;
|
||||
}
|
||||
case 401: {
|
||||
throw new UnauthenticatedError(`No token or token expired`);
|
||||
}
|
||||
default: {
|
||||
throw new Error(
|
||||
`Unable to getExtendedStreamingHistoryStatus: ${res.status}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return res.data;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
export interface ExtendedStreamingHistoryStatus {
|
||||
total: number;
|
||||
imported: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export interface SpotifyExtendedStreamingHistoryItem {
|
||||
ts: string;
|
||||
spotify_track_uri: string;
|
||||
}
|
||||
|
|
@ -2,65 +2,61 @@ import { format, formatDistanceToNow } from "date-fns";
|
|||
import React, { FormEvent, useCallback, useMemo, useState } from "react";
|
||||
import { ApiToken, NewApiToken } from "../api/entities/api-token";
|
||||
import { useApiTokens } from "../hooks/use-api";
|
||||
import { useAuthProtection } from "../hooks/use-auth-protection";
|
||||
import { SpinnerIcon } from "../icons/Spinner";
|
||||
import TrashcanIcon from "../icons/Trashcan";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { Spinner } from "./ui/Spinner";
|
||||
|
||||
export const AuthApiTokens: React.FC = () => {
|
||||
const { requireUser } = useAuthProtection();
|
||||
|
||||
const { apiTokens, isLoading, createToken, revokeToken } = useApiTokens();
|
||||
const sortedTokens = useMemo(
|
||||
() => apiTokens.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1)),
|
||||
[apiTokens]
|
||||
() =>
|
||||
apiTokens
|
||||
.filter((token) => !token.revokedAt)
|
||||
.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1)),
|
||||
[apiTokens],
|
||||
);
|
||||
|
||||
requireUser();
|
||||
|
||||
return (
|
||||
<div className="md:flex md:justify-center p-4 text-gray-700 dark:text-gray-400">
|
||||
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-2xl font-normal">API Tokens</p>
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<p className="text-2xl font-normal">API Tokens</p>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-4 m-2">
|
||||
<p className="mb-4">
|
||||
You can use API Tokens to access the Listory API directly. You can
|
||||
find the API docs{" "}
|
||||
<a href="/api/docs" target="_blank" className={"underline"}>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div className="mb-4">
|
||||
<NewTokenForm createToken={createToken} />
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-4 m-2">
|
||||
<p className="mb-4">
|
||||
You can use API Tokens to access the Listory API directly. You can
|
||||
find the API docs{" "}
|
||||
<a href="/api/docs" target="_blank">
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div className="mb-4">
|
||||
<NewTokenForm createToken={createToken} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl">Manage Existing Tokens</h3>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{sortedTokens.length === 0 && (
|
||||
<div className="text-center m-4">
|
||||
<p className="">Could not find any api tokens!</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-xl">Manage Existing Tokens</h3>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{sortedTokens.length === 0 && (
|
||||
<div className="text-center m-4">
|
||||
<p className="">Could not find any api tokens!</p>
|
||||
{sortedTokens.length > 0 && (
|
||||
<div className="table-auto w-full">
|
||||
{sortedTokens.map((apiToken) => (
|
||||
<ApiTokenItem
|
||||
apiToken={apiToken}
|
||||
revokeToken={revokeToken}
|
||||
key={apiToken.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{sortedTokens.length > 0 && (
|
||||
<div className="table-auto w-full">
|
||||
{sortedTokens.map((apiToken) => (
|
||||
<ApiTokenItem
|
||||
apiToken={apiToken}
|
||||
revokeToken={revokeToken}
|
||||
key={apiToken.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -97,7 +93,7 @@ const NewTokenForm: React.FC<{
|
|||
createToken,
|
||||
setNewToken,
|
||||
setNewTokenDescription,
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
280
frontend/src/components/ImportListens.tsx
Normal file
280
frontend/src/components/ImportListens.tsx
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Files from "react-files";
|
||||
import type { ReactFile } from "react-files";
|
||||
import {
|
||||
useSpotifyImportExtendedStreamingHistory,
|
||||
useSpotifyImportExtendedStreamingHistoryStatus,
|
||||
} from "../hooks/use-api";
|
||||
import { SpotifyExtendedStreamingHistoryItem } from "../api/entities/spotify-extended-streaming-history-item";
|
||||
import { ErrorIcon } from "../icons/Error";
|
||||
import { numberToPercent } from "../util/numberToPercent";
|
||||
import { Button } from "./ui/button";
|
||||
import { Table, TableBody, TableCell, TableRow } from "./ui/table";
|
||||
import { Badge } from "./ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./ui/card";
|
||||
import { Code } from "./ui/code";
|
||||
|
||||
export const ImportListens: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<p className="text-2xl font-normal">
|
||||
Import Listens from Spotify Extended Streaming History
|
||||
</p>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-4 m-2">
|
||||
<p className="my-4">
|
||||
Here you can import your full Spotify Listen history that was exported
|
||||
from the{" "}
|
||||
<a
|
||||
target="blank"
|
||||
href="https://www.spotify.com/us/account/privacy/"
|
||||
className="underline"
|
||||
>
|
||||
Extended streaming history
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
The extended streaming history contains additional personally
|
||||
identifiable data such as the IP address of the listen (which can be
|
||||
linked to locations). To avoid saving this on the server, the data is
|
||||
preprocessed in your web browser and only the necessary data
|
||||
(timestamp & track ID) are sent to the server.
|
||||
</p>
|
||||
<p className="my-4">
|
||||
If an error occurs, you can always retry uploading the file, Listory
|
||||
deduplicates any listens to make sure that everything is saved only
|
||||
once.
|
||||
</p>
|
||||
|
||||
<FileUpload />
|
||||
<ImportProgress />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface FileData {
|
||||
file: ReactFile;
|
||||
status: Status;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
enum Status {
|
||||
Select,
|
||||
Import,
|
||||
Finished,
|
||||
Error,
|
||||
}
|
||||
|
||||
const FileUpload: React.FC = () => {
|
||||
// Using a map is ... meh, need to wrap all state updates in `new Map()` so react re-renders
|
||||
const [fileMap, setFileMap] = useState<Map<ReactFile["id"], FileData>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const [status, setStatus] = useState<Status>(Status.Select);
|
||||
|
||||
const addFiles = useCallback(
|
||||
(files: ReactFile[]) => {
|
||||
setFileMap((_fileMap) => {
|
||||
files.forEach((file) =>
|
||||
_fileMap.set(file.id, { file, status: Status.Select }),
|
||||
);
|
||||
return new Map(_fileMap);
|
||||
});
|
||||
},
|
||||
[setFileMap],
|
||||
);
|
||||
|
||||
const updateFile = useCallback((data: FileData) => {
|
||||
setFileMap((_fileMap) => new Map(_fileMap.set(data.file.id, data)));
|
||||
}, []);
|
||||
|
||||
const clearFiles = useCallback(() => {
|
||||
setFileMap(new Map());
|
||||
}, [setFileMap]);
|
||||
|
||||
const { importHistory } = useSpotifyImportExtendedStreamingHistory();
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
setStatus(Status.Import);
|
||||
|
||||
let errorOccurred = false;
|
||||
|
||||
for (const data of fileMap.values()) {
|
||||
data.status = Status.Import;
|
||||
updateFile(data);
|
||||
|
||||
let items: SpotifyExtendedStreamingHistoryItem[];
|
||||
|
||||
// Scope so these tmp variables can be GC-ed ASAP
|
||||
{
|
||||
const fileContent = await data.file.text();
|
||||
|
||||
const rawItems = JSON.parse(
|
||||
fileContent,
|
||||
) as SpotifyExtendedStreamingHistoryItem[];
|
||||
|
||||
items = rawItems
|
||||
.filter(({ spotify_track_uri }) => spotify_track_uri !== null)
|
||||
.map(({ ts, spotify_track_uri }) => ({
|
||||
ts,
|
||||
spotify_track_uri,
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
await importHistory(items);
|
||||
|
||||
data.status = Status.Finished;
|
||||
} catch (err) {
|
||||
data.error = err as Error;
|
||||
data.status = Status.Error;
|
||||
|
||||
errorOccurred = true;
|
||||
}
|
||||
updateFile(data);
|
||||
}
|
||||
|
||||
if (!errorOccurred) {
|
||||
setStatus(Status.Finished);
|
||||
}
|
||||
}, [fileMap, importHistory, updateFile]);
|
||||
|
||||
return (
|
||||
<Card className="mb-5">
|
||||
<CardHeader>
|
||||
<CardTitle>File Upload</CardTitle>
|
||||
<CardDescription>
|
||||
Select <Code>endsong_XY.json</Code> files here and start the import.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Files
|
||||
className="shadow-inner bg-gray-200 dark:bg-gray-700 rounded p-4 text-center cursor-pointer"
|
||||
dragActiveClassName=""
|
||||
onChange={addFiles}
|
||||
accepts={["application/json"]}
|
||||
multiple
|
||||
clickable
|
||||
>
|
||||
Drop files here or click to upload
|
||||
</Files>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{Array.from(fileMap.values()).map((data) => (
|
||||
<File key={data.file.id} data={data} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter className="flex gap-x-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleImport().catch((e) => console.error("Import Failed:", e))
|
||||
}
|
||||
variant="secondary"
|
||||
disabled={status !== Status.Select}
|
||||
>
|
||||
Start Import
|
||||
</Button>
|
||||
<Button
|
||||
onClick={clearFiles}
|
||||
variant="secondary"
|
||||
disabled={status !== Status.Select}
|
||||
>
|
||||
Remove All Files
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const File: React.FC<{ data: FileData }> = ({ data }) => {
|
||||
const hasErrors = data.status === Status.Error && data.error;
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>{data.file.name}</TableCell>
|
||||
<TableCell className="text-sm font-thin">
|
||||
{data.file.sizeReadable}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{data.status === Status.Select && <Badge>Prepared for import!</Badge>}
|
||||
{data.status === Status.Import && <Badge>Loading!</Badge>}
|
||||
{data.status === Status.Finished && <Badge>Check!</Badge>}
|
||||
{hasErrors && (
|
||||
<Badge variant="destructive">
|
||||
<ErrorIcon />
|
||||
{data.error?.message}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
const ImportProgress: React.FC = () => {
|
||||
const {
|
||||
importStatus: { total, imported },
|
||||
isLoading,
|
||||
reload,
|
||||
} = useSpotifyImportExtendedStreamingHistoryStatus();
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (!isLoading) {
|
||||
reload();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isLoading, reload]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Import Progress</CardTitle>
|
||||
<CardDescription>
|
||||
Shows how many of the submitted listens are already imported and
|
||||
visible to you. This will take a while, and the process might halt for
|
||||
a few minutes if we hit the Spotify API rate limit. If this is not
|
||||
finished after a few hours, please contact your Listory administrator.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex pb-2">
|
||||
<div className="md:flex w-10/12">
|
||||
<div className={`md:w-full font-bold`}>
|
||||
Imported
|
||||
<br />
|
||||
{imported}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-2/12 text-right">
|
||||
Total
|
||||
<br />
|
||||
{total}
|
||||
</div>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<div className="h-2 w-full bg-gradient-to-r from-teal-200/25 via-green-400 to-violet-400 dark:from-teal-700/25 dark:via-green-600/85 dark:to-amber-500 flex flex-row-reverse">
|
||||
<div
|
||||
style={{ width: numberToPercent(1 - imported / total) }}
|
||||
className="h-full bg-gray-100 dark:bg-gray-900"
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { Spinner } from "./ui/Spinner";
|
||||
|
||||
export const LoginLoading: React.FC = () => (
|
||||
<main className="sm:flex sm:justify-center p-4 dark:bg-gray-900 h-screen">
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export const LoginSuccess: React.FC = () => {
|
||||
useNavigate()("/", { replace: false });
|
||||
return null;
|
||||
};
|
||||
|
|
@ -1,55 +1,124 @@
|
|||
import React, { useCallback, useRef, useState } from "react";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { User } from "../api/entities/user";
|
||||
import { useAuth } from "../hooks/use-auth";
|
||||
import { useOutsideClick } from "../hooks/useOutsideClick";
|
||||
import { CogwheelIcon } from "../icons/Cogwheel";
|
||||
import { ImportIcon } from "../icons/Import";
|
||||
import { SpotifyLogo } from "../icons/Spotify";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "./ui/avatar";
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
} from "./ui/navigation-menu";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export const NavBar: React.FC = () => {
|
||||
const { user, loginWithSpotifyProps } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between flex-wrap bg-green-500 dark:bg-gray-800 p-6">
|
||||
<div className="flex items-center shrink-0 text-white mr-6">
|
||||
<span className="font-semibold text-xl tracking-tight">Listory</span>
|
||||
<div className="flex items-center justify-between flex-wrap py-3 px-6 bg-green-500 dark:bg-gray-800 dark:text-gray-100">
|
||||
<div className="flex items-center shrink-0 mr-6">
|
||||
<span className="font-semibold text-xl tracking-tight text-white">
|
||||
Listory
|
||||
</span>
|
||||
</div>
|
||||
<nav className="w-full block grow lg:flex lg:items-center lg:w-auto ">
|
||||
<div className="text-sm lg:grow">
|
||||
<nav className="w-full grow sm:flex sm:items-center sm:w-auto">
|
||||
<div className="sm:grow">
|
||||
{user && (
|
||||
<>
|
||||
<Link to="/">
|
||||
<NavItem>Home</NavItem>
|
||||
</Link>
|
||||
<Link to="/listens">
|
||||
<NavItem>Your Listens</NavItem>
|
||||
</Link>
|
||||
<Link to="/reports/listens">
|
||||
<NavItem>Listens Report</NavItem>
|
||||
</Link>
|
||||
<Link to="/reports/top-artists">
|
||||
<NavItem>Top Artists</NavItem>
|
||||
</Link>
|
||||
<Link to="/reports/top-albums">
|
||||
<NavItem>Top Albums</NavItem>
|
||||
</Link>
|
||||
<Link to="/reports/top-tracks">
|
||||
<NavItem>Top Tracks</NavItem>
|
||||
</Link>
|
||||
<Link to="/reports/top-genres">
|
||||
<NavItem>Top Genres</NavItem>
|
||||
</Link>
|
||||
</>
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink
|
||||
asChild
|
||||
className={navigationMenuTriggerStyle()}
|
||||
>
|
||||
<Link to="/">Home</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink
|
||||
asChild
|
||||
className={navigationMenuTriggerStyle()}
|
||||
>
|
||||
<Link to="/listens">Your Listens</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>Reports</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<ul className="grid gap-3 p-4 grid-flow-row grid-cols-1 sm:grid-cols-2 w-6 min-w-max sm:min-w-fit sm:w-[500px]">
|
||||
<NavListItem title="Listens" to={"/reports/listens"}>
|
||||
When did you listen how much music?
|
||||
</NavListItem>
|
||||
|
||||
<NavListItem
|
||||
title="Top Artists"
|
||||
to={"/reports/top-artists"}
|
||||
>
|
||||
What are your top artists in the last week/month/year?
|
||||
</NavListItem>
|
||||
|
||||
<NavListItem
|
||||
title="Top Albums"
|
||||
to={"/reports/top-albums"}
|
||||
>
|
||||
What are your top albums in the last week/month/year?
|
||||
</NavListItem>
|
||||
|
||||
<NavListItem
|
||||
title="Top Tracks"
|
||||
to={"/reports/top-tracks"}
|
||||
>
|
||||
What are your top tracks in the last week/month/year?
|
||||
</NavListItem>
|
||||
|
||||
<NavListItem
|
||||
title="Top Genres"
|
||||
to={"/reports/top-genres"}
|
||||
>
|
||||
What are your top genres in the last week/month/year?
|
||||
</NavListItem>
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{!user && (
|
||||
<a {...loginWithSpotifyProps()}>
|
||||
<NavItem>
|
||||
Login with Spotify{" "}
|
||||
<SpotifyLogo className="w-6 h-6 ml-2 mb-1 inline fill-current text-white" />
|
||||
</NavItem>
|
||||
</a>
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink
|
||||
asChild
|
||||
className={navigationMenuTriggerStyle()}
|
||||
>
|
||||
<a {...loginWithSpotifyProps()}>
|
||||
<span>Login with Spotify </span>
|
||||
<SpotifyLogo className="w-6 h-6 ml-2 mb-1 inline fill-current text-white" />
|
||||
</a>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
)}
|
||||
{user && <NavUserInfo user={user} />}
|
||||
</div>
|
||||
|
|
@ -58,58 +127,76 @@ export const NavBar: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const NavItem: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const NavListItem = React.forwardRef<
|
||||
React.ElementRef<typeof Link>,
|
||||
React.ComponentPropsWithoutRef<typeof Link>
|
||||
>(({ className, title, children, ...props }, ref) => {
|
||||
return (
|
||||
<span className="block mt-4 lg:inline-block lg:mt-0 text-green-200 hover:text-white mr-4">
|
||||
{children}
|
||||
</span>
|
||||
<li>
|
||||
<NavigationMenuLink asChild>
|
||||
<Link
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="text-sm font-medium leading-none">{title}</div>
|
||||
<p className="line-clamp-3 text-sm leading-snug text-muted-foreground">
|
||||
{children}
|
||||
</p>
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
});
|
||||
NavListItem.displayName = "NavListItem";
|
||||
|
||||
const NavUserInfo: React.FC<{ user: User }> = ({ user }) => {
|
||||
const [menuOpen, setMenuOpen] = useState<boolean>(false);
|
||||
const closeMenu = useCallback(() => setMenuOpen(false), [setMenuOpen]);
|
||||
|
||||
const wrapperRef = useRef(null);
|
||||
useOutsideClick(wrapperRef, closeMenu);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef}>
|
||||
<div
|
||||
className="flex items-center mr-4 mt-4 lg:mt-0 cursor-pointer"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
>
|
||||
<span className="text-green-200 text-sm">{user.displayName}</span>
|
||||
{user.photo && (
|
||||
<img
|
||||
className="w-6 h-6 rounded-full ml-4"
|
||||
src={user.photo}
|
||||
alt="Profile of logged in user"
|
||||
></img>
|
||||
)}
|
||||
</div>
|
||||
{menuOpen ? <NavUserInfoMenu closeMenu={closeMenu} /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NavUserInfoMenu: React.FC<{ closeMenu: () => void }> = ({
|
||||
closeMenu,
|
||||
}) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="drop-down w-48 overflow-hidden bg-green-100 dark:bg-gray-700 text-gray-700 dark:text-green-200 rounded-md shadow absolute top-3 right-3">
|
||||
<ul>
|
||||
<li className="px-3 py-3 text-sm font-medium flex items-center space-x-2 hover:bg-green-200 hover:text-gray-800 dark:hover:text-white">
|
||||
<span>
|
||||
<CogwheelIcon className="w-5 h-5 fill-current" />
|
||||
</span>
|
||||
<Link to="/auth/api-tokens" onClick={closeMenu}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className="flex flex-row-reverse sm:flex-row px-0 mt-2 sm:px-8"
|
||||
>
|
||||
<span className="text-green-200 pl-2 sm:pr-2">
|
||||
{user.displayName}
|
||||
</span>
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={user.photo}
|
||||
alt="Profile picture of logged in user"
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{user.displayName
|
||||
.split(" ")
|
||||
.filter((name) => name.length > 0)
|
||||
.map((name) => name[0].toUpperCase())
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/auth/api-tokens">
|
||||
<CogwheelIcon className="w-5 h-5 fill-current pr-2" />
|
||||
API Tokens
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/import">
|
||||
<ImportIcon className="w-5 h-5 fill-current pr-2" />
|
||||
Import Listens
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { Album } from "../api/entities/album";
|
||||
import { TimeOptions } from "../api/entities/time-options";
|
||||
import { TimePreset } from "../api/entities/time-preset.enum";
|
||||
import { useTopAlbums } from "../hooks/use-api";
|
||||
import { useAuthProtection } from "../hooks/use-auth-protection";
|
||||
import { getMaxCount } from "../util/getMaxCount";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { TopListItem } from "./TopListItem";
|
||||
|
||||
export const ReportTopAlbums: React.FC = () => {
|
||||
const { requireUser } = useAuthProtection();
|
||||
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_90_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
time: timeOptions,
|
||||
}),
|
||||
[timeOptions]
|
||||
);
|
||||
|
||||
const { topAlbums, isLoading } = useTopAlbums(options);
|
||||
|
||||
const reportHasItems = topAlbums.length !== 0;
|
||||
const maxCount = getMaxCount(topAlbums);
|
||||
|
||||
requireUser();
|
||||
|
||||
return (
|
||||
<div className="md:flex md:justify-center p-4">
|
||||
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Top Albums
|
||||
</p>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems &&
|
||||
topAlbums.map(({ album, count }) => (
|
||||
<ReportItem
|
||||
key={album.id}
|
||||
album={album}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ReportItem: React.FC<{
|
||||
album: Album;
|
||||
count: number;
|
||||
maxCount: number;
|
||||
}> = ({ album, count, maxCount }) => {
|
||||
const artists = album.artists?.map((artist) => artist.name).join(", ") || "";
|
||||
|
||||
return (
|
||||
<TopListItem
|
||||
key={album.id}
|
||||
title={album.name}
|
||||
subTitle={artists}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { TimeOptions } from "../api/entities/time-options";
|
||||
import { TimePreset } from "../api/entities/time-preset.enum";
|
||||
import { useTopArtists } from "../hooks/use-api";
|
||||
import { useAuthProtection } from "../hooks/use-auth-protection";
|
||||
import { getMaxCount } from "../util/getMaxCount";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { TopListItem } from "./TopListItem";
|
||||
|
||||
export const ReportTopArtists: React.FC = () => {
|
||||
const { requireUser } = useAuthProtection();
|
||||
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_90_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
time: timeOptions,
|
||||
}),
|
||||
[timeOptions]
|
||||
);
|
||||
|
||||
const { topArtists, isLoading } = useTopArtists(options);
|
||||
|
||||
const reportHasItems = topArtists.length !== 0;
|
||||
const maxCount = getMaxCount(topArtists);
|
||||
|
||||
requireUser();
|
||||
|
||||
return (
|
||||
<div className="md:flex md:justify-center p-4">
|
||||
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Top Artists
|
||||
</p>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems &&
|
||||
topArtists.map(({ artist, count }) => (
|
||||
<TopListItem
|
||||
key={artist.id}
|
||||
title={artist.name}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { Artist } from "../api/entities/artist";
|
||||
import { Genre } from "../api/entities/genre";
|
||||
import { TimeOptions } from "../api/entities/time-options";
|
||||
import { TimePreset } from "../api/entities/time-preset.enum";
|
||||
import { TopArtistsItem } from "../api/entities/top-artists-item";
|
||||
import { useTopGenres } from "../hooks/use-api";
|
||||
import { useAuthProtection } from "../hooks/use-auth-protection";
|
||||
import { capitalizeString } from "../util/capitalizeString";
|
||||
import { getMaxCount } from "../util/getMaxCount";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { TopListItem } from "./TopListItem";
|
||||
|
||||
export const ReportTopGenres: React.FC = () => {
|
||||
const { requireUser } = useAuthProtection();
|
||||
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_90_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
time: timeOptions,
|
||||
}),
|
||||
[timeOptions]
|
||||
);
|
||||
|
||||
const { topGenres, isLoading } = useTopGenres(options);
|
||||
|
||||
const reportHasItems = topGenres.length !== 0;
|
||||
|
||||
requireUser();
|
||||
|
||||
const maxCount = getMaxCount(topGenres);
|
||||
|
||||
return (
|
||||
<div className="md:flex md:justify-center p-4">
|
||||
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Top Genres
|
||||
</p>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems &&
|
||||
topGenres.map(({ genre, artists, count }) => (
|
||||
<ReportItem
|
||||
key={genre.id}
|
||||
genre={genre}
|
||||
count={count}
|
||||
artists={artists}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ReportItem: React.FC<{
|
||||
genre: Genre;
|
||||
artists: TopArtistsItem[];
|
||||
count: number;
|
||||
maxCount: number;
|
||||
}> = ({ genre, artists, count, maxCount }) => {
|
||||
const artistList = artists
|
||||
.map(({ artist, count: artistCount }) => (
|
||||
<ArtistItem key={artist.id} artist={artist} count={artistCount} />
|
||||
))
|
||||
// @ts-expect-error
|
||||
.reduce((acc, curr) => (acc === null ? [curr] : [acc, ", ", curr]), null);
|
||||
|
||||
return (
|
||||
<TopListItem
|
||||
title={capitalizeString(genre.name)}
|
||||
subTitle={artistList}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ArtistItem: React.FC<{
|
||||
artist: Artist;
|
||||
count: number;
|
||||
}> = ({ artist, count }) => (
|
||||
<span title={`Listens: ${count}`}>{artist.name}</span>
|
||||
);
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { TimeOptions } from "../api/entities/time-options";
|
||||
import { TimePreset } from "../api/entities/time-preset.enum";
|
||||
import { Track } from "../api/entities/track";
|
||||
import { useTopTracks } from "../hooks/use-api";
|
||||
import { useAuthProtection } from "../hooks/use-auth-protection";
|
||||
import { getMaxCount } from "../util/getMaxCount";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { TopListItem } from "./TopListItem";
|
||||
|
||||
export const ReportTopTracks: React.FC = () => {
|
||||
const { requireUser } = useAuthProtection();
|
||||
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_90_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
time: timeOptions,
|
||||
}),
|
||||
[timeOptions]
|
||||
);
|
||||
|
||||
const { topTracks, isLoading } = useTopTracks(options);
|
||||
|
||||
const reportHasItems = topTracks.length !== 0;
|
||||
|
||||
requireUser();
|
||||
|
||||
const maxCount = getMaxCount(topTracks);
|
||||
|
||||
return (
|
||||
<div className="md:flex md:justify-center p-4">
|
||||
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Top Tracks
|
||||
</p>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems &&
|
||||
topTracks.map(({ track, count }) => (
|
||||
<ReportItem
|
||||
key={track.id}
|
||||
track={track}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ReportItem: React.FC<{
|
||||
track: Track;
|
||||
count: number;
|
||||
maxCount: number;
|
||||
}> = ({ track, count, maxCount }) => {
|
||||
const artists = track.artists?.map((artist) => artist.name).join(", ") || "";
|
||||
|
||||
return (
|
||||
<TopListItem
|
||||
title={track.name}
|
||||
subTitle={artists}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
73
frontend/src/components/ThemeProvider.tsx
Normal file
73
frontend/src/components/ThemeProvider.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light" | "system";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context;
|
||||
};
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Listen } from "../api/entities/listen";
|
||||
import { useRecentListens } from "../hooks/use-api";
|
||||
import { useAuthProtection } from "../hooks/use-auth-protection";
|
||||
import { ReloadIcon } from "../icons/Reload";
|
||||
import { getPaginationItems } from "../util/getPaginationItems";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { Listen } from "../../api/entities/listen";
|
||||
import { useRecentListens } from "../../hooks/use-api";
|
||||
import { ReloadIcon } from "../../icons/Reload";
|
||||
import { getPaginationItems } from "../../util/getPaginationItems";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
import { Table, TableBody, TableCell, TableRow } from "../ui/table";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const LISTENS_PER_PAGE = 15;
|
||||
|
||||
export const RecentListens: React.FC = () => {
|
||||
const { requireUser } = useAuthProtection();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
|
|
@ -26,44 +25,43 @@ export const RecentListens: React.FC = () => {
|
|||
}
|
||||
}, [totalPages, paginationMeta]);
|
||||
|
||||
requireUser();
|
||||
|
||||
return (
|
||||
<div className="md:flex md:justify-center p-4">
|
||||
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Recent listens
|
||||
</p>
|
||||
<button
|
||||
className="shrink-0 mx-2 bg-transparent hover:bg-green-500 text-green-500 hover:text-white font-semibold py-2 px-4 border border-green-500 hover:border-transparent rounded"
|
||||
onClick={reload}
|
||||
>
|
||||
<ReloadIcon className="w-5 h-5 fill-current" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-2 m-2">
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{recentListens.length === 0 && (
|
||||
<div className="text-center m-4">
|
||||
<p className="text-gray-700 dark:text-gray-400">
|
||||
Could not find any listens!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{recentListens.length > 0 && (
|
||||
<div className="table-auto w-full">
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Recent listens
|
||||
</h2>
|
||||
<Button
|
||||
className="shrink-0 mx-2 bg-transparent hover:bg-green-500 text-green-500 hover:text-white font-semibold py-2 px-4 border border-green-500 hover:border-transparent rounded"
|
||||
onClick={reload}
|
||||
variant="outline"
|
||||
>
|
||||
<ReloadIcon className="w-5 h-5 fill-current" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-2 m-2">
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{recentListens.length === 0 && (
|
||||
<div className="text-center m-4">
|
||||
<p className="text-gray-700 dark:text-gray-400">
|
||||
Could not find any listens!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{recentListens.length > 0 && (
|
||||
<Table className="table-auto w-full">
|
||||
<TableBody>
|
||||
{recentListens.map((listen) => (
|
||||
<ListenItem listen={listen} key={listen.id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} setPage={setPage} />
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} setPage={setPage} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -111,7 +109,7 @@ const Pagination: React.FC<{
|
|||
>
|
||||
...
|
||||
</div>
|
||||
)
|
||||
),
|
||||
)}
|
||||
<button
|
||||
className={`${
|
||||
|
|
@ -134,15 +132,19 @@ const ListenItem: React.FC<{ listen: Listen }> = ({ listen }) => {
|
|||
});
|
||||
const dateTime = format(new Date(listen.playedAt), "PP p");
|
||||
return (
|
||||
<div className="hover:bg-gray-100 dark:hover:bg-gray-700 border-b border-gray-200 dark:border-gray-700/25 md:flex md:justify-around text-gray-700 dark:text-gray-300 px-2 py-2">
|
||||
<div className="md:w-1/2 font-bold">{trackName}</div>
|
||||
<div className=" md:w-1/3">{artists}</div>
|
||||
<div
|
||||
className="md:w-1/6 text-gray-500 font-extra-light text-sm"
|
||||
<TableRow className="sm:flex sm:justify-around sm:hover:bg-gray-100 sm:dark:hover:bg-gray-700 border-b border-gray-200 dark:border-gray-700/25 text-gray-700 dark:text-gray-300 px-2 py-2">
|
||||
<TableCell className="block py-1 sm:p-1 sm:table-cell sm:w-1/2 font-bold text-l">
|
||||
{trackName}
|
||||
</TableCell>
|
||||
<TableCell className="block py-1 sm:p-1 sm:table-cell sm:w-1/3 text-l">
|
||||
{artists}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="block py-1 sm:p-1 sm:table-cell sm:w-1/6 font-extra-light text-sm"
|
||||
title={dateTime}
|
||||
>
|
||||
{timeAgo}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
|
@ -10,20 +10,25 @@ import {
|
|||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { ListenReportItem } from "../api/entities/listen-report-item";
|
||||
import { ListenReportOptions } from "../api/entities/listen-report-options";
|
||||
import { TimeOptions } from "../api/entities/time-options";
|
||||
import { TimePreset } from "../api/entities/time-preset.enum";
|
||||
import { useListensReport } from "../hooks/use-api";
|
||||
import { useAuthProtection } from "../hooks/use-auth-protection";
|
||||
import { ListenReportItem } from "../../api/entities/listen-report-item";
|
||||
import { ListenReportOptions } from "../../api/entities/listen-report-options";
|
||||
import { TimeOptions } from "../../api/entities/time-options";
|
||||
import { TimePreset } from "../../api/entities/time-preset.enum";
|
||||
import { useListensReport } from "../../hooks/use-api";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
import { Label } from "../ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
|
||||
export const ReportListens: React.FC = () => {
|
||||
const { requireUser } = useAuthProtection();
|
||||
|
||||
const [timeFrame, setTimeFrame] = useState<"day" | "week" | "month" | "year">(
|
||||
"day"
|
||||
"day",
|
||||
);
|
||||
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
|
|
@ -34,60 +39,60 @@ export const ReportListens: React.FC = () => {
|
|||
|
||||
const reportOptions = useMemo(
|
||||
() => ({ timeFrame, time: timeOptions }),
|
||||
[timeFrame, timeOptions]
|
||||
[timeFrame, timeOptions],
|
||||
);
|
||||
|
||||
const { report, isLoading } = useListensReport(reportOptions);
|
||||
|
||||
const reportHasItems = report.length !== 0;
|
||||
|
||||
requireUser();
|
||||
|
||||
return (
|
||||
<div className="md:flex md:justify-center p-4">
|
||||
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Listen Report
|
||||
</p>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<div className="md:flex">
|
||||
<div className="text-gray-700 dark:text-gray-300 mr-2">
|
||||
<label className="text-sm">Timeframe</label>
|
||||
<select
|
||||
className="block appearance-none min-w-full md:win-w-0 md:w-1/4 bg-white dark:bg-gray-700 border border-gray-400 hover:border-gray-500 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:text-gray-200 p-2 rounded shadow leading-tight focus:outline-none focus:ring"
|
||||
onChange={(e) =>
|
||||
setTimeFrame(
|
||||
e.target.value as "day" | "week" | "month" | "year"
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="day">Daily</option>
|
||||
<option value="week">Weekly</option>
|
||||
<option value="month">Monthly</option>
|
||||
<option value="year">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
</div>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems && (
|
||||
<div className="w-full text-gray-700 dark:text-gray-300 mt-5">
|
||||
<ReportGraph timeFrame={timeFrame} data={report} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Listen Report
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<div className="sm:flex">
|
||||
<div className="text-gray-700 dark:text-gray-300 mr-2">
|
||||
<Label className="text-sm" htmlFor={"timeframe"}>
|
||||
Timeframe
|
||||
</Label>
|
||||
<Select
|
||||
onValueChange={(e: "day" | "week" | "month" | "year") =>
|
||||
setTimeFrame(e)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Choose aggregation" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="day">Daily</SelectItem>
|
||||
<SelectItem value="week">Weekly</SelectItem>
|
||||
<SelectItem value="month">Monthly</SelectItem>
|
||||
<SelectItem value="year">Yearly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
</div>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is empty! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems && (
|
||||
<div className="w-full text-gray-700 dark:text-gray-300 mt-5">
|
||||
<ReportGraph timeFrame={timeFrame} data={report} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -128,7 +133,7 @@ const ReportGraph: React.FC<{
|
|||
<AreaChart
|
||||
data={dataLocal}
|
||||
margin={{
|
||||
left: -20,
|
||||
left: -5,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
|
|
@ -163,7 +168,7 @@ const ReportGraph: React.FC<{
|
|||
};
|
||||
|
||||
const shortDateFormatFromTimeFrame = (
|
||||
timeFrame: "day" | "week" | "month" | "year"
|
||||
timeFrame: "day" | "week" | "month" | "year",
|
||||
): string => {
|
||||
const FORMAT_DAY = "P";
|
||||
const FORMAT_WEEK = "'Week' w yyyy";
|
||||
|
|
@ -186,7 +191,7 @@ const shortDateFormatFromTimeFrame = (
|
|||
};
|
||||
|
||||
const dateFormatFromTimeFrame = (
|
||||
timeFrame: "day" | "week" | "month" | "year"
|
||||
timeFrame: "day" | "week" | "month" | "year",
|
||||
): string => {
|
||||
const FORMAT_DAY = "PPPP";
|
||||
const FORMAT_WEEK = "'Week starting on' PPPP";
|
||||
|
|
@ -1,7 +1,15 @@
|
|||
import React from "react";
|
||||
import { TimeOptions } from "../api/entities/time-options";
|
||||
import { TimePreset } from "../api/entities/time-preset.enum";
|
||||
import { DateSelect } from "./inputs/DateSelect";
|
||||
import { TimeOptions } from "../../api/entities/time-options";
|
||||
import { TimePreset } from "../../api/entities/time-preset.enum";
|
||||
import { DateSelect } from "../inputs/DateSelect";
|
||||
import { Label } from "../ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
|
||||
interface ReportTimeOptionsProps {
|
||||
timeOptions: TimeOptions;
|
||||
|
|
@ -23,28 +31,34 @@ export const ReportTimeOptions: React.FC<ReportTimeOptionsProps> = ({
|
|||
setTimeOptions,
|
||||
}) => {
|
||||
return (
|
||||
<div className="md:flex mb-4">
|
||||
<div className="sm:flex mb-4">
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
<label className="text-sm">Timeframe</label>
|
||||
<select
|
||||
className="block appearance-none min-w-full md:w-1/4 bg-white dark:bg-gray-700 border border-gray-400 hover:border-gray-500 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:text-gray-200 p-2 rounded shadow leading-tight focus:outline-none focus:ring"
|
||||
onChange={(e) =>
|
||||
<Label className="text-sm" htmlFor={"period"}>
|
||||
Period
|
||||
</Label>
|
||||
<Select
|
||||
onValueChange={(e: TimePreset) =>
|
||||
setTimeOptions({
|
||||
...timeOptions,
|
||||
timePreset: e.target.value as TimePreset,
|
||||
timePreset: e,
|
||||
})
|
||||
}
|
||||
value={timeOptions.timePreset}
|
||||
>
|
||||
{timePresetOptions.map(({ value, description }) => (
|
||||
<option value={value} key={value}>
|
||||
{description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Time period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{timePresetOptions.map(({ value, description }) => (
|
||||
<SelectItem value={value} key={value}>
|
||||
{description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{timeOptions.timePreset === TimePreset.CUSTOM && (
|
||||
<div className="md:flex text-gray-700 dark:text-gray-200">
|
||||
<div className="sm:flex text-gray-700 dark:text-gray-200">
|
||||
<div className="pl-2">
|
||||
<DateSelect
|
||||
label="Start"
|
||||
78
frontend/src/components/reports/ReportTopAlbums.tsx
Normal file
78
frontend/src/components/reports/ReportTopAlbums.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { Album } from "../../api/entities/album";
|
||||
import { TimeOptions } from "../../api/entities/time-options";
|
||||
import { TimePreset } from "../../api/entities/time-preset.enum";
|
||||
import { useTopAlbums } from "../../hooks/use-api";
|
||||
import { getMaxCount } from "../../util/getMaxCount";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
import { TopListItem } from "./TopListItem";
|
||||
|
||||
export const ReportTopAlbums: React.FC = () => {
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_90_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
time: timeOptions,
|
||||
}),
|
||||
[timeOptions],
|
||||
);
|
||||
|
||||
const { topAlbums, isLoading } = useTopAlbums(options);
|
||||
|
||||
const reportHasItems = topAlbums.length !== 0;
|
||||
const maxCount = getMaxCount(topAlbums);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Top Albums
|
||||
</h2>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems &&
|
||||
topAlbums.map(({ album, count }) => (
|
||||
<ReportItem
|
||||
key={album.id}
|
||||
album={album}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ReportItem: React.FC<{
|
||||
album: Album;
|
||||
count: number;
|
||||
maxCount: number;
|
||||
}> = ({ album, count, maxCount }) => {
|
||||
const artists = album.artists?.map((artist) => artist.name).join(", ") || "";
|
||||
|
||||
return (
|
||||
<TopListItem
|
||||
key={album.id}
|
||||
title={album.name}
|
||||
subTitle={artists}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
60
frontend/src/components/reports/ReportTopArtists.tsx
Normal file
60
frontend/src/components/reports/ReportTopArtists.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { TimeOptions } from "../../api/entities/time-options";
|
||||
import { TimePreset } from "../../api/entities/time-preset.enum";
|
||||
import { useTopArtists } from "../../hooks/use-api";
|
||||
import { getMaxCount } from "../../util/getMaxCount";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
import { TopListItem } from "./TopListItem";
|
||||
|
||||
export const ReportTopArtists: React.FC = () => {
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_90_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
time: timeOptions,
|
||||
}),
|
||||
[timeOptions],
|
||||
);
|
||||
|
||||
const { topArtists, isLoading } = useTopArtists(options);
|
||||
|
||||
const reportHasItems = topArtists.length !== 0;
|
||||
const maxCount = getMaxCount(topArtists);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Top Artists
|
||||
</h2>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems &&
|
||||
topArtists.map(({ artist, count }) => (
|
||||
<TopListItem
|
||||
key={artist.id}
|
||||
title={artist.name}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
95
frontend/src/components/reports/ReportTopGenres.tsx
Normal file
95
frontend/src/components/reports/ReportTopGenres.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { Artist } from "../../api/entities/artist";
|
||||
import { Genre } from "../../api/entities/genre";
|
||||
import { TimeOptions } from "../../api/entities/time-options";
|
||||
import { TimePreset } from "../../api/entities/time-preset.enum";
|
||||
import { TopArtistsItem } from "../../api/entities/top-artists-item";
|
||||
import { useTopGenres } from "../../hooks/use-api";
|
||||
import { capitalizeString } from "../../util/capitalizeString";
|
||||
import { getMaxCount } from "../../util/getMaxCount";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
import { TopListItem } from "./TopListItem";
|
||||
|
||||
export const ReportTopGenres: React.FC = () => {
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_90_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
time: timeOptions,
|
||||
}),
|
||||
[timeOptions],
|
||||
);
|
||||
|
||||
const { topGenres, isLoading } = useTopGenres(options);
|
||||
|
||||
const reportHasItems = topGenres.length !== 0;
|
||||
|
||||
const maxCount = getMaxCount(topGenres);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Top Genres
|
||||
</h2>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems &&
|
||||
topGenres.map(({ genre, artists, count }) => (
|
||||
<ReportItem
|
||||
key={genre.id}
|
||||
genre={genre}
|
||||
count={count}
|
||||
artists={artists}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ReportItem: React.FC<{
|
||||
genre: Genre;
|
||||
artists: TopArtistsItem[];
|
||||
count: number;
|
||||
maxCount: number;
|
||||
}> = ({ genre, artists, count, maxCount }) => {
|
||||
const artistList = artists
|
||||
.map(({ artist, count: artistCount }) => (
|
||||
<ArtistItem key={artist.id} artist={artist} count={artistCount} />
|
||||
))
|
||||
// @ts-expect-error
|
||||
.reduce((acc, curr) => (acc === null ? [curr] : [acc, ", ", curr]), null);
|
||||
|
||||
return (
|
||||
<TopListItem
|
||||
title={capitalizeString(genre.name)}
|
||||
subTitle={artistList}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ArtistItem: React.FC<{
|
||||
artist: Artist;
|
||||
count: number;
|
||||
}> = ({ artist, count }) => (
|
||||
<span title={`Listens: ${count}`}>{artist.name}</span>
|
||||
);
|
||||
78
frontend/src/components/reports/ReportTopTracks.tsx
Normal file
78
frontend/src/components/reports/ReportTopTracks.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { TimeOptions } from "../../api/entities/time-options";
|
||||
import { TimePreset } from "../../api/entities/time-preset.enum";
|
||||
import { Track } from "../../api/entities/track";
|
||||
import { useTopTracks } from "../../hooks/use-api";
|
||||
import { getMaxCount } from "../../util/getMaxCount";
|
||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
import { TopListItem } from "./TopListItem";
|
||||
|
||||
export const ReportTopTracks: React.FC = () => {
|
||||
const [timeOptions, setTimeOptions] = useState<TimeOptions>({
|
||||
timePreset: TimePreset.LAST_90_DAYS,
|
||||
customTimeStart: new Date("2020"),
|
||||
customTimeEnd: new Date(),
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
time: timeOptions,
|
||||
}),
|
||||
[timeOptions],
|
||||
);
|
||||
|
||||
const { topTracks, isLoading } = useTopTracks(options);
|
||||
|
||||
const reportHasItems = topTracks.length !== 0;
|
||||
|
||||
const maxCount = getMaxCount(topTracks);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Top Tracks
|
||||
</h2>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<ReportTimeOptions
|
||||
timeOptions={timeOptions}
|
||||
setTimeOptions={setTimeOptions}
|
||||
/>
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
{!reportHasItems && !isLoading && (
|
||||
<div>
|
||||
<p>Report is emtpy! :(</p>
|
||||
</div>
|
||||
)}
|
||||
{reportHasItems &&
|
||||
topTracks.map(({ track, count }) => (
|
||||
<ReportItem
|
||||
key={track.id}
|
||||
track={track}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ReportItem: React.FC<{
|
||||
track: Track;
|
||||
count: number;
|
||||
maxCount: number;
|
||||
}> = ({ track, count, maxCount }) => {
|
||||
const artists = track.artists?.map((artist) => artist.name).join(", ") || "";
|
||||
|
||||
return (
|
||||
<TopListItem
|
||||
title={track.name}
|
||||
subTitle={artists}
|
||||
count={count}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { numberToPercent } from "../../util/numberToPercent";
|
||||
|
||||
export interface TopListItemProps {
|
||||
title: string;
|
||||
|
|
@ -42,9 +43,3 @@ export const TopListItem: React.FC<TopListItemProps> = ({
|
|||
|
||||
const isMaxCountValid = (maxCount: number) =>
|
||||
!(Number.isNaN(maxCount) || maxCount === 0);
|
||||
|
||||
const numberToPercent = (ratio: number) =>
|
||||
ratio.toLocaleString(undefined, {
|
||||
style: "percent",
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { SpinnerIcon } from "../icons/Spinner";
|
||||
import { SpinnerIcon } from "../../icons/Spinner";
|
||||
|
||||
interface SpinnerProps {
|
||||
className?: string;
|
||||
48
frontend/src/components/ui/avatar.tsx
Normal file
48
frontend/src/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "src/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
36
frontend/src/components/ui/badge.tsx
Normal file
36
frontend/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border border-gray-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 dark:border-gray-800 dark:focus:ring-gray-300",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-gray-900 text-gray-50 hover:bg-gray-900/80 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-50/80",
|
||||
secondary:
|
||||
"border-transparent bg-gray-100 text-gray-900 hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-50 dark:hover:bg-gray-800/80",
|
||||
destructive:
|
||||
"border-transparent bg-red-500 text-gray-50 hover:bg-red-500/80 dark:bg-red-900 dark:text-gray-50 dark:hover:bg-red-900/80",
|
||||
outline: "text-gray-900 dark:text-gray-50",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
58
frontend/src/components/ui/button.tsx
Normal file
58
frontend/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-gray-900 dark:focus-visible:ring-gray-300",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-gray-900 text-gray-50 hover:bg-gray-900/90 dark:bg-gray-50 dark:text-gray-900 dark:hover:bg-gray-50/90",
|
||||
destructive:
|
||||
"bg-red-500 text-gray-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-gray-50 dark:hover:bg-red-900/90",
|
||||
outline:
|
||||
"border border-gray-200 bg-white hover:bg-gray-100 hover:text-gray-900 dark:border-gray-800 dark:bg-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-50",
|
||||
secondary:
|
||||
"bg-gray-100 text-gray-900 hover:bg-gray-100/80 dark:bg-gray-800 dark:text-gray-50 dark:hover:bg-gray-800/80",
|
||||
ghost:
|
||||
"hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-50",
|
||||
link: "text-gray-900 underline-offset-4 hover:underline dark:text-gray-50",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
86
frontend/src/components/ui/card.tsx
Normal file
86
frontend/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border border-gray-200 bg-white text-gray-900 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-gray-500 dark:text-gray-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
9
frontend/src/components/ui/code.tsx
Normal file
9
frontend/src/components/ui/code.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
export const Code: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<code className="tracking-wide font-mono bg-gray-200 dark:bg-gray-600 rounded-md px-1">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
};
|
||||
198
frontend/src/components/ui/dropdown-menu.tsx
Normal file
198
frontend/src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-gray-100 data-[state=open]:bg-gray-100 dark:focus:bg-gray-800 dark:data-[state=open]:bg-gray-800",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white p-1 text-gray-900 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white p-1 text-gray-900 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800 dark:focus:text-gray-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800 dark:focus:text-gray-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800 dark:focus:text-gray-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-gray-100 dark:bg-gray-800", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
24
frontend/src/components/ui/label.tsx
Normal file
24
frontend/src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "src/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
129
frontend/src/components/ui/navigation-menu.tsx
Normal file
129
frontend/src/components/ui/navigation-menu.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import * as React from "react";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
const NavigationMenu = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
));
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
|
||||
|
||||
const NavigationMenuList = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-1 flex-col sm:flex-row list-none sm:items-center justify-center space-y-2 sm:space-x-1 sm:space-y-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-white px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-100 hover:text-gray-900 focus:bg-gray-100 focus:text-gray-900 focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-gray-100/50 data-[state=open]:bg-gray-100/50 dark:bg-gray-900 dark:hover:bg-gray-800 dark:hover:text-gray-50 dark:focus:bg-gray-800 dark:focus:text-gray-50 dark:data-[active]:bg-gray-800/50 dark:data-[state=open]:bg-gray-800/50",
|
||||
);
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{""}
|
||||
<ChevronDown
|
||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
));
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
|
||||
|
||||
const NavigationMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link;
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border border-gray-200 bg-white text-gray-900 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
NavigationMenuViewport.displayName =
|
||||
NavigationMenuPrimitive.Viewport.displayName;
|
||||
|
||||
const NavigationMenuIndicator = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-gray-200 shadow-md dark:bg-gray-800" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
));
|
||||
NavigationMenuIndicator.displayName =
|
||||
NavigationMenuPrimitive.Indicator.displayName;
|
||||
|
||||
export {
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
};
|
||||
119
frontend/src/components/ui/select.tsx
Normal file
119
frontend/src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-gray-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-800 dark:bg-gray-900 dark:ring-offset-gray-900 dark:placeholder:text-gray-400 dark:focus:ring-gray-300",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white text-gray-900 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-50",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800 dark:focus:text-gray-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-gray-100 dark:bg-gray-800", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
};
|
||||
117
frontend/src/components/ui/table.tsx
Normal file
117
frontend/src/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-gray-900 font-medium text-gray-50 dark:bg-gray-50 dark:text-gray-900",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-gray-100/50 data-[state=selected]:bg-gray-100 dark:hover:bg-gray-800/50 dark:data-[state=selected]:bg-gray-800",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-gray-500 [&:has([role=checkbox])]:pr-0 dark:text-gray-400",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-gray-500 dark:text-gray-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
import axios, {
|
||||
AxiosInstance,
|
||||
InternalAxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
} from "axios";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
|
|
@ -13,7 +17,7 @@ interface ApiClientContext {
|
|||
}
|
||||
|
||||
const apiClientContext = createContext<ApiClientContext>(
|
||||
undefined as any as ApiClientContext
|
||||
undefined as any as ApiClientContext,
|
||||
);
|
||||
|
||||
export const ProvideApiClient: React.FC<{ children: React.ReactNode }> = ({
|
||||
|
|
@ -35,13 +39,13 @@ export function useApiClient() {
|
|||
function useProvideApiClient(): ApiClientContext {
|
||||
const { accessToken, refreshAccessToken } = useAuth();
|
||||
|
||||
// Wrap value to immediatly update when refreshing access token
|
||||
// Wrap value to immediately update when refreshing access token
|
||||
// and always having access to newest access token in interceptor
|
||||
const localAccessToken = useRef(accessToken);
|
||||
|
||||
// initialState must be passed as function as return value of axios.create()
|
||||
// is also callable and react will call it and then use that result (promise)
|
||||
// as the initial state instead of the axios instace.
|
||||
// as the initial state instead of the axios instance.
|
||||
const [client] = useState<AxiosInstance>(() => axios.create());
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -54,15 +58,11 @@ function useProvideApiClient(): ApiClientContext {
|
|||
// Setup Axios Interceptors
|
||||
const requestInterceptor = client.interceptors.request.use(
|
||||
(config) => {
|
||||
if (!config.headers) {
|
||||
config.headers = {};
|
||||
}
|
||||
|
||||
config.headers.Authorization = `Bearer ${localAccessToken.current}`;
|
||||
|
||||
return config;
|
||||
},
|
||||
(err) => Promise.reject(err)
|
||||
(err) => Promise.reject(err),
|
||||
);
|
||||
const responseInterceptor = client.interceptors.response.use(
|
||||
(data) => data,
|
||||
|
|
@ -73,7 +73,7 @@ function useProvideApiClient(): ApiClientContext {
|
|||
|
||||
const { response, config } = err as {
|
||||
response: AxiosResponse;
|
||||
config: AxiosRequestConfig;
|
||||
config: InternalAxiosRequestConfig;
|
||||
};
|
||||
|
||||
if (response && response.status !== 401) {
|
||||
|
|
@ -84,7 +84,7 @@ function useProvideApiClient(): ApiClientContext {
|
|||
localAccessToken.current = await refreshAccessToken();
|
||||
|
||||
return client.request(config);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import { useCallback, useMemo } from "react";
|
|||
import {
|
||||
createApiToken,
|
||||
getApiTokens,
|
||||
getExtendedStreamingHistoryStatus,
|
||||
getListensReport,
|
||||
getRecentListens,
|
||||
getTopAlbums,
|
||||
getTopArtists,
|
||||
getTopGenres,
|
||||
getTopTracks,
|
||||
importExtendedStreamingHistory,
|
||||
revokeApiToken,
|
||||
} from "../api/api";
|
||||
import { ListenReportOptions } from "../api/entities/listen-report-options";
|
||||
|
|
@ -18,6 +20,7 @@ import { TopGenresOptions } from "../api/entities/top-genres-options";
|
|||
import { TopTracksOptions } from "../api/entities/top-tracks-options";
|
||||
import { useApiClient } from "./use-api-client";
|
||||
import { useAsync } from "./use-async";
|
||||
import { SpotifyExtendedStreamingHistoryItem } from "../api/entities/spotify-extended-streaming-history-item";
|
||||
|
||||
const INITIAL_EMPTY_ARRAY: [] = [];
|
||||
Object.freeze(INITIAL_EMPTY_ARRAY);
|
||||
|
|
@ -27,7 +30,7 @@ export const useRecentListens = (options: PaginationOptions) => {
|
|||
|
||||
const fetchData = useMemo(
|
||||
() => () => getRecentListens(options, client),
|
||||
[options, client]
|
||||
[options, client],
|
||||
);
|
||||
|
||||
const {
|
||||
|
|
@ -48,7 +51,7 @@ export const useListensReport = (options: ListenReportOptions) => {
|
|||
|
||||
const fetchData = useMemo(
|
||||
() => () => getListensReport(options, client),
|
||||
[options, client]
|
||||
[options, client],
|
||||
);
|
||||
|
||||
const {
|
||||
|
|
@ -65,7 +68,7 @@ export const useTopArtists = (options: TopArtistsOptions) => {
|
|||
|
||||
const fetchData = useMemo(
|
||||
() => () => getTopArtists(options, client),
|
||||
[options, client]
|
||||
[options, client],
|
||||
);
|
||||
|
||||
const {
|
||||
|
|
@ -82,7 +85,7 @@ export const useTopAlbums = (options: TopAlbumsOptions) => {
|
|||
|
||||
const fetchData = useMemo(
|
||||
() => () => getTopAlbums(options, client),
|
||||
[options, client]
|
||||
[options, client],
|
||||
);
|
||||
|
||||
const {
|
||||
|
|
@ -99,7 +102,7 @@ export const useTopTracks = (options: TopTracksOptions) => {
|
|||
|
||||
const fetchData = useMemo(
|
||||
() => () => getTopTracks(options, client),
|
||||
[options, client]
|
||||
[options, client],
|
||||
);
|
||||
|
||||
const {
|
||||
|
|
@ -116,7 +119,7 @@ export const useTopGenres = (options: TopGenresOptions) => {
|
|||
|
||||
const fetchData = useMemo(
|
||||
() => () => getTopGenres(options, client),
|
||||
[options, client]
|
||||
[options, client],
|
||||
);
|
||||
|
||||
const {
|
||||
|
|
@ -143,13 +146,11 @@ export const useApiTokens = () => {
|
|||
const createToken = useCallback(
|
||||
async (description: string) => {
|
||||
const apiToken = await createApiToken(description, client);
|
||||
console.log("apiToken created", apiToken);
|
||||
await reload();
|
||||
console.log("reloaded data");
|
||||
|
||||
return apiToken;
|
||||
},
|
||||
[client, reload]
|
||||
[client, reload],
|
||||
);
|
||||
|
||||
const revokeToken = useCallback(
|
||||
|
|
@ -157,8 +158,43 @@ export const useApiTokens = () => {
|
|||
await revokeApiToken(id, client);
|
||||
await reload();
|
||||
},
|
||||
[client, reload]
|
||||
[client, reload],
|
||||
);
|
||||
|
||||
return { apiTokens, isLoading, error, createToken, revokeToken };
|
||||
};
|
||||
|
||||
export const useSpotifyImportExtendedStreamingHistory = () => {
|
||||
const { client } = useApiClient();
|
||||
|
||||
const importHistory = useCallback(
|
||||
async (listens: SpotifyExtendedStreamingHistoryItem[]) => {
|
||||
return importExtendedStreamingHistory(listens, client);
|
||||
},
|
||||
[client]
|
||||
);
|
||||
|
||||
const getStatus = useCallback(async () => {
|
||||
return getExtendedStreamingHistoryStatus(client);
|
||||
}, [client]);
|
||||
|
||||
return { importHistory, getStatus };
|
||||
};
|
||||
|
||||
export const useSpotifyImportExtendedStreamingHistoryStatus = () => {
|
||||
const { client } = useApiClient();
|
||||
|
||||
const fetchData = useMemo(
|
||||
() => () => getExtendedStreamingHistoryStatus(client),
|
||||
[client]
|
||||
);
|
||||
|
||||
const {
|
||||
value: importStatus,
|
||||
pending: isLoading,
|
||||
error,
|
||||
reload,
|
||||
} = useAsync(fetchData, { total: 0, imported: 0 });
|
||||
|
||||
return { importStatus, isLoading, error, reload };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useCallback, useEffect, useState, useTransition } from "react";
|
|||
|
||||
type UseAsync = <T>(
|
||||
asyncFunction: () => Promise<T>,
|
||||
initialValue: T
|
||||
initialValue: T,
|
||||
) => {
|
||||
pending: boolean;
|
||||
value: T;
|
||||
|
|
@ -12,7 +12,7 @@ type UseAsync = <T>(
|
|||
|
||||
export const useAsync: UseAsync = <T extends any>(
|
||||
asyncFunction: () => Promise<T>,
|
||||
initialValue: T
|
||||
initialValue: T,
|
||||
) => {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [value, setValue] = useState<T>(initialValue);
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
import { useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "./use-auth";
|
||||
|
||||
export function useAuthProtection() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const requireUser = useCallback(async () => {
|
||||
if (!user) {
|
||||
navigate("/");
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
return { requireUser };
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import React, { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Hook that alerts clicks outside of the passed ref
|
||||
*/
|
||||
export const useOutsideClick = (
|
||||
ref: React.MutableRefObject<any>,
|
||||
callback: () => void
|
||||
) => {
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Alert if clicked on outside of element
|
||||
*/
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target)) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
// Bind the event listener
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
// Unbind the event listener on clean up
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [ref, callback]);
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import React from "react";
|
||||
|
||||
export const CogwheelIcon: React.FC<React.SVGProps<SVGSVGElement>> = (
|
||||
props
|
||||
props,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
|
|
|
|||
14
frontend/src/icons/Error.tsx
Normal file
14
frontend/src/icons/Error.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import React from "react";
|
||||
|
||||
export const ErrorIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 50 50"
|
||||
fill="#D75A4A"
|
||||
>
|
||||
<circle fill="fill" cx="25" cy="25" r="25" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
12
frontend/src/icons/Import.tsx
Normal file
12
frontend/src/icons/Import.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import * as React from "react";
|
||||
export const ImportIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
viewBox="0 0 60.903 60.903"
|
||||
{...props}
|
||||
>
|
||||
<path d="M49.561 16.464H39.45v6h10.111c3.008 0 5.341 1.535 5.341 2.857v26.607c0 1.321-2.333 2.858-5.341 2.858H11.34c-3.007 0-5.34-1.537-5.34-2.858V25.324c0-1.322 2.333-2.858 5.34-2.858h10.11v-6H11.34C4.981 16.466 0 20.357 0 25.324v26.605c0 4.968 4.981 8.857 11.34 8.857h38.223c6.357 0 11.34-3.891 11.34-8.857V25.324c-.001-4.969-4.982-8.86-11.342-8.86z" />
|
||||
<path d="M39.529 29.004a2.99 2.99 0 0 0-2.121.88l-3.756 3.755V3.117a3 3 0 0 0-6 0v30.724l-3.959-3.958a2.992 2.992 0 0 0-4.242 0 2.997 2.997 0 0 0 0 4.241l8.957 8.957a2.988 2.988 0 0 0 2.12.877h.045c.768 0 1.534-.291 2.12-.877l8.957-8.957a2.997 2.997 0 0 0-2.121-5.12z" />
|
||||
</svg>
|
||||
);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import React from "react";
|
||||
|
||||
export const TrashcanIcon: React.FC<React.SVGProps<SVGSVGElement>> = (
|
||||
props
|
||||
props,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { App } from "./App";
|
|||
import { ProvideApiClient } from "./hooks/use-api-client";
|
||||
import { ProvideAuth } from "./hooks/use-auth";
|
||||
import "./index.css";
|
||||
import { ThemeProvider } from "./components/ThemeProvider";
|
||||
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
|
||||
|
|
@ -13,9 +14,11 @@ root.render(
|
|||
<ProvideAuth>
|
||||
<ProvideApiClient>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</ProvideApiClient>
|
||||
</ProvideAuth>
|
||||
</React.StrictMode>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
|
|
|||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
36
frontend/src/react-files.d.ts
vendored
Normal file
36
frontend/src/react-files.d.ts
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
//// <reference types="react" />
|
||||
|
||||
declare module "react-files" {
|
||||
declare const Files: React.FC<
|
||||
Partial<{
|
||||
accepts: string[];
|
||||
children: React.ReactNode;
|
||||
className: string;
|
||||
clickable: boolean;
|
||||
dragActiveClassName: string;
|
||||
inputProps: unknown;
|
||||
multiple: boolean;
|
||||
maxFiles: number;
|
||||
maxFileSize: number;
|
||||
minFileSize: number;
|
||||
name: string;
|
||||
onChange: (files: ReactFile[]) => void;
|
||||
onDragEnter: () => void;
|
||||
onDragLeave: () => void;
|
||||
onError: (
|
||||
error: { code: number; message: string },
|
||||
file: ReactFile
|
||||
) => void;
|
||||
style: object;
|
||||
}>
|
||||
>;
|
||||
|
||||
export type ReactFile = File & {
|
||||
id: string;
|
||||
extension: string;
|
||||
sizeReadable: string;
|
||||
preview: { type: "image"; url: string } | { type: "file" };
|
||||
};
|
||||
|
||||
export default Files;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
export const getPaginationItems = (
|
||||
currentPage: number,
|
||||
totalPages: number,
|
||||
delta: number = 1
|
||||
delta: number = 1,
|
||||
): (number | null)[] => {
|
||||
const left = currentPage - delta;
|
||||
const right = currentPage + delta;
|
||||
|
|
|
|||
5
frontend/src/util/numberToPercent.ts
Normal file
5
frontend/src/util/numberToPercent.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const numberToPercent = (ratio: number) =>
|
||||
ratio.toLocaleString(undefined, {
|
||||
style: "percent",
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
|
|
@ -5,7 +5,7 @@ export const qs = (parameters: QueryParameters): string => {
|
|||
const queryParams = new URLSearchParams();
|
||||
|
||||
Object.entries(parameters).forEach(([key, value]) =>
|
||||
queryParams.append(key, value)
|
||||
queryParams.append(key, value),
|
||||
);
|
||||
|
||||
return queryParams.toString();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
const colors = require("tailwindcss/colors");
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"],
|
||||
theme: {
|
||||
colors: {
|
||||
|
|
@ -12,6 +14,7 @@ module.exports = {
|
|||
|
||||
// Tailwind v1 Colors
|
||||
gray: {
|
||||
50: "#ffffff",
|
||||
100: "#f7fafc",
|
||||
200: "#edf2f7",
|
||||
300: "#e2e8f0",
|
||||
|
|
@ -21,9 +24,11 @@ module.exports = {
|
|||
700: "#4a5568",
|
||||
800: "#2d3748",
|
||||
900: "#1a202c",
|
||||
950: "#0C0F12",
|
||||
},
|
||||
|
||||
green: {
|
||||
50: "#FFFFFF",
|
||||
100: "#f0fff4",
|
||||
200: "#c6f6d5",
|
||||
300: "#9ae6b4",
|
||||
|
|
@ -33,6 +38,7 @@ module.exports = {
|
|||
700: "#2f855a",
|
||||
800: "#276749",
|
||||
900: "#22543d",
|
||||
950: "#1C4A2F",
|
||||
},
|
||||
|
||||
yellow: colors.yellow,
|
||||
|
|
@ -40,5 +46,29 @@ module.exports = {
|
|||
violet: colors.violet,
|
||||
amber: colors.amber,
|
||||
},
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
|
|
@ -21,5 +17,8 @@
|
|||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
],
|
||||
"paths": {
|
||||
"src/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import path from "path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
|
|
@ -7,6 +8,11 @@ export default defineConfig(() => {
|
|||
outDir: "build",
|
||||
},
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
src: path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
|
|
|
|||
18310
package-lock.json
generated
18310
package-lock.json
generated
File diff suppressed because it is too large
Load diff
155
package.json
155
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@listory/api",
|
||||
"version": "1.26.1",
|
||||
"version": "1.31.0",
|
||||
"description": "Track your Spotify Listen History",
|
||||
"author": {
|
||||
"name": "Julian Tölle",
|
||||
|
|
@ -26,94 +26,93 @@
|
|||
"test:e2e": "jest --config ./apps/listory/test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apricote/nest-pg-boss": "2.0.0",
|
||||
"@narando/nest-axios-interceptor": "2.2.0",
|
||||
"@nestjs/axios": "0.1.0",
|
||||
"@nestjs/common": "9.4.0",
|
||||
"@nestjs/config": "2.3.1",
|
||||
"@nestjs/core": "9.4.0",
|
||||
"@nestjs/jwt": "10.0.3",
|
||||
"@nestjs/passport": "9.0.3",
|
||||
"@nestjs/platform-express": "9.4.0",
|
||||
"@nestjs/serve-static": "3.0.1",
|
||||
"@nestjs/swagger": "6.3.0",
|
||||
"@nestjs/terminus": "9.2.2",
|
||||
"@nestjs/typeorm": "9.0.1",
|
||||
"@opentelemetry/api": "1.4.0",
|
||||
"@apricote/nest-pg-boss": "2.1.0",
|
||||
"@narando/nest-axios-interceptor": "3.0.0",
|
||||
"@nestjs/axios": "3.1.3",
|
||||
"@nestjs/common": "10.4.15",
|
||||
"@nestjs/config": "3.3.0",
|
||||
"@nestjs/core": "10.4.15",
|
||||
"@nestjs/jwt": "10.2.0",
|
||||
"@nestjs/passport": "10.0.3",
|
||||
"@nestjs/platform-express": "10.4.15",
|
||||
"@nestjs/serve-static": "4.0.2",
|
||||
"@nestjs/swagger": "7.4.2",
|
||||
"@nestjs/terminus": "10.2.3",
|
||||
"@nestjs/typeorm": "10.0.2",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/api-metrics": "0.33.0",
|
||||
"@opentelemetry/context-async-hooks": "1.9.0",
|
||||
"@opentelemetry/exporter-prometheus": "0.35.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.35.0",
|
||||
"@opentelemetry/instrumentation": "0.35.0",
|
||||
"@opentelemetry/instrumentation-dns": "0.31.3",
|
||||
"@opentelemetry/instrumentation-express": "0.32.2",
|
||||
"@opentelemetry/instrumentation-http": "0.35.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "0.32.3",
|
||||
"@opentelemetry/instrumentation-pg": "0.35.1",
|
||||
"@opentelemetry/instrumentation-pino": "0.33.2",
|
||||
"@opentelemetry/resources": "1.9.0",
|
||||
"@opentelemetry/sdk-metrics-base": "0.31.0",
|
||||
"@opentelemetry/sdk-node": "0.35.0",
|
||||
"@opentelemetry/sdk-trace-base": "1.9.0",
|
||||
"@opentelemetry/semantic-conventions": "1.9.0",
|
||||
"@sentry/node": "7.51.0",
|
||||
"@opentelemetry/context-async-hooks": "1.29.0",
|
||||
"@opentelemetry/exporter-prometheus": "0.56.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.56.0",
|
||||
"@opentelemetry/instrumentation": "0.56.0",
|
||||
"@opentelemetry/instrumentation-dns": "0.42.0",
|
||||
"@opentelemetry/instrumentation-express": "0.46.0",
|
||||
"@opentelemetry/instrumentation-http": "0.56.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "0.43.0",
|
||||
"@opentelemetry/instrumentation-pg": "0.49.0",
|
||||
"@opentelemetry/instrumentation-pino": "0.45.0",
|
||||
"@opentelemetry/resources": "1.29.0",
|
||||
"@opentelemetry/sdk-metrics": "1.29.0",
|
||||
"@opentelemetry/sdk-node": "0.56.0",
|
||||
"@opentelemetry/sdk-trace-base": "1.29.0",
|
||||
"@opentelemetry/semantic-conventions": "1.28.0",
|
||||
"@sentry/node": "7.120.3",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.14.0",
|
||||
"cookie-parser": "1.4.6",
|
||||
"class-validator": "0.14.1",
|
||||
"cookie-parser": "1.4.7",
|
||||
"date-fns": "2.30.0",
|
||||
"joi": "17.9.2",
|
||||
"lodash": "^4.17.21",
|
||||
"nest-raven": "9.2.0",
|
||||
"nestjs-otel": "5.1.2",
|
||||
"nestjs-pino": "3.2.0",
|
||||
"nestjs-typeorm-paginate": "4.0.3",
|
||||
"passport": "0.6.0",
|
||||
"passport-http-bearer": "^1.0.1",
|
||||
"joi": "17.13.3",
|
||||
"lodash": "4.17.21",
|
||||
"nest-raven": "10.1.0",
|
||||
"nestjs-otel": "5.1.5",
|
||||
"nestjs-pino": "4.1.0",
|
||||
"nestjs-typeorm-paginate": "4.0.4",
|
||||
"passport": "0.7.0",
|
||||
"passport-http-bearer": "1.0.1",
|
||||
"passport-jwt": "4.0.1",
|
||||
"passport-spotify": "2.0.0",
|
||||
"pg": "8.10.0",
|
||||
"pg-boss": "^9.0.0",
|
||||
"pino": "8.12.1",
|
||||
"pino-http": "8.3.3",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"rimraf": "5.0.0",
|
||||
"pg": "8.13.1",
|
||||
"pg-boss": "9.0.3",
|
||||
"pino": "8.21.0",
|
||||
"pino-http": "9.0.0",
|
||||
"reflect-metadata": "0.1.14",
|
||||
"rimraf": "5.0.10",
|
||||
"rxjs": "7.8.1",
|
||||
"typeorm": "0.3.15"
|
||||
"typeorm": "0.3.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "9.4.2",
|
||||
"@nestjs/schematics": "9.1.0",
|
||||
"@nestjs/testing": "9.4.0",
|
||||
"@types/cookie-parser": "1.4.3",
|
||||
"@types/express": "4.17.17",
|
||||
"@types/jest": "29.5.1",
|
||||
"@types/lodash": "^4.14.194",
|
||||
"@types/long": "4.0.2",
|
||||
"@types/node": "18.16.5",
|
||||
"@types/passport-http-bearer": "^1.0.37",
|
||||
"@types/passport-jwt": "3.0.8",
|
||||
"@types/supertest": "2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "5.59.2",
|
||||
"@typescript-eslint/parser": "5.59.2",
|
||||
"eslint": "8.40.0",
|
||||
"@nestjs/cli": "10.4.9",
|
||||
"@nestjs/schematics": "10.2.3",
|
||||
"@nestjs/testing": "10.4.15",
|
||||
"@types/cookie-parser": "1.4.8",
|
||||
"@types/express": "5.0.0",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.14",
|
||||
"@types/node": "20.17.16",
|
||||
"@types/passport-http-bearer": "1.0.41",
|
||||
"@types/passport-jwt": "4.0.1",
|
||||
"@types/supertest": "6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||
"@typescript-eslint/parser": "6.21.0",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-airbnb-base": "15.0.0",
|
||||
"eslint-config-airbnb-typescript": "17.0.0",
|
||||
"eslint-config-prettier": "8.8.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-jsdoc": "43.2.0",
|
||||
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||
"eslint-config-airbnb-typescript": "17.1.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-jsdoc": "48.11.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-prefer-arrow": "1.2.3",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"jest": "29.5.0",
|
||||
"pino-pretty": "10.0.0",
|
||||
"prettier": "2.8.8",
|
||||
"supertest": "6.3.3",
|
||||
"ts-jest": "29.1.0",
|
||||
"ts-loader": "9.4.2",
|
||||
"ts-node": "10.9.1",
|
||||
"eslint-plugin-react": "7.37.4",
|
||||
"eslint-plugin-react-hooks": "4.6.2",
|
||||
"jest": "29.7.0",
|
||||
"pino-pretty": "10.3.1",
|
||||
"prettier": "3.4.2",
|
||||
"supertest": "6.3.4",
|
||||
"ts-jest": "29.2.5",
|
||||
"ts-loader": "9.5.1",
|
||||
"ts-node": "10.9.2",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.0.4"
|
||||
"typescript": "5.7.2"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
":automergeBranch",
|
||||
":automergeLinters",
|
||||
":automergeTesters",
|
||||
":automergeTypes"
|
||||
":automergeTypes",
|
||||
":maintainLockFilesWeekly"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import type { Response } from "express";
|
||||
import type { Response as ExpressResponse } from "express";
|
||||
import { User } from "../users/user.entity";
|
||||
import { AuthSession } from "./auth-session.entity";
|
||||
import { AuthController } from "./auth.controller";
|
||||
|
|
@ -27,7 +27,7 @@ describe("AuthController", () => {
|
|||
|
||||
describe("spotifyCallback", () => {
|
||||
let user: User;
|
||||
let res: Response;
|
||||
let res: ExpressResponse;
|
||||
let refreshToken: string;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -36,7 +36,7 @@ describe("AuthController", () => {
|
|||
statusCode: 200,
|
||||
cookie: jest.fn(),
|
||||
redirect: jest.fn(),
|
||||
} as unknown as Response;
|
||||
} as unknown as ExpressResponse;
|
||||
|
||||
refreshToken = "REFRESH_TOKEN";
|
||||
authService.createSession = jest.fn().mockResolvedValue({ refreshToken });
|
||||
|
|
@ -56,7 +56,7 @@ describe("AuthController", () => {
|
|||
expect(res.cookie).toHaveBeenCalledWith(
|
||||
COOKIE_REFRESH_TOKEN,
|
||||
refreshToken,
|
||||
{ httpOnly: true }
|
||||
{ httpOnly: true },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ describe("AuthController", () => {
|
|||
|
||||
expect(res.redirect).toHaveBeenCalledTimes(1);
|
||||
expect(res.redirect).toHaveBeenCalledWith(
|
||||
"/login/success?source=spotify"
|
||||
"/login/success?source=spotify",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import {
|
||||
Body,
|
||||
Body as NestBody,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
|
|
@ -10,7 +10,7 @@ import {
|
|||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBody, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import type { Response as ExpressResponse } from "express";
|
||||
import { User } from "../users/user.entity";
|
||||
import { AuthSession } from "./auth-session.entity";
|
||||
import { AuthService } from "./auth.service";
|
||||
|
|
@ -42,7 +42,7 @@ export class AuthController {
|
|||
@Get("spotify/callback")
|
||||
@UseFilters(SpotifyAuthFilter)
|
||||
@UseGuards(SpotifyAuthGuard)
|
||||
async spotifyCallback(@ReqUser() user: User, @Res() res: Response) {
|
||||
async spotifyCallback(@ReqUser() user: User, @Res() res: ExpressResponse) {
|
||||
const { refreshToken } = await this.authService.createSession(user);
|
||||
|
||||
// Refresh token should not be accessible to frontend to reduce risk
|
||||
|
|
@ -57,7 +57,7 @@ export class AuthController {
|
|||
@UseGuards(RefreshTokenAuthGuard)
|
||||
async refreshAccessToken(
|
||||
// With RefreshTokenAuthGuard the session is available instead of user
|
||||
@ReqUser() session: AuthSession
|
||||
@ReqUser() session: AuthSession,
|
||||
): Promise<RefreshAccessTokenResponseDto> {
|
||||
const { accessToken } = await this.authService.createAccessToken(session);
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ export class AuthController {
|
|||
@AuthAccessToken()
|
||||
async createApiToken(
|
||||
@ReqUser() user: User,
|
||||
@Body("description") description: string
|
||||
@NestBody("description") description: string,
|
||||
): Promise<NewApiTokenDto> {
|
||||
const apiToken = await this.authService.createApiToken(user, description);
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ export class AuthController {
|
|||
@AuthAccessToken()
|
||||
async revokeApiToken(
|
||||
@ReqUser() user: User,
|
||||
@Param("id") id: string
|
||||
@Param("id") id: string,
|
||||
): Promise<void> {
|
||||
return this.authService.revokeApiToken(user, id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ describe("AuthService", () => {
|
|||
usersService = module.get<UsersService>(UsersService);
|
||||
jwtService = module.get<JwtService>(JwtService);
|
||||
authSessionRepository = module.get<AuthSessionRepository>(
|
||||
AuthSessionRepository
|
||||
AuthSessionRepository,
|
||||
);
|
||||
apiTokenRepository = module.get<ApiTokenRepository>(ApiTokenRepository);
|
||||
});
|
||||
|
|
@ -84,7 +84,7 @@ describe("AuthService", () => {
|
|||
|
||||
expect(service.allowedByUserFilter).toHaveBeenCalledTimes(1);
|
||||
expect(service.allowedByUserFilter).toHaveBeenCalledWith(
|
||||
loginDto.profile.id
|
||||
loginDto.profile.id,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ describe("AuthService", () => {
|
|||
service.allowedByUserFilter = jest.fn().mockReturnValue(false);
|
||||
|
||||
await expect(service.spotifyLogin(loginDto)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -197,7 +197,7 @@ describe("AuthService", () => {
|
|||
{
|
||||
jwtid: session.id,
|
||||
expiresIn: "EXPIRATION_TIME",
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -231,7 +231,7 @@ describe("AuthService", () => {
|
|||
session.revokedAt = new Date("2020-01-01T00:00:00Z");
|
||||
|
||||
await expect(service.createAccessToken(session)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -258,7 +258,7 @@ describe("AuthService", () => {
|
|||
|
||||
it("returns the session", async () => {
|
||||
await expect(service.findSession("AUTH_SESSION")).resolves.toEqual(
|
||||
session
|
||||
session,
|
||||
);
|
||||
|
||||
expect(authSessionRepository.findOneBy).toHaveBeenCalledTimes(1);
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@ export class AuthService {
|
|||
private readonly usersService: UsersService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly authSessionRepository: AuthSessionRepository,
|
||||
private readonly apiTokenRepository: ApiTokenRepository
|
||||
private readonly apiTokenRepository: ApiTokenRepository,
|
||||
) {
|
||||
this.userFilter = this.config.get<string>("SPOTIFY_USER_FILTER");
|
||||
this.sessionExpirationTime = this.config.get<string>(
|
||||
"SESSION_EXPIRATION_TIME"
|
||||
"SESSION_EXPIRATION_TIME",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ export class AuthService {
|
|||
* @param session
|
||||
*/
|
||||
private async createRefreshToken(
|
||||
session: AuthSession
|
||||
session: AuthSession,
|
||||
): Promise<{ refreshToken: string }> {
|
||||
const payload = {
|
||||
sub: session.user.id,
|
||||
|
|
@ -86,7 +86,7 @@ export class AuthService {
|
|||
}
|
||||
|
||||
async createAccessToken(
|
||||
session: AuthSession
|
||||
session: AuthSession,
|
||||
): Promise<{ accessToken: string }> {
|
||||
if (session.revokedAt) {
|
||||
throw new ForbiddenException("SessionIsRevoked");
|
||||
|
|
@ -115,7 +115,7 @@ export class AuthService {
|
|||
|
||||
// TODO demagic 20
|
||||
const tokenBuffer = await new Promise<Buffer>((resolve, reject) =>
|
||||
randomBytes(20, (err, buf) => (err ? reject(err) : resolve(buf)))
|
||||
randomBytes(20, (err, buf) => (err ? reject(err) : resolve(buf))),
|
||||
);
|
||||
apiToken.token = `lis${tokenBuffer.toString("hex")}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,6 @@ export function AuthAccessToken() {
|
|||
return applyDecorators(
|
||||
UseGuards(ApiAuthGuard),
|
||||
ApiBearerAuth(),
|
||||
ApiUnauthorizedResponse({ description: "Unauthorized" })
|
||||
ApiUnauthorizedResponse({ description: "Unauthorized" }),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,5 +4,5 @@ export const ReqUser = createParamDecorator<void>(
|
|||
(_: void, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ import {
|
|||
ForbiddenException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
import type { Response as ExpressResponse } from "express";
|
||||
|
||||
@Catch()
|
||||
export class SpotifyAuthFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(this.constructor.name);
|
||||
|
||||
catch(exception: Error, host: ArgumentsHost) {
|
||||
const response = host.switchToHttp().getResponse<Response>();
|
||||
const response = host.switchToHttp().getResponse<ExpressResponse>();
|
||||
|
||||
let reason = "unknown";
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ export class SpotifyAuthFilter implements ExceptionFilter {
|
|||
|
||||
this.logger.error(
|
||||
`Login with Spotify failed: ${exception}`,
|
||||
exception.stack
|
||||
exception.stack,
|
||||
);
|
||||
|
||||
response.redirect(`/login/failure?reason=${reason}&source=spotify`);
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ import { AuthStrategy } from "./strategies.enum";
|
|||
@Injectable()
|
||||
export class AccessTokenStrategy extends PassportStrategy(
|
||||
Strategy,
|
||||
AuthStrategy.AccessToken
|
||||
AuthStrategy.AccessToken,
|
||||
) {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
config: ConfigService
|
||||
config: ConfigService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { AuthStrategy } from "./strategies.enum";
|
|||
@Injectable()
|
||||
export class ApiTokenStrategy extends PassportStrategy(
|
||||
Strategy,
|
||||
AuthStrategy.ApiToken
|
||||
AuthStrategy.ApiToken,
|
||||
) {
|
||||
constructor(private readonly authService: AuthService) {
|
||||
super();
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ describe("RefreshTokenStrategy", () => {
|
|||
authService.findSession = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await expect(strategy.validate(payload)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ describe("RefreshTokenStrategy", () => {
|
|||
session.revokedAt = "2021-01-01";
|
||||
|
||||
await expect(strategy.validate(payload)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,11 +19,11 @@ const extractJwtFromCookie: JwtFromRequestFunction = (req) => {
|
|||
@Injectable()
|
||||
export class RefreshTokenStrategy extends PassportStrategy(
|
||||
Strategy,
|
||||
AuthStrategy.RefreshToken
|
||||
AuthStrategy.RefreshToken,
|
||||
) {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
config: ConfigService
|
||||
config: ConfigService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: extractJwtFromCookie,
|
||||
|
|
|
|||
|
|
@ -8,17 +8,17 @@ import { AuthStrategy } from "./strategies.enum";
|
|||
@Injectable()
|
||||
export class SpotifyStrategy extends PassportStrategy(
|
||||
Strategy,
|
||||
AuthStrategy.Spotify
|
||||
AuthStrategy.Spotify,
|
||||
) {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
config: ConfigService
|
||||
config: ConfigService,
|
||||
) {
|
||||
super({
|
||||
clientID: config.get<string>("SPOTIFY_CLIENT_ID"),
|
||||
clientSecret: config.get<string>("SPOTIFY_CLIENT_SECRET"),
|
||||
callbackURL: `${config.get<string>(
|
||||
"APP_URL"
|
||||
"APP_URL",
|
||||
)}/api/v1/auth/spotify/callback`,
|
||||
scope: [
|
||||
"user-read-private",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import * as Joi from "joi";
|
|||
SPOTIFY_UPDATE_INTERVAL_SEC: Joi.number().default(60),
|
||||
SPOTIFY_WEB_API_URL: Joi.string().default("https://api.spotify.com/"),
|
||||
SPOTIFY_AUTH_API_URL: Joi.string().default(
|
||||
"https://accounts.spotify.com/"
|
||||
"https://accounts.spotify.com/",
|
||||
),
|
||||
SPOTIFY_USER_FILTER: Joi.string(),
|
||||
|
||||
|
|
@ -53,14 +53,14 @@ import * as Joi from "joi";
|
|||
{
|
||||
is: Joi.valid(true),
|
||||
then: Joi.required(),
|
||||
}
|
||||
},
|
||||
),
|
||||
PROMETHEUS_BASIC_AUTH_PASSWORD: Joi.string().when(
|
||||
"PROMETHEUS_BASIC_AUTH",
|
||||
{
|
||||
is: Joi.valid(true),
|
||||
then: Joi.required(),
|
||||
}
|
||||
},
|
||||
),
|
||||
}),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const DatabaseModule = TypeOrmModule.forRootAsync({
|
|||
|
||||
// Debug/Development Options
|
||||
//
|
||||
// logging: true,
|
||||
//logging: true,
|
||||
//
|
||||
// synchronize: true,
|
||||
// migrationsRun: false,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { TYPEORM_ENTITY_REPOSITORY } from "./entity-repository.decorator";
|
|||
|
||||
export class TypeOrmRepositoryModule {
|
||||
public static for<T extends new (...args: any[]) => any>(
|
||||
repositories: T[]
|
||||
repositories: T[],
|
||||
): DynamicModule {
|
||||
const providers: Provider[] = [];
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ export class TypeOrmRepositoryModule {
|
|||
return new repository(
|
||||
baseRepository.target,
|
||||
baseRepository.manager,
|
||||
baseRepository.queryRunner
|
||||
baseRepository.queryRunner,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export class CreateUsersTable0000000000001 implements MigrationInterface {
|
|||
}),
|
||||
],
|
||||
}),
|
||||
true
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export class CreateLibraryTables0000000000002 implements MigrationInterface {
|
|||
}),
|
||||
],
|
||||
}),
|
||||
true
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
|
|
@ -64,7 +64,7 @@ export class CreateLibraryTables0000000000002 implements MigrationInterface {
|
|||
isUnique: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
|
|
@ -94,7 +94,7 @@ export class CreateLibraryTables0000000000002 implements MigrationInterface {
|
|||
referencedTableName: "album",
|
||||
}),
|
||||
],
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
|
|
@ -137,7 +137,7 @@ export class CreateLibraryTables0000000000002 implements MigrationInterface {
|
|||
}),
|
||||
],
|
||||
}),
|
||||
true
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
|
|
@ -180,7 +180,7 @@ export class CreateLibraryTables0000000000002 implements MigrationInterface {
|
|||
}),
|
||||
],
|
||||
}),
|
||||
true
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export class CreateListensTable0000000000003 implements MigrationInterface {
|
|||
}),
|
||||
],
|
||||
}),
|
||||
true
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export class CreateAuthSessionsTable0000000000004
|
|||
}),
|
||||
],
|
||||
}),
|
||||
true
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export class CreateGenreTables0000000000005 implements MigrationInterface {
|
|||
}),
|
||||
],
|
||||
}),
|
||||
true
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
|
|
@ -77,7 +77,7 @@ export class CreateGenreTables0000000000005 implements MigrationInterface {
|
|||
}),
|
||||
],
|
||||
}),
|
||||
true
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export class AddUpdatedAtColumnes0000000000006 implements MigrationInterface {
|
|||
name: "updatedAt",
|
||||
type: "timestamp",
|
||||
default: "NOW()",
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export class CreateApiTokensTable0000000000007 implements MigrationInterface {
|
|||
}),
|
||||
],
|
||||
}),
|
||||
true
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
72
src/database/migrations/08-OptimizeDBIndices.ts
Normal file
72
src/database/migrations/08-OptimizeDBIndices.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { MigrationInterface, QueryRunner, TableIndex } from "typeorm";
|
||||
|
||||
export class OptimizeDBIndices0000000000008 implements MigrationInterface {
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createIndices("artist", [
|
||||
new TableIndex({
|
||||
// This index helps with the "update artist" job
|
||||
name: "IDX_ARTIST_UPDATED_AT",
|
||||
columnNames: ["updatedAt"],
|
||||
}),
|
||||
]);
|
||||
|
||||
await queryRunner.createIndices("listen", [
|
||||
new TableIndex({
|
||||
// This index helps with the "getCrawlableUserInfo" query
|
||||
name: "IDX_LISTEN_USER_ID_PLAYED_AT",
|
||||
columnNames: ["userId", "playedAt"],
|
||||
}),
|
||||
]);
|
||||
|
||||
// handled by Primary Key on (albumId, artistId)
|
||||
await queryRunner.dropIndex("album_artists", "IDX_ALBUM_ARTISTS_ALBUM_ID");
|
||||
|
||||
// handled by Primary Key on (artistId, genreId)
|
||||
await queryRunner.dropIndex("artist_genres", "IDX_ARTIST_GENRES_ARTIST_ID");
|
||||
|
||||
// handled by IDX_LISTEN_UNIQUE on (trackId, userId, playedAt)
|
||||
await queryRunner.dropIndex("listen", "IDX_LISTEN_TRACK_ID");
|
||||
// handled by IDX_LISTEN_USER_ID_PLAYED_AT on (userId, playedAt)
|
||||
await queryRunner.dropIndex("listen", "IDX_LISTEN_USER_ID");
|
||||
|
||||
// handled by Primary Key on (trackId, artistId)
|
||||
await queryRunner.dropIndex("track_artists", "IDX_TRACK_ARTISTS_TRACK_ID");
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createIndices("album_artists", [
|
||||
new TableIndex({
|
||||
name: "IDX_ALBUM_ARTISTS_ALBUM_ID",
|
||||
columnNames: ["albumId"],
|
||||
}),
|
||||
]);
|
||||
|
||||
await queryRunner.createIndices("artist_genres", [
|
||||
new TableIndex({
|
||||
name: "IDX_ARTIST_GENRES_ARTIST_ID",
|
||||
columnNames: ["artistId"],
|
||||
}),
|
||||
]);
|
||||
|
||||
await queryRunner.createIndices("listen", [
|
||||
new TableIndex({
|
||||
name: "IDX_LISTEN_TRACK_ID",
|
||||
columnNames: ["trackId"],
|
||||
}),
|
||||
new TableIndex({
|
||||
name: "IDX_LISTEN_USER_ID",
|
||||
columnNames: ["userId"],
|
||||
}),
|
||||
]);
|
||||
|
||||
await queryRunner.createIndices("track_artists", [
|
||||
new TableIndex({
|
||||
name: "IDX_TRACK_ARTISTS_TRACK_ID",
|
||||
columnNames: ["trackId"],
|
||||
}),
|
||||
]);
|
||||
|
||||
await queryRunner.dropIndex("artist", "IDX_ARTIST_UPDATED_AT");
|
||||
await queryRunner.dropIndex("listen", "IDX_LISTEN_USER_ID_PLAYED_AT");
|
||||
}
|
||||
}
|
||||
68
src/database/migrations/09-CreateSpotifyImportTables.ts
Normal file
68
src/database/migrations/09-CreateSpotifyImportTables.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import {
|
||||
MigrationInterface,
|
||||
QueryRunner,
|
||||
Table,
|
||||
TableIndex,
|
||||
TableForeignKey,
|
||||
} from "typeorm";
|
||||
import { TableColumnOptions } from "typeorm/schema-builder/options/TableColumnOptions";
|
||||
|
||||
const primaryUUIDColumn: TableColumnOptions = {
|
||||
name: "id",
|
||||
type: "uuid",
|
||||
isPrimary: true,
|
||||
isGenerated: true,
|
||||
generationStrategy: "uuid",
|
||||
};
|
||||
|
||||
export class CreateSpotifyImportTables0000000000009
|
||||
implements MigrationInterface
|
||||
{
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "spotify_extended_streaming_history_listen",
|
||||
columns: [
|
||||
primaryUUIDColumn,
|
||||
{ name: "userId", type: "uuid" },
|
||||
{ name: "playedAt", type: "timestamp" },
|
||||
{ name: "spotifyTrackUri", type: "varchar" },
|
||||
{ name: "trackId", type: "uuid", isNullable: true },
|
||||
{ name: "listenId", type: "uuid", isNullable: true },
|
||||
],
|
||||
indices: [
|
||||
new TableIndex({
|
||||
name: "IDX_SPOTIFY_EXTENDED_STREAMING_HISTORY_LISTEN_USER_PLAYED_AT",
|
||||
columnNames: ["userId", "playedAt", "spotifyTrackUri"],
|
||||
isUnique: true,
|
||||
}),
|
||||
],
|
||||
foreignKeys: [
|
||||
new TableForeignKey({
|
||||
name: "FK_SPOTIFY_EXTENDED_STREAMING_HISTORY_LISTEN_USER_ID",
|
||||
columnNames: ["userId"],
|
||||
referencedColumnNames: ["id"],
|
||||
referencedTableName: "user",
|
||||
}),
|
||||
new TableForeignKey({
|
||||
name: "FK_SPOTIFY_EXTENDED_STREAMING_HISTORY_LISTEN_TRACK_ID",
|
||||
columnNames: ["trackId"],
|
||||
referencedColumnNames: ["id"],
|
||||
referencedTableName: "track",
|
||||
}),
|
||||
new TableForeignKey({
|
||||
name: "FK_SPOTIFY_EXTENDED_STREAMING_HISTORY_LISTEN_LISTEN_ID",
|
||||
columnNames: ["listenId"],
|
||||
referencedColumnNames: ["id"],
|
||||
referencedTableName: "listen",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable("spotify_extended_streaming_history_listen");
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ export class HealthCheckController {
|
|||
private readonly health: HealthCheckService,
|
||||
private readonly http: HttpHealthIndicator,
|
||||
private readonly typeorm: TypeOrmHealthIndicator,
|
||||
private readonly config: ConfigService
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
|
|
@ -27,7 +27,10 @@ export class HealthCheckController {
|
|||
() =>
|
||||
this.http.pingCheck(
|
||||
"spotify-web",
|
||||
this.config.get<string>("SPOTIFY_WEB_API_URL")
|
||||
this.config.get<string>("SPOTIFY_WEB_API_URL"),
|
||||
{
|
||||
validateStatus: () => true,
|
||||
}, // Successful as long as we get a valid HTTP response back }
|
||||
),
|
||||
() => this.typeorm.pingCheck("db"),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -3,10 +3,6 @@ import { Repository, SelectQueryBuilder } from "typeorm";
|
|||
import { EntityRepository } from "../database/entity-repository";
|
||||
import { Interval } from "../reports/interval";
|
||||
import { User } from "../users/user.entity";
|
||||
import {
|
||||
CreateListenRequestDto,
|
||||
CreateListenResponseDto,
|
||||
} from "./dto/create-listen.dto";
|
||||
import { Listen } from "./listen.entity";
|
||||
|
||||
export class ListenScopes extends SelectQueryBuilder<Listen> {
|
||||
|
|
@ -37,52 +33,4 @@ export class ListenRepository extends Repository<Listen> {
|
|||
get scoped(): ListenScopes {
|
||||
return new ListenScopes(this.createQueryBuilder("listen"));
|
||||
}
|
||||
|
||||
async insertNoConflict({
|
||||
user,
|
||||
track,
|
||||
playedAt,
|
||||
}: CreateListenRequestDto): Promise<CreateListenResponseDto> {
|
||||
const result = await this.createQueryBuilder()
|
||||
.insert()
|
||||
.values({
|
||||
user,
|
||||
track,
|
||||
playedAt,
|
||||
})
|
||||
.onConflict('("playedAt", "trackId", "userId") DO NOTHING')
|
||||
.execute();
|
||||
|
||||
const [insertedRowIdentifier] = result.identifiers;
|
||||
|
||||
if (!insertedRowIdentifier) {
|
||||
// We did not insert a new listen, it already existed
|
||||
return {
|
||||
listen: await this.findOneBy({ user, track, playedAt }),
|
||||
isDuplicate: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
listen: await this.findOneBy({ id: insertedRowIdentifier.id }),
|
||||
isDuplicate: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param rows
|
||||
* @returns A list of all new (non-duplicate) listens
|
||||
*/
|
||||
async insertsNoConflict(rows: CreateListenRequestDto[]): Promise<Listen[]> {
|
||||
const result = await this.createQueryBuilder()
|
||||
.insert()
|
||||
.values(rows)
|
||||
.orIgnore()
|
||||
.execute();
|
||||
|
||||
return this.findBy(
|
||||
result.identifiers.filter(Boolean).map(({ id }) => ({ id }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ describe("Listens Controller", () => {
|
|||
|
||||
it("returns the listens", async () => {
|
||||
await expect(
|
||||
controller.getRecentlyPlayed(filter, user, 1, 10)
|
||||
controller.getRecentlyPlayed(filter, user, 1, 10),
|
||||
).resolves.toEqual(listens);
|
||||
|
||||
expect(listensService.getListens).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -57,7 +57,7 @@ describe("Listens Controller", () => {
|
|||
await controller.getRecentlyPlayed(filter, user, 1, 1000);
|
||||
|
||||
expect(listensService.getListens).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: 100 })
|
||||
expect.objectContaining({ limit: 100 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export class ListensController {
|
|||
@Query("filter") filter: GetListensFilterDto,
|
||||
@ReqUser() user: User,
|
||||
@Query("page") page: number = 1,
|
||||
@Query("limit") limit: number = 10
|
||||
@Query("limit") limit: number = 10,
|
||||
): Promise<Pagination<Listen>> {
|
||||
const clampedLimit = limit > 100 ? 100 : limit;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@ import {
|
|||
paginate,
|
||||
PaginationTypeEnum,
|
||||
} from "nestjs-typeorm-paginate";
|
||||
import { Track } from "../music-library/track.entity";
|
||||
import { User } from "../users/user.entity";
|
||||
import { CreateListenResponseDto } from "./dto/create-listen.dto";
|
||||
import { GetListensDto } from "./dto/get-listens.dto";
|
||||
import { Listen } from "./listen.entity";
|
||||
import { ListenRepository, ListenScopes } from "./listen.repository";
|
||||
|
|
@ -35,39 +33,6 @@ describe("ListensService", () => {
|
|||
expect(listenRepository).toBeDefined();
|
||||
});
|
||||
|
||||
describe("createListen", () => {
|
||||
let user: User;
|
||||
let track: Track;
|
||||
let playedAt: Date;
|
||||
let response: CreateListenResponseDto;
|
||||
beforeEach(() => {
|
||||
user = { id: "USER" } as User;
|
||||
track = { id: "TRACK" } as Track;
|
||||
playedAt = new Date("2021-01-01T00:00:00Z");
|
||||
|
||||
response = {
|
||||
listen: {
|
||||
id: "LISTEN",
|
||||
} as Listen,
|
||||
isDuplicate: true,
|
||||
};
|
||||
listenRepository.insertNoConflict = jest.fn().mockResolvedValue(response);
|
||||
});
|
||||
|
||||
it("creates the listen", async () => {
|
||||
await expect(
|
||||
service.createListen({ user, track, playedAt })
|
||||
).resolves.toEqual(response);
|
||||
|
||||
expect(listenRepository.insertNoConflict).toHaveBeenCalledTimes(1);
|
||||
expect(listenRepository.insertNoConflict).toHaveBeenLastCalledWith({
|
||||
user,
|
||||
track,
|
||||
playedAt,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getListens", () => {
|
||||
let options: GetListensDto & IPaginationOptions;
|
||||
let user: User;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import { Injectable } from "@nestjs/common";
|
||||
import { Span } from "nestjs-otel";
|
||||
import {
|
||||
IPaginationOptions,
|
||||
paginate,
|
||||
Pagination,
|
||||
PaginationTypeEnum,
|
||||
} from "nestjs-typeorm-paginate";
|
||||
import {
|
||||
CreateListenRequestDto,
|
||||
CreateListenResponseDto,
|
||||
} from "./dto/create-listen.dto";
|
||||
import { CreateListenRequestDto } from "./dto/create-listen.dto";
|
||||
import { GetListensDto } from "./dto/get-listens.dto";
|
||||
import { Listen } from "./listen.entity";
|
||||
import { ListenRepository, ListenScopes } from "./listen.repository";
|
||||
|
|
@ -17,22 +15,9 @@ import { ListenRepository, ListenScopes } from "./listen.repository";
|
|||
export class ListensService {
|
||||
constructor(private readonly listenRepository: ListenRepository) {}
|
||||
|
||||
async createListen({
|
||||
user,
|
||||
track,
|
||||
playedAt,
|
||||
}: CreateListenRequestDto): Promise<CreateListenResponseDto> {
|
||||
const response = await this.listenRepository.insertNoConflict({
|
||||
user,
|
||||
track,
|
||||
playedAt,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@Span()
|
||||
async createListens(
|
||||
listensData: CreateListenRequestDto[]
|
||||
listensData: CreateListenRequestDto[],
|
||||
): Promise<Listen[]> {
|
||||
const existingListens = await this.listenRepository.findBy(listensData);
|
||||
|
||||
|
|
@ -42,17 +27,19 @@ export class ListensService {
|
|||
(existingListen) =>
|
||||
newListen.user.id === existingListen.user.id &&
|
||||
newListen.track.id === existingListen.track.id &&
|
||||
newListen.playedAt.getTime() === existingListen.playedAt.getTime()
|
||||
)
|
||||
newListen.playedAt.getTime() === existingListen.playedAt.getTime(),
|
||||
),
|
||||
);
|
||||
|
||||
return this.listenRepository.save(
|
||||
missingListens.map((entry) => this.listenRepository.create(entry))
|
||||
const newListens = await this.listenRepository.save(
|
||||
missingListens.map((entry) => this.listenRepository.create(entry)),
|
||||
);
|
||||
|
||||
return [...existingListens, ...newListens];
|
||||
}
|
||||
|
||||
async getListens(
|
||||
options: GetListensDto & IPaginationOptions
|
||||
options: GetListensDto & IPaginationOptions,
|
||||
): Promise<Pagination<Listen>> {
|
||||
const { page, limit, user, filter } = options;
|
||||
|
||||
|
|
@ -77,16 +64,6 @@ export class ListensService {
|
|||
});
|
||||
}
|
||||
|
||||
async getMostRecentListenPerUser(): Promise<Listen[]> {
|
||||
return this.listenRepository
|
||||
.createQueryBuilder("listen")
|
||||
.leftJoinAndSelect("listen.user", "user")
|
||||
.distinctOn(["user.id"])
|
||||
.orderBy({ "user.id": "ASC", "listen.playedAt": "DESC" })
|
||||
.limit(1)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
getScopedQueryBuilder(): ListenScopes {
|
||||
return this.listenRepository.scoped;
|
||||
}
|
||||
|
|
|
|||
11
src/main.ts
11
src/main.ts
|
|
@ -13,7 +13,7 @@ import { Scope } from "@sentry/node";
|
|||
|
||||
function setupSentry(
|
||||
app: NestExpressApplication,
|
||||
configService: ConfigService
|
||||
configService: ConfigService,
|
||||
) {
|
||||
Sentry.init({
|
||||
dsn: configService.get<string>("SENTRY_DSN"),
|
||||
|
|
@ -34,7 +34,7 @@ function setupSentry(
|
|||
}
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -43,14 +43,19 @@ async function bootstrap() {
|
|||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
bufferLogs: true,
|
||||
rawBody: true,
|
||||
});
|
||||
app.useLogger(app.get(Logger));
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
transformOptions: { enableImplicitConversion: true },
|
||||
})
|
||||
}),
|
||||
);
|
||||
app.useBodyParser("json", {
|
||||
limit:
|
||||
"10mb" /* Need large bodies for Spotify Extended Streaming History */,
|
||||
});
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const configService = app.get<ConfigService>(ConfigService);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export class FindTrackDto {
|
||||
spotify: {
|
||||
id: string;
|
||||
id?: string;
|
||||
uri?: string;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue