mirror of
https://github.com/apricote/Listory.git
synced 2026-01-13 21:21:02 +00:00
Compare commits
712 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 |
85 changed files with 11187 additions and 22230 deletions
|
|
@ -19,5 +19,6 @@
|
|||
!frontend/tsconfig.json
|
||||
!frontend/vite.config.js
|
||||
!frontend/index.html
|
||||
!frontend/*.d.ts
|
||||
!frontend/src/**/*
|
||||
!frontend/public/**/*
|
||||
|
|
|
|||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
|
|
|||
2
.github/workflows/release.yaml
vendored
2
.github/workflows/release.yaml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
version: "v0.11.2"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
|
|
|||
42
CHANGELOG.md
42
CHANGELOG.md
|
|
@ -1,3 +1,45 @@
|
|||
# [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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.28.0
|
||||
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.28.0
|
||||
appVersion: 1.31.0
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ services:
|
|||
#####
|
||||
|
||||
db:
|
||||
image: postgres:16.0
|
||||
image: postgres:16.6
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_PASSWORD: listory
|
||||
|
|
@ -18,7 +18,7 @@ services:
|
|||
- db
|
||||
|
||||
api:
|
||||
image: apricote/listory:1.28.0
|
||||
image: apricote/listory:1.31.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DB_HOST: db
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ services:
|
|||
#####
|
||||
|
||||
db:
|
||||
image: postgres:16.0
|
||||
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.4
|
||||
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.47.0
|
||||
image: prom/prometheus:v2.55.1
|
||||
volumes:
|
||||
- ./observability/prometheus:/etc/prometheus
|
||||
- prometheus_data:/prometheus
|
||||
|
|
@ -146,7 +148,7 @@ services:
|
|||
|
||||
loki:
|
||||
profiles: ["observability"]
|
||||
image: grafana/loki:2.9.1
|
||||
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.9.1
|
||||
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.2.3
|
||||
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:10.1.1
|
||||
image: grafana/grafana-oss:10.4.14
|
||||
volumes:
|
||||
- ./observability/grafana/provisioning:/etc/grafana/provisioning
|
||||
environment:
|
||||
|
|
|
|||
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"
|
||||
}
|
||||
}
|
||||
13174
frontend/package-lock.json
generated
13174
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": "6.1.3",
|
||||
"@testing-library/react": "14.0.0",
|
||||
"@testing-library/user-event": "14.5.1",
|
||||
"@types/jest": "29.5.5",
|
||||
"@types/node": "20.6.2",
|
||||
"@types/react": "18.2.21",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"@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.4",
|
||||
"autoprefixer": "10.4.15",
|
||||
"axios": "1.5.0",
|
||||
"@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.1.0",
|
||||
"lucide-react": "0.468.0",
|
||||
"npm-run-all": "4.1.5",
|
||||
"postcss": "8.4.29",
|
||||
"prettier": "3.0.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "6.16.0",
|
||||
"recharts": "2.8.0",
|
||||
"tailwindcss": "3.3.3",
|
||||
"typescript": "5.2.2",
|
||||
"vite": "4.4.9",
|
||||
"vitest": "0.34.4"
|
||||
"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)\"",
|
||||
|
|
|
|||
|
|
@ -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 */>
|
||||
<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 path="/" />
|
||||
<Route path="/login/success" element={<LoginSuccess />} />
|
||||
<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="/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,6 +14,8 @@ 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 {}
|
||||
|
||||
|
|
@ -276,7 +278,7 @@ export const revokeApiToken = async (
|
|||
id: string,
|
||||
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,25 +2,22 @@ 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
|
||||
.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>
|
||||
|
|
@ -28,7 +25,7 @@ export const AuthApiTokens: React.FC = () => {
|
|||
<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">
|
||||
<a href="/api/docs" target="_blank" className={"underline"}>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
|
|
@ -59,8 +56,7 @@ export const AuthApiTokens: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
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/use-outside-click";
|
||||
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 && (
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink
|
||||
asChild
|
||||
className={navigationMenuTriggerStyle()}
|
||||
>
|
||||
<a {...loginWithSpotifyProps()}>
|
||||
<NavItem>
|
||||
Login with Spotify{" "}
|
||||
<span>Login with Spotify </span>
|
||||
<SpotifyLogo className="w-6 h-6 ml-2 mb-1 inline fill-current text-white" />
|
||||
</NavItem>
|
||||
</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">
|
||||
<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}
|
||||
</span>
|
||||
</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)}
|
||||
<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 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 className="text-green-200 pl-2 sm:pr-2">
|
||||
{user.displayName}
|
||||
</span>
|
||||
<Link to="/auth/api-tokens" onClick={closeMenu}>
|
||||
<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,21 +25,19 @@ 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">
|
||||
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Recent listens
|
||||
</p>
|
||||
<button
|
||||
</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>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-2 m-2">
|
||||
{isLoading && <Spinner className="m-8" />}
|
||||
|
|
@ -53,17 +50,18 @@ export const RecentListens: React.FC = () => {
|
|||
)}
|
||||
<div>
|
||||
{recentListens.length > 0 && (
|
||||
<div className="table-auto w-full">
|
||||
<Table className="table-auto w-full">
|
||||
<TableBody>
|
||||
{recentListens.map((listen) => (
|
||||
<ListenItem listen={listen} key={listen.id} />
|
||||
))}
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} setPage={setPage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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,18 +10,23 @@ 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",
|
||||
);
|
||||
|
|
@ -41,33 +46,34 @@ export const ReportListens: React.FC = () => {
|
|||
|
||||
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">
|
||||
<h2 className="text-2xl font-normal text-gray-700 dark:text-gray-400">
|
||||
Listen Report
|
||||
</p>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-5 m-2">
|
||||
<div className="md:flex">
|
||||
<div className="sm: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",
|
||||
)
|
||||
<Label className="text-sm" htmlFor={"timeframe"}>
|
||||
Timeframe
|
||||
</Label>
|
||||
<Select
|
||||
onValueChange={(e: "day" | "week" | "month" | "year") =>
|
||||
setTimeFrame(e)
|
||||
}
|
||||
>
|
||||
<option value="day">Daily</option>
|
||||
<option value="week">Weekly</option>
|
||||
<option value="month">Monthly</option>
|
||||
<option value="year">Yearly</option>
|
||||
</select>
|
||||
<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}
|
||||
|
|
@ -86,8 +92,7 @@ export const ReportListens: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Time period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{timePresetOptions.map(({ value, description }) => (
|
||||
<option value={value} key={value}>
|
||||
<SelectItem value={value} key={value}>
|
||||
{description}
|
||||
</option>
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
@ -143,9 +146,7 @@ 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;
|
||||
},
|
||||
|
|
@ -162,3 +163,38 @@ export const useApiTokens = () => {
|
|||
|
||||
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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
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>
|
||||
);
|
||||
|
|
@ -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,7 +14,9 @@ root.render(
|
|||
<ProvideAuth>
|
||||
<ProvideApiClient>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</ProvideApiClient>
|
||||
</ProvideAuth>
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
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,
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
16644
package-lock.json
generated
16644
package-lock.json
generated
File diff suppressed because it is too large
Load diff
136
package.json
136
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@listory/api",
|
||||
"version": "1.28.0",
|
||||
"version": "1.31.0",
|
||||
"description": "Track your Spotify Listen History",
|
||||
"author": {
|
||||
"name": "Julian Tölle",
|
||||
|
|
@ -28,91 +28,91 @@
|
|||
"dependencies": {
|
||||
"@apricote/nest-pg-boss": "2.1.0",
|
||||
"@narando/nest-axios-interceptor": "3.0.0",
|
||||
"@nestjs/axios": "3.0.0",
|
||||
"@nestjs/common": "10.2.5",
|
||||
"@nestjs/config": "3.1.1",
|
||||
"@nestjs/core": "10.2.5",
|
||||
"@nestjs/jwt": "10.1.1",
|
||||
"@nestjs/passport": "10.0.2",
|
||||
"@nestjs/platform-express": "10.2.5",
|
||||
"@nestjs/serve-static": "4.0.0",
|
||||
"@nestjs/swagger": "7.1.11",
|
||||
"@nestjs/terminus": "10.1.1",
|
||||
"@nestjs/typeorm": "10.0.0",
|
||||
"@opentelemetry/api": "1.6.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.17.0",
|
||||
"@opentelemetry/exporter-prometheus": "0.43.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.43.0",
|
||||
"@opentelemetry/instrumentation": "0.43.0",
|
||||
"@opentelemetry/instrumentation-dns": "0.32.2",
|
||||
"@opentelemetry/instrumentation-express": "0.33.1",
|
||||
"@opentelemetry/instrumentation-http": "0.43.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "0.33.1",
|
||||
"@opentelemetry/instrumentation-pg": "0.36.1",
|
||||
"@opentelemetry/instrumentation-pino": "0.34.1",
|
||||
"@opentelemetry/resources": "1.17.0",
|
||||
"@opentelemetry/sdk-metrics": "1.17.0",
|
||||
"@opentelemetry/sdk-node": "0.43.0",
|
||||
"@opentelemetry/sdk-trace-base": "1.17.0",
|
||||
"@opentelemetry/semantic-conventions": "1.17.0",
|
||||
"@sentry/node": "7.69.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.10.1",
|
||||
"joi": "17.13.3",
|
||||
"lodash": "4.17.21",
|
||||
"nest-raven": "10.0.0",
|
||||
"nest-raven": "10.1.0",
|
||||
"nestjs-otel": "5.1.5",
|
||||
"nestjs-pino": "3.4.0",
|
||||
"nestjs-pino": "4.1.0",
|
||||
"nestjs-typeorm-paginate": "4.0.4",
|
||||
"passport": "0.6.0",
|
||||
"passport": "0.7.0",
|
||||
"passport-http-bearer": "1.0.1",
|
||||
"passport-jwt": "4.0.1",
|
||||
"passport-spotify": "2.0.0",
|
||||
"pg": "8.11.3",
|
||||
"pg": "8.13.1",
|
||||
"pg-boss": "9.0.3",
|
||||
"pino": "8.15.1",
|
||||
"pino-http": "8.5.0",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"rimraf": "5.0.1",
|
||||
"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.17"
|
||||
"typeorm": "0.3.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "10.1.17",
|
||||
"@nestjs/schematics": "10.0.2",
|
||||
"@nestjs/testing": "10.2.5",
|
||||
"@types/cookie-parser": "1.4.4",
|
||||
"@types/express": "4.17.17",
|
||||
"@types/jest": "29.5.5",
|
||||
"@types/lodash": "4.14.198",
|
||||
"@types/node": "20.6.2",
|
||||
"@types/passport-http-bearer": "1.0.37",
|
||||
"@types/passport-jwt": "3.0.9",
|
||||
"@types/supertest": "2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "6.7.0",
|
||||
"@typescript-eslint/parser": "6.7.0",
|
||||
"eslint": "8.49.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.1.0",
|
||||
"eslint-config-prettier": "9.0.0",
|
||||
"eslint-plugin-import": "2.28.1",
|
||||
"eslint-plugin-jsdoc": "46.8.1",
|
||||
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||
"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.33.2",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"eslint-plugin-react": "7.37.4",
|
||||
"eslint-plugin-react-hooks": "4.6.2",
|
||||
"jest": "29.7.0",
|
||||
"pino-pretty": "10.2.0",
|
||||
"prettier": "3.0.3",
|
||||
"supertest": "6.3.3",
|
||||
"ts-jest": "29.1.1",
|
||||
"ts-loader": "9.4.4",
|
||||
"ts-node": "10.9.1",
|
||||
"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.2.2"
|
||||
"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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export class OptimizeDBIndices0000000000008 implements MigrationInterface {
|
|||
]);
|
||||
|
||||
// handled by Primary Key on (albumId, artistId)
|
||||
await queryRunner.dropIndex("album_artist", "IDX_ALBUM_ARTISTS_ALBUM_ID");
|
||||
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");
|
||||
|
|
@ -34,7 +34,7 @@ export class OptimizeDBIndices0000000000008 implements MigrationInterface {
|
|||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createIndices("album_artist", [
|
||||
await queryRunner.createIndices("album_artists", [
|
||||
new TableIndex({
|
||||
name: "IDX_ALBUM_ARTISTS_ALBUM_ID",
|
||||
columnNames: ["albumId"],
|
||||
|
|
|
|||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,20 +15,7 @@ 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[],
|
||||
): Promise<Listen[]> {
|
||||
|
|
@ -46,9 +31,11 @@ export class ListensService {
|
|||
),
|
||||
);
|
||||
|
||||
return this.listenRepository.save(
|
||||
const newListens = await this.listenRepository.save(
|
||||
missingListens.map((entry) => this.listenRepository.create(entry)),
|
||||
);
|
||||
|
||||
return [...existingListens, ...newListens];
|
||||
}
|
||||
|
||||
async getListens(
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ async function bootstrap() {
|
|||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
bufferLogs: true,
|
||||
rawBody: true,
|
||||
});
|
||||
app.useLogger(app.get(Logger));
|
||||
app.useGlobalPipes(
|
||||
|
|
@ -51,6 +52,10 @@ async function bootstrap() {
|
|||
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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,9 +175,7 @@ export class MusicLibraryService {
|
|||
}
|
||||
|
||||
async findTrack(query: FindTrackDto): Promise<Track | undefined> {
|
||||
return this.trackRepository.findOneBy({
|
||||
spotify: { id: query.spotify.id },
|
||||
});
|
||||
return this.trackRepository.findOneBy(query);
|
||||
}
|
||||
|
||||
async findTracks(query: FindTrackDto[]): Promise<Track[]> {
|
||||
|
|
|
|||
5
src/override.d.ts
vendored
5
src/override.d.ts
vendored
|
|
@ -1,5 +0,0 @@
|
|||
// Issue with opentelemetry-js: https://github.com/open-telemetry/opentelemetry-js/issues/3580#issuecomment-1701157270
|
||||
export {};
|
||||
declare global {
|
||||
type BlobPropertyBag = unknown;
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ export class SchedulerService implements OnApplicationBootstrap {
|
|||
}
|
||||
|
||||
private async setupSpotifyCrawlerSupervisor(): Promise<void> {
|
||||
await this.superviseImportJobsJobService.schedule("*/1 * * * *", {}, {});
|
||||
// await this.superviseImportJobsJobService.schedule("*/1 * * * *", {}, {});
|
||||
}
|
||||
|
||||
@Span()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class ExtendedStreamingHistoryStatusDto {
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
})
|
||||
total: number;
|
||||
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
})
|
||||
imported: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ArrayMaxSize } from "class-validator";
|
||||
import { SpotifyExtendedStreamingHistoryItemDto } from "./spotify-extended-streaming-history-item.dto";
|
||||
|
||||
export class ImportExtendedStreamingHistoryDto {
|
||||
@ApiProperty({
|
||||
type: SpotifyExtendedStreamingHistoryItemDto,
|
||||
isArray: true,
|
||||
maxItems: 50_000,
|
||||
})
|
||||
@ArrayMaxSize(50_000) // File size is ~16k by default, might need refactoring if Spotify starts exporting larger files
|
||||
listens: SpotifyExtendedStreamingHistoryItemDto[];
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class SpotifyExtendedStreamingHistoryItemDto {
|
||||
@ApiProperty({ format: "iso8601", example: "2018-11-30T08:33:33Z" })
|
||||
ts: string;
|
||||
|
||||
@ApiProperty({ example: "spotify:track:6askbS4pEVWbbDnUGEXh3G" })
|
||||
spotify_track_uri: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { Body as NestBody, Controller, Get, Post } from "@nestjs/common";
|
||||
import { ApiBody, ApiTags } from "@nestjs/swagger";
|
||||
import { AuthAccessToken } from "../../../auth/decorators/auth-access-token.decorator";
|
||||
import { ReqUser } from "../../../auth/decorators/req-user.decorator";
|
||||
import { User } from "../../../users/user.entity";
|
||||
import { ExtendedStreamingHistoryStatusDto } from "./dto/extended-streaming-history-status.dto";
|
||||
import { ImportExtendedStreamingHistoryDto } from "./dto/import-extended-streaming-history.dto";
|
||||
import { ImportService } from "./import.service";
|
||||
|
||||
@ApiTags("import")
|
||||
@Controller("api/v1/import")
|
||||
export class ImportController {
|
||||
constructor(private readonly importService: ImportService) {}
|
||||
|
||||
@Post("extended-streaming-history")
|
||||
@ApiBody({ type: () => ImportExtendedStreamingHistoryDto })
|
||||
@AuthAccessToken()
|
||||
async importExtendedStreamingHistory(
|
||||
@ReqUser() user: User,
|
||||
@NestBody() data: ImportExtendedStreamingHistoryDto,
|
||||
): Promise<void> {
|
||||
return this.importService.importExtendedStreamingHistory(user, data);
|
||||
}
|
||||
|
||||
@Get("extended-streaming-history/status")
|
||||
@AuthAccessToken()
|
||||
async getExtendedStreamingHistoryStatus(
|
||||
@ReqUser() user: User,
|
||||
): Promise<ExtendedStreamingHistoryStatusDto> {
|
||||
return this.importService.getExtendedStreamingHistoryStatus(user);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
import { JobService } from "@apricote/nest-pg-boss";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { uniq } from "lodash";
|
||||
import { Span } from "nestjs-otel";
|
||||
import type { Job } from "pg-boss";
|
||||
import { ListensService } from "../../../listens/listens.service";
|
||||
import { User } from "../../../users/user.entity";
|
||||
import { SpotifyService } from "../spotify.service";
|
||||
import { ExtendedStreamingHistoryStatusDto } from "./dto/extended-streaming-history-status.dto";
|
||||
import { ImportExtendedStreamingHistoryDto } from "./dto/import-extended-streaming-history.dto";
|
||||
import {
|
||||
IProcessSpotifyExtendedStreamingHistoryListenJob,
|
||||
ProcessSpotifyExtendedStreamingHistoryListenJob,
|
||||
} from "./jobs";
|
||||
import { SpotifyExtendedStreamingHistoryListenRepository } from "./listen.repository";
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
private readonly logger = new Logger(this.constructor.name);
|
||||
|
||||
constructor(
|
||||
private readonly importListenRepository: SpotifyExtendedStreamingHistoryListenRepository,
|
||||
@ProcessSpotifyExtendedStreamingHistoryListenJob.Inject()
|
||||
private readonly processListenJobService: JobService<IProcessSpotifyExtendedStreamingHistoryListenJob>,
|
||||
private readonly spotifyService: SpotifyService,
|
||||
private readonly listensService: ListensService,
|
||||
) {}
|
||||
|
||||
@Span()
|
||||
async importExtendedStreamingHistory(
|
||||
user: User,
|
||||
{ listens: importListens }: ImportExtendedStreamingHistoryDto,
|
||||
): Promise<void> {
|
||||
// IDK what's happening, but my personal data set has entries with duplicate
|
||||
// listens? might be related to offline mode.
|
||||
// Anyway, this cleans it up:
|
||||
const uniqEntries = new Set();
|
||||
const uniqueListens = importListens.filter((listen) => {
|
||||
const key = `${listen.spotify_track_uri}-${listen.ts}`;
|
||||
|
||||
if (!uniqEntries.has(key)) {
|
||||
// New entry
|
||||
uniqEntries.add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
let listens = uniqueListens.map((listenData) =>
|
||||
this.importListenRepository.create({
|
||||
user,
|
||||
playedAt: new Date(listenData.ts),
|
||||
spotifyTrackUri: listenData.spotify_track_uri,
|
||||
}),
|
||||
);
|
||||
|
||||
// Save listens to import table
|
||||
const insertResult = await this.importListenRepository.upsert(listens, [
|
||||
"user",
|
||||
"playedAt",
|
||||
"spotifyTrackUri",
|
||||
]);
|
||||
|
||||
const processJobs = insertResult.identifiers.map((listen) => ({
|
||||
data: {
|
||||
id: listen.id,
|
||||
},
|
||||
singletonKey: listen.id,
|
||||
retryLimit: 10,
|
||||
retryDelay: 5,
|
||||
retryBackoff: true,
|
||||
}));
|
||||
|
||||
// Schedule jobs to process imports
|
||||
await this.processListenJobService.insert(processJobs);
|
||||
}
|
||||
|
||||
@ProcessSpotifyExtendedStreamingHistoryListenJob.Handle({
|
||||
// Spotify API "Get Several XY" allows max 50 IDs
|
||||
batchSize: 50,
|
||||
newJobCheckInterval: 500,
|
||||
})
|
||||
@Span()
|
||||
async processListens(
|
||||
jobs: Job<IProcessSpotifyExtendedStreamingHistoryListenJob>[],
|
||||
): Promise<void> {
|
||||
this.logger.debug(
|
||||
{ jobs: jobs.length },
|
||||
"processing extended streaming history listens",
|
||||
);
|
||||
const importListens = await this.importListenRepository.findBy(
|
||||
jobs.map((job) => ({ id: job.data.id })),
|
||||
);
|
||||
|
||||
const listensWithoutTracks = importListens.filter(
|
||||
(importListen) => !importListen.track,
|
||||
);
|
||||
if (listensWithoutTracks.length > 0) {
|
||||
const missingTrackIDs = uniq(
|
||||
listensWithoutTracks.map((importListen) =>
|
||||
importListen.spotifyTrackUri.replace("spotify:track:", ""),
|
||||
),
|
||||
);
|
||||
|
||||
const tracks = await this.spotifyService.importTracks(missingTrackIDs);
|
||||
|
||||
listensWithoutTracks.forEach((listen) => {
|
||||
listen.track = tracks.find(
|
||||
(track) => listen.spotifyTrackUri === track.spotify.uri,
|
||||
);
|
||||
if (!listen.track) {
|
||||
this.logger.warn(
|
||||
{ listen },
|
||||
"could not find track for extended streaming history listen",
|
||||
);
|
||||
throw new Error(
|
||||
`could not find track for extended streaming history listen`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Using upsert instead of save to only do a single query
|
||||
await this.importListenRepository.upsert(listensWithoutTracks, ["id"]);
|
||||
}
|
||||
|
||||
const listensWithoutListen = importListens.filter(
|
||||
(importListen) => !importListen.listen,
|
||||
);
|
||||
if (listensWithoutListen.length > 0) {
|
||||
const listens = await this.listensService.createListens(
|
||||
listensWithoutListen.map((listen) => ({
|
||||
user: listen.user,
|
||||
track: listen.track,
|
||||
playedAt: listen.playedAt,
|
||||
})),
|
||||
);
|
||||
|
||||
listensWithoutListen.forEach((importListen) => {
|
||||
importListen.listen = listens.find(
|
||||
(listen) =>
|
||||
importListen.user.id === listen.user.id &&
|
||||
importListen.track.id === listen.track.id &&
|
||||
importListen.playedAt.getTime() === listen.playedAt.getTime(),
|
||||
);
|
||||
if (!importListen.listen) {
|
||||
this.logger.warn(
|
||||
{ listen: importListen, listens: listens },
|
||||
"could not find listen for extended streaming history listen",
|
||||
);
|
||||
throw new Error(
|
||||
`could not find listen for extended streaming history listen`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Using upsert instead of save to only do a single query
|
||||
await this.importListenRepository.upsert(listensWithoutListen, ["id"]);
|
||||
}
|
||||
}
|
||||
|
||||
@Span()
|
||||
async getExtendedStreamingHistoryStatus(
|
||||
user: User,
|
||||
): Promise<ExtendedStreamingHistoryStatusDto> {
|
||||
const qb = this.importListenRepository
|
||||
.createQueryBuilder("listen")
|
||||
.where("listen.userId = :user", { user: user.id });
|
||||
|
||||
const [total, imported] = await Promise.all([
|
||||
qb.clone().getCount(),
|
||||
qb.clone().andWhere("listen.listenId IS NOT NULL").getCount(),
|
||||
]);
|
||||
|
||||
return { total, imported };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export { ImportController } from "./import.controller";
|
||||
export { ImportService } from "./import.service";
|
||||
export { ProcessSpotifyExtendedStreamingHistoryListenJob } from "./jobs";
|
||||
export { SpotifyExtendedStreamingHistoryListenRepository } from "./listen.repository";
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { createJob } from "@apricote/nest-pg-boss";
|
||||
|
||||
export type IProcessSpotifyExtendedStreamingHistoryListenJob = { id: string };
|
||||
export const ProcessSpotifyExtendedStreamingHistoryListenJob =
|
||||
createJob<IProcessSpotifyExtendedStreamingHistoryListenJob>(
|
||||
"process-spotify-extended-streaming-history-listen",
|
||||
);
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
|
||||
import { Track } from "../../../music-library/track.entity";
|
||||
import { User } from "../../../users/user.entity";
|
||||
import { Listen } from "../../../listens/listen.entity";
|
||||
|
||||
@Entity({ name: "spotify_extended_streaming_history_listen" })
|
||||
export class SpotifyExtendedStreamingHistoryListen {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id: string;
|
||||
|
||||
@ManyToOne(() => User, { eager: true })
|
||||
user: User;
|
||||
|
||||
@Column({ type: "timestamp" })
|
||||
playedAt: Date;
|
||||
|
||||
@Column()
|
||||
spotifyTrackUri: string;
|
||||
|
||||
@ManyToOne(() => Track, { nullable: true, eager: true })
|
||||
track?: Track;
|
||||
|
||||
@ManyToOne(() => Listen, { nullable: true, eager: true })
|
||||
listen?: Listen;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { Repository } from "typeorm";
|
||||
import { EntityRepository } from "../../../database/entity-repository";
|
||||
import { SpotifyExtendedStreamingHistoryListen } from "./listen.entity";
|
||||
|
||||
@EntityRepository(SpotifyExtendedStreamingHistoryListen)
|
||||
export class SpotifyExtendedStreamingHistoryListenRepository extends Repository<SpotifyExtendedStreamingHistoryListen> {}
|
||||
|
|
@ -1,25 +1,37 @@
|
|||
import { PGBossModule } from "@apricote/nest-pg-boss";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmRepositoryModule } from "../../database/entity-repository/typeorm-repository.module";
|
||||
import { ListensModule } from "../../listens/listens.module";
|
||||
import { MusicLibraryModule } from "../../music-library/music-library.module";
|
||||
import { UsersModule } from "../../users/users.module";
|
||||
import { ImportSpotifyJob } from "../jobs";
|
||||
import {
|
||||
ImportController,
|
||||
ImportService,
|
||||
ProcessSpotifyExtendedStreamingHistoryListenJob,
|
||||
SpotifyExtendedStreamingHistoryListenRepository,
|
||||
} from "./import-extended-streaming-history";
|
||||
import { SpotifyApiModule } from "./spotify-api/spotify-api.module";
|
||||
import { SpotifyAuthModule } from "./spotify-auth/spotify-auth.module";
|
||||
import { SpotifyService } from "./spotify.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PGBossModule.forJobs([ImportSpotifyJob]),
|
||||
PGBossModule.forJobs([
|
||||
ImportSpotifyJob,
|
||||
ProcessSpotifyExtendedStreamingHistoryListenJob,
|
||||
]),
|
||||
TypeOrmRepositoryModule.for([
|
||||
SpotifyExtendedStreamingHistoryListenRepository,
|
||||
]),
|
||||
UsersModule,
|
||||
ListensModule,
|
||||
MusicLibraryModule,
|
||||
SpotifyApiModule,
|
||||
SpotifyAuthModule,
|
||||
],
|
||||
providers: [SpotifyService],
|
||||
providers: [SpotifyService, ImportService],
|
||||
controllers: [ImportController],
|
||||
exports: [SpotifyService],
|
||||
})
|
||||
export class SpotifyModule {
|
||||
constructor(private readonly spotifyService: SpotifyService) {}
|
||||
}
|
||||
export class SpotifyModule {}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ export class SpotifyService {
|
|||
private readonly spotifyAuth: SpotifyAuthService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns a list of users that should be crawled for new listens.
|
||||
* Only returns the lastListen date if it was within the last hour.
|
||||
*/
|
||||
@Span()
|
||||
async getCrawlableUserInfo(): Promise<
|
||||
{ userID: string; lastListen: Date }[]
|
||||
|
|
@ -57,6 +61,7 @@ export class SpotifyService {
|
|||
.select(`listen."userId"`)
|
||||
.addSelect(`listen."playedAt"`)
|
||||
.from("listen", "listen")
|
||||
.where(`listen."playedAt" > now() - interval '1 hour'`)
|
||||
.orderBy("listen.userId", "DESC")
|
||||
.addOrderBy("listen.playedAt", "DESC"),
|
||||
"listen",
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@
|
|||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"target": "ES2020",
|
||||
"target": "ES2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"paths": {},
|
||||
"lib": ["ES2020"]
|
||||
},
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"include": ["src", "test"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue