Compare commits
637 commits
Author | SHA1 | Date | |
---|---|---|---|
|
03b616622e | ||
|
50e1da01fe | ||
|
f8f50b26d8 | ||
|
a3bc133bcb | ||
|
50dae22ff4 | ||
|
5a91154eab | ||
|
d5b89d512a | ||
|
97f43f88eb | ||
|
3177af9524 | ||
|
f99d22738c | ||
|
ea7124db52 | ||
|
b1247e5de6 | ||
|
5ab962634e | ||
|
d99944dcff | ||
|
920bcb06dc | ||
|
3679cc21ab | ||
|
07e5626469 | ||
|
6bb6f87800 | ||
|
cb6b52ff4c | ||
|
03c377dff3 | ||
|
0e3f172032 | ||
|
fd704419d9 | ||
|
32ac72f592 | ||
|
9c77b87435 | ||
|
d4d9e94f44 | ||
|
47cef2abf0 | ||
|
0a5cdf380f | ||
|
9da7967158 | ||
|
964744a9ae | ||
|
779304d035 | ||
|
f15d7445bd | ||
|
26d68ba3fc | ||
|
958805a1fd | ||
|
dd74c173a5 | ||
|
bd020c93f5 | ||
|
75555f0426 | ||
|
233de7fc37 | ||
|
c6e0230e5d | ||
|
ca2732668e | ||
|
456b76ac96 | ||
|
a2c58bea82 | ||
|
7276b3d5ef | ||
|
1ef8315462 | ||
|
2f88ac419b | ||
|
cee7c85780 | ||
|
b2c5205035 | ||
|
2be59c3eb6 | ||
|
176fc896ae | ||
|
a943555d86 | ||
|
58bbfbbc42 | ||
|
026b644630 | ||
|
1483b355af | ||
|
6a5bb635bb | ||
|
0e0aca3844 | ||
|
1512718d41 | ||
|
4731b90899 | ||
|
844e32a11e | ||
|
0b2cfc45c1 | ||
|
c8be41ce6c | ||
|
ec2f710d06 | ||
|
71112173b9 | ||
|
1615c20e66 | ||
|
fbcea12786 | ||
|
047d13afd1 | ||
|
4c1165533c | ||
|
14bdcf71fe | ||
|
1b3f022b2d | ||
|
b6d93a7f09 | ||
|
4b2438c298 | ||
|
3b79c8e09f | ||
|
d7bbf2b8e2 | ||
|
b8df34309f | ||
|
278fa3c397 | ||
|
6f48ee98e5 | ||
|
774b0c104b | ||
|
fbd4d9a74e | ||
|
3f2fefe4f7 | ||
|
fb8513bc9f | ||
|
e4ec958edb | ||
|
78ad88a245 | ||
|
99660aeee8 | ||
|
65a412eb59 | ||
|
2002ed1c9c | ||
|
488a1f6070 | ||
|
1e12d937aa | ||
|
e643a44b59 | ||
|
7319870289 | ||
|
72c1ebf66d | ||
|
008c4a3ab7 | ||
|
38d3d11571 | ||
|
d4565acfe7 | ||
|
16129c53bd | ||
|
cbf1961510 | ||
|
c76a4d4be7 | ||
|
27af0400c0 | ||
|
76a80ff034 | ||
|
f7f333ad52 | ||
|
0ce45e32aa | ||
|
abeb11c8a6 | ||
|
e01af4adec | ||
|
3043a0bd15 | ||
|
232cab01f8 | ||
|
b301953249 | ||
|
bb9cb03c8a | ||
|
d8a4d39849 | ||
|
0d6966726e | ||
|
bb26725c67 | ||
|
103fe5718b | ||
|
a0d1bf1788 | ||
|
37f35281b4 | ||
|
306fb3cb33 | ||
|
24a6107171 | ||
|
e495fbfd99 | ||
|
da6fbc4995 | ||
|
8d673e1229 | ||
|
d8c7d02f5e | ||
|
8331c4b305 | ||
|
5a06f4788b | ||
|
827171e206 | ||
|
c3f3fa9876 | ||
|
825ef84da5 | ||
|
4445d46f61 | ||
|
c583057d45 | ||
|
982f3ee2a1 | ||
|
cd1e4e9f91 | ||
|
33f8e6d144 | ||
|
af70cc83ba | ||
|
8b731fd9e5 | ||
|
a0c697b982 | ||
|
21a6fe407b | ||
|
1c1a7b3e27 | ||
|
61fd5481cc | ||
|
d400b27545 | ||
|
ec9d455d18 | ||
|
8a75f303bb | ||
|
0ce706e02e | ||
|
3076593021 | ||
|
a83389bb7d | ||
|
ee1cde8a04 | ||
|
a9efe88461 | ||
|
c8200f2972 | ||
|
80b43d3e0c | ||
|
a7e235441b | ||
|
bb80637e72 | ||
|
8fb5de4e87 | ||
|
1c03f0a07a | ||
|
b4ac96ccaf | ||
|
5723fbdea5 | ||
|
260bc15577 | ||
|
59bfe3e835 | ||
|
086fac4120 | ||
|
46bc36ad4a | ||
|
ed477e8cc6 | ||
|
8b878149d4 | ||
|
db512419ea | ||
|
491bc65a38 | ||
|
94ec286de6 | ||
|
108c83588c | ||
|
abbb4950a5 | ||
|
0fe0b34898 | ||
|
8ad7d4be0a | ||
|
ac7f3805d4 | ||
|
e524dd111e | ||
|
4a14709f13 | ||
|
85b8ee493e | ||
|
e546488aeb | ||
|
0702e0abd3 | ||
|
29318005b2 | ||
|
b4b61c94d7 | ||
|
f18620d890 | ||
|
eb86c3fb79 | ||
|
f89fc2ee1d | ||
|
495eaa0a37 | ||
|
b44b7b80ff | ||
|
c8ff5362a3 | ||
|
d575a0a29f | ||
|
2e1134fdfb | ||
|
ab6ae15836 | ||
|
a4840c7365 | ||
|
dbcc13a5cf | ||
|
8d2639b349 | ||
|
889b034a79 | ||
|
e49145023f | ||
|
ae1fb05607 | ||
|
80a957bc5b | ||
|
24f93d8258 | ||
|
01016e08d2 | ||
|
949fde1517 | ||
|
804cf9916b | ||
|
ef24680baf | ||
|
d4b1d240b9 | ||
|
448c795982 | ||
|
7f667e2d1c | ||
|
a1afdb0c63 | ||
|
ee1d7599d5 | ||
|
8adad18198 | ||
|
49a0758aae | ||
|
e3bdc73013 | ||
|
1922e1e895 | ||
|
7ee8e7c8d2 | ||
|
e5106c0704 | ||
|
80331aa217 | ||
|
84537e6884 | ||
|
f7062115bc | ||
|
24f9f20eb7 | ||
|
217c6f2ed0 | ||
|
82490ef5dd | ||
|
fcda017c39 | ||
|
0c36925783 | ||
|
fcb67e62c5 | ||
|
0651a2886e | ||
|
d876f4cb39 | ||
|
b5259d1a98 | ||
|
ffe053ffc5 | ||
|
1785af4749 | ||
|
98b4b7330e | ||
|
f68e5ae9ef | ||
|
b3e73ce86a | ||
|
02cbb5f812 | ||
|
1d9ae72c81 | ||
|
d6470ae814 | ||
|
a816d1a913 | ||
|
2c03316f86 | ||
|
d19214a390 | ||
|
03035d1a2d | ||
|
6886716e67 | ||
|
6165308c23 | ||
|
77a9469ff8 | ||
|
2dce9b4c48 | ||
|
74e05763f7 | ||
|
a4185fde07 | ||
|
ebb519e6d8 | ||
|
90fbba600f | ||
|
4bd4921131 | ||
|
44ee813c6f | ||
|
3e467c5021 | ||
|
2b0dd3ab99 | ||
|
2618346a32 | ||
|
eacbb14279 | ||
|
c8570d07ef | ||
|
7e345dd1f9 | ||
|
afe6ad6bfc | ||
|
af4c1e8514 | ||
|
d4fecd1dd8 | ||
|
79779eb721 | ||
|
6af666b58c | ||
|
42872136a0 | ||
|
aca7b2ac5f | ||
|
a646f19b3f | ||
|
9c5298aebf | ||
|
c6b07852fe | ||
|
40d3053df9 | ||
|
5e576a58e9 | ||
|
4d63b5dda1 | ||
|
c1efd3f68b | ||
|
2841cc67e8 | ||
|
becf34b0d9 | ||
|
1bba915aef | ||
|
f7239137d6 | ||
|
d656986413 | ||
|
d0c4e9d846 | ||
|
bacb607d90 | ||
|
3914e24b3d | ||
|
4582be1da5 | ||
|
c3c7f8b14f | ||
|
863d4f117b | ||
|
35038ed56b | ||
|
c4149f5d94 | ||
|
de2db71e54 | ||
|
2e3ed896cc | ||
|
f6f352e9e0 | ||
|
33dede8abc | ||
|
1e2e66ecf7 | ||
|
6e5140d859 | ||
|
84a7f90129 | ||
|
aff02c2def | ||
|
2dd5b29303 | ||
|
bc8f17393a | ||
|
72f78f1c8d | ||
|
201b4ca1e8 | ||
|
04811a0658 | ||
|
ee94d6aa89 | ||
|
3382cd6208 | ||
|
56f9ec1d10 | ||
|
1b42312d02 | ||
|
8a41a9f506 | ||
|
18bb2d7501 | ||
|
6f8e576c9b | ||
|
e5bb102ab1 | ||
|
13700fe2b2 | ||
|
3cfbe65855 | ||
|
ab0b11cc92 | ||
|
17c8071de9 | ||
|
1fe7f61ec8 | ||
|
ea3b8124fc | ||
|
c41cfadb19 | ||
|
e434fe0847 | ||
|
8a8aaa752e | ||
|
4420d1df2c | ||
|
d90d39933a | ||
|
7e38ab624a | ||
|
9a36187333 | ||
|
b0c4d70628 | ||
|
d5fa6424c1 | ||
|
07fe5b3cb1 | ||
|
672547cd07 | ||
|
28167403a4 | ||
|
eaf08d42dd | ||
|
bae95a5e07 | ||
|
db1ed9e257 | ||
|
adef35049f | ||
|
bd9abf2e52 | ||
|
aa74c00fd4 | ||
|
a68a907fa7 | ||
|
1a05d6d03a | ||
|
60f4183057 | ||
|
04c3afc850 | ||
|
066e512844 | ||
|
d60457afaf | ||
|
f5a788220e | ||
|
2fd5edc6e4 | ||
|
26716d0b89 | ||
|
3e0f57d0c7 | ||
|
5347717f3d | ||
|
6b0a569998 | ||
|
4476391287 | ||
|
16a9d8c244 | ||
|
bbda9a0ee8 | ||
|
776fdcc6ce | ||
|
f76e06ec57 | ||
|
3a9cff697b | ||
|
e6979c77e3 | ||
|
99866507f5 | ||
|
cf0dd07c21 | ||
|
b9bf8c6c96 | ||
|
725d0da15d | ||
|
37164070d2 | ||
|
d7bd34531f | ||
|
71ca1753ef | ||
|
c6bfbf4ac1 | ||
|
a27fde72ee | ||
|
785f6a36bf | ||
|
1a4c12d851 | ||
|
b484e32b08 | ||
|
303438834b | ||
|
70b7b7615a | ||
|
5c42e60faf | ||
|
48ef60e0eb | ||
|
01af97ddab | ||
|
038794fa1c | ||
|
ffb1bccb10 | ||
|
371eb3bee6 | ||
|
466d935d02 | ||
|
7ffca9c3f1 | ||
|
a5981b3106 | ||
|
1df080983a | ||
|
822b72eee4 | ||
|
014abdcc00 | ||
|
b35cc437d3 | ||
|
7ce87c7168 | ||
|
2a3f8eedf6 | ||
|
895c3f2f97 | ||
|
8860f4591b | ||
|
984f26807e | ||
|
88c58e6108 | ||
|
83c7c4a14a | ||
|
b4a4df480e | ||
|
9843b4d218 | ||
|
c1251af597 | ||
|
7d09b8bf1b | ||
|
f57bdeec12 | ||
|
9899f6b761 | ||
|
328e10b89f | ||
|
b37f8a8375 | ||
|
139937f887 | ||
|
b8b90451b6 | ||
|
c9a2aff6a6 | ||
|
b25b117717 | ||
|
e174fd731c | ||
|
34e6d463be | ||
|
808169f43c | ||
|
82b6531a07 | ||
|
917e2cabfc | ||
|
a981025679 | ||
|
f9ea2d73e1 | ||
|
a5728e9407 | ||
|
404a2acc8f | ||
|
7bf61ee8ed | ||
|
bf1cc47525 | ||
|
74ad27d5fb | ||
|
60b73aff24 | ||
|
4de465e544 | ||
|
e1161b9227 | ||
|
c2cdd0fa08 | ||
|
b365a03e71 | ||
|
2e629efc7f | ||
|
fcaa2bef6e | ||
|
3b2a9fedef | ||
|
64256f2638 | ||
|
5c8122d93b | ||
|
6ffb95779d | ||
|
af975d0e7f | ||
|
34b2b88cf3 | ||
|
3fea166274 | ||
|
233b905492 | ||
|
7afad765cb | ||
|
39663cb594 | ||
|
457e0539fa | ||
|
871ba619a3 | ||
|
f5427310e8 | ||
|
e4435252f4 | ||
|
a66aa74c7f | ||
|
3949e15f77 | ||
|
acaf50bf8a | ||
|
09eedc08c1 | ||
|
738bcf8bcb | ||
|
795caa5d9d | ||
|
c041197f3f | ||
|
0730789cb3 | ||
|
1eb0fbd2c9 | ||
|
0484122b7f | ||
|
7bc9c704d1 | ||
|
c9efdc2c16 | ||
|
e0dee998d4 | ||
|
e09ea60616 | ||
|
7cab07fa01 | ||
|
5a62a8afe9 | ||
|
23d6d41148 | ||
|
a07fbfd7b7 | ||
|
1411268e35 | ||
|
9bda88edbd | ||
|
16da25898f | ||
|
88ae2b8e83 | ||
|
7d1ede8c91 | ||
|
3805f8b156 | ||
|
39f40670d0 | ||
|
514cf2b81c | ||
|
4dbb5975c0 | ||
|
b327e59ab1 | ||
|
917d51e54b | ||
|
122d05346b | ||
|
645802981e | ||
|
0348a2475c | ||
|
a2b7f59dc9 | ||
|
c23dc93c7a | ||
|
d3e3667405 | ||
|
d4ed5afe93 | ||
|
790ecef1c1 | ||
|
edb59ffdc6 | ||
|
e137038586 | ||
|
761eb343f9 | ||
|
6c15c907bf | ||
|
06b21e3d27 | ||
|
263d2e6f30 | ||
|
c97fec66f4 | ||
|
d5cf881d7a | ||
|
050d3e6cd2 | ||
|
757f68c872 | ||
|
b83bf9bc09 | ||
|
8d96e883d6 | ||
|
75079a981b | ||
|
e8043be107 | ||
|
c276f66017 | ||
|
db7df33b27 | ||
|
1b8e355944 | ||
|
342ef90cbe | ||
|
b3be60acf6 | ||
|
b58d3e2849 | ||
|
4206fb943d | ||
|
ee58ff40f1 | ||
|
21fd842bde | ||
|
b63b5eb262 | ||
|
9c69509a19 | ||
|
511147fb90 | ||
|
9ce0030482 | ||
|
4164263639 | ||
|
1bebb88d0e | ||
|
d2f5dbbc26 | ||
|
2af6500028 | ||
|
a7cf8be8c4 | ||
|
69b3e544bd | ||
|
0c63c61db5 | ||
|
8dc34ddfa7 | ||
|
8efda0047c | ||
|
e3c17e0347 | ||
|
038c43bc09 | ||
|
720549ec7e | ||
|
3aa1f273b6 | ||
|
5105ebb870 | ||
|
0a55c12f4f | ||
|
67c20c3a5f | ||
|
f4f7892123 | ||
|
00a93e466d | ||
|
d27993d2c9 | ||
|
bfc65d51e3 | ||
|
3c29d38599 | ||
|
f8816ff227 | ||
|
a5be460c2e | ||
|
e1e7853c34 | ||
|
3f862f67ab | ||
|
4bb5b2641b | ||
|
f044fdf5dc | ||
|
d1be4637ad | ||
|
d18f645c18 | ||
|
6663360caa | ||
|
c2d24ee4d9 | ||
|
37471fcdc1 | ||
|
0f42567d98 | ||
|
8148f09b9c | ||
|
c270afb5e5 | ||
|
6de0f73ec1 | ||
|
a80a34afb1 | ||
|
584f813fc4 | ||
|
a44056dc43 | ||
|
c941c78ebf | ||
|
9e9f001fdf | ||
|
9060887694 | ||
|
6d9ea27e9f | ||
|
4f4973157c | ||
|
859a6d2f27 | ||
|
e3ad646bae | ||
|
53a661861a | ||
|
4a27ec3271 | ||
|
926de71274 | ||
|
b0e57bae9f | ||
|
0bbb4573b2 | ||
|
eeda28a287 | ||
|
59c55b96be | ||
|
060609e2cb | ||
|
a1c48bf012 | ||
|
a67fb66d2e | ||
|
3001f4845a | ||
|
192c1ad669 | ||
|
89ae8a3607 | ||
|
21909e0bda | ||
|
5e9635a76f | ||
|
90d718c186 | ||
|
6627a635d5 | ||
|
20740f33af | ||
|
0681a04607 | ||
|
3cfbe0c89b | ||
|
21d491843f | ||
|
566ac99a53 | ||
|
fca504901f | ||
|
e03231cbd7 | ||
|
693051f4e9 | ||
|
06e891f9a9 | ||
|
a2bb1b88ea | ||
|
9602269f27 | ||
|
27b9af6067 | ||
|
d43c0749c1 | ||
|
f4fca7600a | ||
|
6134f1c812 | ||
|
8392b7a606 | ||
|
94cf82c3d9 | ||
|
b0018c3f06 | ||
|
8977114806 | ||
|
36f8eac3e4 | ||
|
0f01a8eda5 | ||
|
de965dace8 | ||
|
f0ca2edb25 | ||
|
514d5f92d1 | ||
|
7e05f8a5eb | ||
|
48d4d4e966 | ||
|
6a7b50b35f | ||
|
6cad5a8efb | ||
|
7743664527 | ||
|
714e1b6d29 | ||
|
a0585e097f | ||
|
6363b76d33 | ||
|
503562f2e5 | ||
|
01a7331354 | ||
|
c07a1ab636 | ||
|
eb9f92f04a | ||
|
c05607934b | ||
|
f9ca93e0b0 | ||
|
09b7d71103 | ||
|
bc06578e53 | ||
|
344f518991 | ||
|
6bb44bef20 | ||
|
aef0c84286 | ||
|
0fbc8dcb41 | ||
|
818138340c | ||
|
4beab6bcec | ||
|
b9b7ae1cac | ||
|
91c75518da | ||
|
82f51293df | ||
|
840ba0f240 | ||
|
c5e3eed64b | ||
|
7dd1cca65d | ||
|
57414bdb67 | ||
|
646be63895 | ||
|
3c1fb97c12 | ||
|
e78619f5e1 | ||
|
aa04904a72 | ||
|
54102ae35e | ||
|
7c4c1798b6 | ||
|
f30006093d | ||
|
c8ef47064a | ||
|
461c984317 | ||
|
ecbad7ce6c | ||
|
7adf624e95 | ||
|
56cb97706b | ||
|
dd5bec799f | ||
|
2389415071 | ||
|
976fb02bd3 | ||
|
007fe18d27 | ||
|
11f2999b52 | ||
|
e204029d3c | ||
|
d3eaae2fb0 | ||
|
251c22a14f | ||
|
7d0a7186e8 | ||
|
7b444b88e3 | ||
|
f317b1b189 | ||
|
3523562c3a | ||
|
5fdc3e3f10 | ||
|
24ef8f6e2a | ||
|
d8d6625478 | ||
|
ad06146784 | ||
|
c97171883c | ||
|
9c5b6045af | ||
|
eb85a7656f | ||
|
25d45b309d | ||
|
7d0b4535bb | ||
|
f8af2025e8 | ||
|
948c117f5b | ||
|
58967ab758 | ||
|
44a153d30a | ||
|
d5d6103327 | ||
|
189b8895b0 | ||
|
748ad1ff8d | ||
|
ccdafcdfab | ||
|
78572a534b | ||
|
2eb8e71e89 | ||
|
5375103c4b | ||
|
faeeb7c899 | ||
|
99fd77feac |
11
.dockerignore
Normal file
|
@ -0,0 +1,11 @@
|
|||
# https://docs.docker.com/build/building/context/#dockerignore-files
|
||||
# Ignore all files by default
|
||||
*
|
||||
|
||||
# Only add necessary files to the Docker build context (Dockerfiles are always included implicitly)
|
||||
!/build/
|
||||
!/internal/
|
||||
!/pkg/
|
||||
!/go.mod
|
||||
!/go.sum
|
||||
!main.go
|
128
.github/CODE_OF_CONDUCT.md
vendored
Normal file
|
@ -0,0 +1,128 @@
|
|||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
glanceapp@duck.com.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
1
.github/FUNDING.yml
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
github: [glanceapp]
|
37
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
|
@ -0,0 +1,37 @@
|
|||
name: Bug report
|
||||
description: Let us know if something isn't working as expected
|
||||
labels: ["bug report"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!NOTE]
|
||||
>
|
||||
> Do not prefix your title with "[BUG]", "[Bug report]", etc., a label will be added automatically.
|
||||
|
||||
If you're unsure whether you're experiencing a bug or not, consider using the [Discussions](https://github.com/glanceapp/glance/discussions) or [Discord](https://discord.com/invite/7KQ7Xa9kJd) to ask for help.
|
||||
|
||||
Please include only the information you think is relevant to the bug:
|
||||
|
||||
* How did you install Glance? (Docker container, manual binary install, etc)
|
||||
* Which version of Glance are you using?
|
||||
* Include the relevant parts of your `glance.yml` if applicable (widget, data source, properties used, etc)
|
||||
* Include any relevant logs or screenshots if applicable
|
||||
* Is the issue specific to a certain browser or OS?
|
||||
* Steps to reliably reproduce the issue
|
||||
* Are you hosting Glance on a VPS?
|
||||
* Anything else you think might be relevant
|
||||
|
||||
**No need to copy the above list into your description, it's just a guide to help you provide the most useful information.**
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Description
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to submit a bug report.
|
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discussions
|
||||
url: https://github.com/glanceapp/glance/discussions
|
||||
about: For help, feedback, guides, resources and more
|
||||
- name: Discord
|
||||
url: https://discord.com/invite/7KQ7Xa9kJd
|
||||
about: Much like the discussions but more chatty
|
33
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
|
@ -0,0 +1,33 @@
|
|||
name: Feature request
|
||||
description: Share your ideas for new features or improvements
|
||||
labels: ["feature request"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!NOTE]
|
||||
>
|
||||
> Do not prefix your title with "[REQUEST]", "[Feature request]", etc., a label will be added automatically.
|
||||
|
||||
Please provide a detailed description of what the feature would do and what it would look like:
|
||||
|
||||
* What problem would this feature solve?
|
||||
* Are there any potential downsides to this feature?
|
||||
* If applicable, what would the configuration for this feature look like?
|
||||
* Are there any existing examples of this feature in other software?
|
||||
* If applicable, include any external documentation required to implement this feature
|
||||
* Anything else you think might be relevant
|
||||
|
||||
**No need to copy the above list into your description, it's just a guide to help you provide the most useful information.**
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Description
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to submit your idea.
|
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
<!-- If your pull request adds new features, changes existing ones or fixes any bugs, please use the dev branch as the base, otherwise use the main branch -->
|
9
.github/SECURITY.md
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security updates will be applied to the latest as well as previous minor version release depending on severity and if applicable.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report any suspected security vulnerabilities to [glanceapp@duck.com](mailto:glanceapp@duck.com) and do not disclose them publicly. You should receive a response within a few days and if confirmed the issue will be resolved as soon as possible.
|
39
.github/workflows/release.yaml
vendored
Normal file
|
@ -0,0 +1,39 @@
|
|||
name: Create release
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the target Git reference
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Set up Docker buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: release
|
3
.gitignore
vendored
|
@ -1,4 +1,5 @@
|
|||
/assets
|
||||
/build
|
||||
/playground
|
||||
glance.yml
|
||||
/.idea
|
||||
/glance*.yml
|
||||
|
|
69
.goreleaser.yaml
Normal file
|
@ -0,0 +1,69 @@
|
|||
project_name: glanceapp/glance
|
||||
|
||||
checksum:
|
||||
disable: true
|
||||
|
||||
builds:
|
||||
- binary: glance
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- openbsd
|
||||
- freebsd
|
||||
- windows
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
- 386
|
||||
goarm:
|
||||
- 7
|
||||
ldflags:
|
||||
- -s -w -X github.com/glanceapp/glance/internal/glance.buildVersion={{ .Tag }}
|
||||
|
||||
archives:
|
||||
-
|
||||
name_template: "glance-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}"
|
||||
files:
|
||||
- nothing*
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
|
||||
dockers:
|
||||
- image_templates:
|
||||
- &amd64_image "{{ .ProjectName }}:{{ .Tag }}-amd64"
|
||||
build_flag_templates:
|
||||
- --platform=linux/amd64
|
||||
goarch: amd64
|
||||
use: buildx
|
||||
dockerfile: Dockerfile.goreleaser
|
||||
|
||||
- image_templates:
|
||||
- &arm64v8_image "{{ .ProjectName }}:{{ .Tag }}-arm64"
|
||||
build_flag_templates:
|
||||
- --platform=linux/arm64
|
||||
goarch: arm64
|
||||
use: buildx
|
||||
dockerfile: Dockerfile.goreleaser
|
||||
|
||||
- image_templates:
|
||||
- &armv7_image "{{ .ProjectName }}:{{ .Tag }}-armv7"
|
||||
build_flag_templates:
|
||||
- --platform=linux/arm/v7
|
||||
goarch: arm
|
||||
goarm: 7
|
||||
use: buildx
|
||||
dockerfile: Dockerfile.goreleaser
|
||||
|
||||
docker_manifests:
|
||||
- name_template: "{{ .ProjectName }}:{{ .Tag }}"
|
||||
image_templates: &multiarch_images
|
||||
- *amd64_image
|
||||
- *arm64v8_image
|
||||
- *armv7_image
|
||||
- name_template: "{{ .ProjectName }}:latest"
|
||||
skip_push: auto
|
||||
image_templates: *multiarch_images
|
18
Dockerfile
|
@ -1,10 +1,16 @@
|
|||
FROM alpine:3.19
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
FROM golang:1.24.2-alpine3.21 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY build/glance-$TARGETOS-$TARGETARCH /app/glance
|
||||
COPY . /app
|
||||
RUN CGO_ENABLED=0 go build .
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/glance .
|
||||
|
||||
HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
|
||||
CMD wget --spider -q http://localhost:8080/api/healthz
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
ENTRYPOINT ["/app/glance"]
|
||||
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
||||
|
|
10
Dockerfile.goreleaser
Normal file
|
@ -0,0 +1,10 @@
|
|||
FROM alpine:3.21
|
||||
|
||||
WORKDIR /app
|
||||
COPY glance .
|
||||
|
||||
HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
|
||||
CMD wget --spider -q http://localhost:8080/api/healthz
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
454
README.md
|
@ -1,90 +1,436 @@
|
|||
<p align="center"><em>What if you could see everything at a...</em></p>
|
||||
<h1 align="center">Glance</h1>
|
||||
<p align="center"><a href="#installation">Install</a> • <a href="docs/configuration.md">Configuration</a> • <a href="docs/themes.md">Themes</a></p>
|
||||
<p align="center"><a href="#installation">Install</a> • <a href="docs/configuration.md">Configuration</a> • <a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a> • <a href="https://github.com/sponsors/glanceapp">Sponsor</a></p>
|
||||
<p align="center"><a href="https://github.com/glanceapp/community-widgets">Community widgets</a> • <a href="docs/preconfigured-pages.md">Preconfigured pages</a> • <a href="docs/themes.md">Themes</a></p>
|
||||
|
||||

|
||||

|
||||
|
||||
### Features
|
||||
#### Various widgets
|
||||
## Features
|
||||
### Various widgets
|
||||
* RSS feeds
|
||||
* Subreddit posts
|
||||
* Weather
|
||||
* Bookmarks
|
||||
* Latest YouTube videos from specific channels
|
||||
* Calendar
|
||||
* Stocks
|
||||
* iframe
|
||||
* Twitch channels & top games
|
||||
* GitHub releases
|
||||
* Site monitor
|
||||
* Hacker News posts
|
||||
* Weather forecasts
|
||||
* YouTube channel uploads
|
||||
* Twitch channels
|
||||
* Market prices
|
||||
* Docker containers status
|
||||
* Server stats
|
||||
* Custom widgets
|
||||
* [and many more...](docs/configuration.md)
|
||||
|
||||
#### Themeable
|
||||

|
||||
### Fast and lightweight
|
||||
* Low memory usage
|
||||
* Few dependencies
|
||||
* Minimal vanilla JS
|
||||
* Single <20mb binary available for multiple OSs & architectures and just as small Docker container
|
||||
* Uncached pages usually load within ~1s (depending on internet speed and number of widgets)
|
||||
|
||||
#### Optimized for mobile devices
|
||||

|
||||
### Tons of customizability
|
||||
* Different layouts
|
||||
* As many pages/tabs as you need
|
||||
* Numerous configuration options for each widget
|
||||
* Multiple styles for some widgets
|
||||
* Custom CSS
|
||||
|
||||
#### Fast and lightweight
|
||||
* Minimal JS, no bloated frameworks
|
||||
* Very few dependencies
|
||||
* Single, easily distributed <15mb binary and just as small docker container
|
||||
* All requests are parallelized, uncached pages usually load within ~1s (depending on internet speed and number of widgets)
|
||||
### Optimized for mobile devices
|
||||
Because you'll want to take it with you on the go.
|
||||
|
||||
### Configuration
|
||||
Checkout the [configuration docs](docs/configuration.md) to learn more. A [preconfigured page](docs/configuration.md#preconfigured-page) is also available to get you started quickly.
|
||||

|
||||
|
||||
### Installation
|
||||
> [!CAUTION]
|
||||
>
|
||||
> The project is under active development, expect things to break every once in a while.
|
||||
### Themeable
|
||||
Easily create your own theme by tweaking a few numbers or choose from one of the [already available themes](docs/themes.md).
|
||||
|
||||
#### Manual
|
||||
Checkout the [releases page](https://github.com/glanceapp/glance/releases) for available binaries. You can place the binary inside `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). To specify a different path for the config file use the `--config` option:
|
||||

|
||||
|
||||
<br>
|
||||
|
||||
## Configuration
|
||||
Configuration is done through YAML files, to learn more about how the layout works, how to add more pages and how to configure widgets, visit the [configuration documentation](docs/configuration.md).
|
||||
|
||||
<details>
|
||||
<summary><strong>Preview example configuration file</strong></summary>
|
||||
<br>
|
||||
|
||||
```yaml
|
||||
pages:
|
||||
- name: Home
|
||||
columns:
|
||||
- size: small
|
||||
widgets:
|
||||
- type: calendar
|
||||
first-day-of-week: monday
|
||||
|
||||
- type: rss
|
||||
limit: 10
|
||||
collapse-after: 3
|
||||
cache: 12h
|
||||
feeds:
|
||||
- url: https://selfh.st/rss/
|
||||
title: selfh.st
|
||||
limit: 4
|
||||
- url: https://ciechanow.ski/atom.xml
|
||||
- url: https://www.joshwcomeau.com/rss.xml
|
||||
title: Josh Comeau
|
||||
- url: https://samwho.dev/rss.xml
|
||||
- url: https://ishadeed.com/feed.xml
|
||||
title: Ahmad Shadeed
|
||||
|
||||
- type: twitch-channels
|
||||
channels:
|
||||
- theprimeagen
|
||||
- j_blow
|
||||
- piratesoftware
|
||||
- cohhcarnage
|
||||
- christitustech
|
||||
- EJ_SA
|
||||
|
||||
- size: full
|
||||
widgets:
|
||||
- type: group
|
||||
widgets:
|
||||
- type: hacker-news
|
||||
- type: lobsters
|
||||
|
||||
- type: videos
|
||||
channels:
|
||||
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
|
||||
- UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
|
||||
- UCsBjURrPoezykLs9EqgamOA # Fireship
|
||||
- UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
|
||||
- UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
|
||||
|
||||
- type: group
|
||||
widgets:
|
||||
- type: reddit
|
||||
subreddit: technology
|
||||
show-thumbnails: true
|
||||
- type: reddit
|
||||
subreddit: selfhosted
|
||||
show-thumbnails: true
|
||||
|
||||
- size: small
|
||||
widgets:
|
||||
- type: weather
|
||||
location: London, United Kingdom
|
||||
units: metric
|
||||
hour-format: 12h
|
||||
|
||||
- type: markets
|
||||
markets:
|
||||
- symbol: SPY
|
||||
name: S&P 500
|
||||
- symbol: BTC-USD
|
||||
name: Bitcoin
|
||||
- symbol: NVDA
|
||||
name: NVIDIA
|
||||
- symbol: AAPL
|
||||
name: Apple
|
||||
- symbol: MSFT
|
||||
name: Microsoft
|
||||
|
||||
- type: releases
|
||||
cache: 1d
|
||||
repositories:
|
||||
- glanceapp/glance
|
||||
- go-gitea/gitea
|
||||
- immich-app/immich
|
||||
- syncthing/syncthing
|
||||
```
|
||||
/opt/glance/glance --config /etc/glance.yml
|
||||
</details>
|
||||
|
||||
<br>
|
||||
|
||||
## Installation
|
||||
|
||||
Choose one of the following methods:
|
||||
|
||||
<details>
|
||||
<summary><strong>Docker compose using provided directory structure (recommended)</strong></summary>
|
||||
<br>
|
||||
|
||||
Create a new directory called `glance` as well as the template files within it by running:
|
||||
|
||||
```bash
|
||||
mkdir glance && cd glance && curl -sL https://github.com/glanceapp/docker-compose-template/archive/refs/heads/main.tar.gz | tar -xzf - --strip-components 2
|
||||
```
|
||||
|
||||
#### Docker
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> Make sure you have a valid `glance.yml` file before running the container.
|
||||
*[click here to view the files that will be created](https://github.com/glanceapp/docker-compose-template/tree/main/root)*
|
||||
|
||||
```console
|
||||
docker run -d -p 8080:8080 \
|
||||
-v ./glance.yml:/app/glance.yml \
|
||||
-v /etc/timezone:/etc/timezone:ro \
|
||||
-v /etc/localtime:/etc/localtime:ro \
|
||||
glanceapp/glance
|
||||
Then, edit the following files as desired:
|
||||
* `docker-compose.yml` to configure the port, volumes and other containery things
|
||||
* `config/home.yml` to configure the widgets or layout of the home page
|
||||
* `config/glance.yml` if you want to change the theme or add more pages
|
||||
|
||||
<details>
|
||||
<summary>Other files you may want to edit</summary>
|
||||
|
||||
* `.env` to configure environment variables that will be available inside configuration files
|
||||
* `assets/user.css` to add custom CSS
|
||||
</details>
|
||||
|
||||
When ready, run:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Or if you prefer docker compose:
|
||||
If you encounter any issues, you can check the logs by running:
|
||||
|
||||
```bash
|
||||
docker compose logs
|
||||
```
|
||||
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Docker compose manual</strong></summary>
|
||||
<br>
|
||||
|
||||
Create a `docker-compose.yml` file with the following contents:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
glance:
|
||||
container_name: glance
|
||||
image: glanceapp/glance
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./glance.yml:/app/glance.yml
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- ./config:/app/config
|
||||
ports:
|
||||
- 8080:8080
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### Building from source
|
||||
Then, create a new directory called `config` and download the example starting [`glance.yml`](https://github.com/glanceapp/glance/blob/main/docs/glance.yml) file into it by running:
|
||||
|
||||
Requirements: [Go](https://go.dev/dl/) >= v1.22
|
||||
|
||||
To build:
|
||||
|
||||
```
|
||||
go build .
|
||||
```bash
|
||||
mkdir config && wget -O config/glance.yml https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
|
||||
```
|
||||
|
||||
To run:
|
||||
Feel free to edit the `glance.yml` file to your liking, and when ready run:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
If you encounter any issues, you can check the logs by running:
|
||||
|
||||
```bash
|
||||
docker logs glance
|
||||
```
|
||||
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Manual binary installation</strong></summary>
|
||||
<br>
|
||||
|
||||
Precompiled binaries are available for Linux, Windows and macOS (x86, x86_64, ARM and ARM64 architectures).
|
||||
|
||||
### Linux
|
||||
|
||||
Visit the [latest release page](https://github.com/glanceapp/glance/releases/latest) for available binaries. You can place the binary in `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). By default, when running the binary, it will look for a `glance.yml` file in the directory it's placed in. To specify a different path for the config file, use the `--config` option:
|
||||
|
||||
```bash
|
||||
/opt/glance/glance --config /etc/glance.yml
|
||||
```
|
||||
|
||||
To grab a starting template for the config file, run:
|
||||
|
||||
```bash
|
||||
wget https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Download and extract the executable from the [latest release](https://github.com/glanceapp/glance/releases/latest) (most likely the file called `glance-windows-amd64.zip` if you're on a 64-bit system) and place it in a folder of your choice. Then, create a new text file called `glance.yml` in the same folder and paste the content from [here](https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml) in it. You should then be able to run the executable and access the dashboard by visiting `http://localhost:8080` in your browser.
|
||||
|
||||
|
||||
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Other</strong></summary>
|
||||
<br>
|
||||
|
||||
Glance can also be installed through the following 3rd party channels:
|
||||
* [Proxmox VE Helper Script](https://community-scripts.github.io/ProxmoxVE/scripts?id=glance)
|
||||
* [NixOS package](https://search.nixos.org/packages?channel=unstable&show=glance)
|
||||
* [Coolify.io](https://coolify.io/docs/services/glance/)
|
||||
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<br>
|
||||
|
||||
## Common issues
|
||||
<details>
|
||||
<summary><strong>Requests timing out</strong></summary>
|
||||
|
||||
The most common cause of this is when using Pi-Hole, AdGuard Home or other ad-blocking DNS services, which by default have a fairly low rate limit. Depending on the number of widgets you have in a single page, this limit can very easily be exceeded. To fix this, increase the rate limit in the settings of your DNS service.
|
||||
|
||||
If using Podman, in some rare cases the timeout can be caused by an unknown issue, in which case it may be resolved by adding the following to the bottom of your `docker-compose.yml` file:
|
||||
```yaml
|
||||
networks:
|
||||
podman:
|
||||
external: true
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Broken layout for markets, bookmarks or other widgets</strong></summary>
|
||||
|
||||
This is almost always caused by the browser extension Dark Reader. To fix this, disable dark mode for the domain where Glance is hosted.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>cannot unmarshal !!map into []glance.page</strong></summary>
|
||||
|
||||
The most common cause of this is having a `pages` key in your `glance.yml` and then also having a `pages` key inside one of your included pages. To fix this, remove the `pages` key from the top of your included pages.
|
||||
|
||||
</details>
|
||||
|
||||
<br>
|
||||
|
||||
## FAQ
|
||||
<details>
|
||||
<summary><strong>Does the information on the page update automatically?</strong></summary>
|
||||
No, a page refresh is required to update the information. Some things do dynamically update where it makes sense, like the clock widget and the relative time showing how long ago something happened.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>How frequently do widgets update?</strong></summary>
|
||||
No requests are made periodically in the background, information is only fetched upon loading the page and then cached. The default cache lifetime is different for each widget and can be configured.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Can I create my own widgets?</strong></summary>
|
||||
|
||||
Yes, there are multiple ways to create custom widgets:
|
||||
* `iframe` widget - allows you to embed things from other websites
|
||||
* `html` widget - allows you to insert your own static HTML
|
||||
* `extension` widget - fetch HTML from a URL
|
||||
* `custom-api` widget - fetch JSON from a URL and render it using custom HTML
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Can I change the title of a widget?</strong></summary>
|
||||
|
||||
Yes, the title of all widgets can be changed by specifying the `title` property in the widget's configuration:
|
||||
|
||||
```yaml
|
||||
- type: rss
|
||||
title: My custom title
|
||||
|
||||
- type: markets
|
||||
title: My custom title
|
||||
|
||||
- type: videos
|
||||
title: My custom title
|
||||
|
||||
# and so on for all widgets...
|
||||
```
|
||||
</details>
|
||||
|
||||
<br>
|
||||
|
||||
## Feature requests
|
||||
|
||||
New feature suggestions are always welcome and will be considered, though please keep in mind that some of them may be out of scope for what the project is trying to achieve (or is reasonably capable of). If you have an idea for a new feature and would like to share it, you can do so [here](https://github.com/glanceapp/glance/issues/new?template=feature_request.yml).
|
||||
|
||||
Feature requests are tagged with one of the following:
|
||||
|
||||
* [Roadmap](https://github.com/glanceapp/glance/labels/roadmap) - will be implemented in a future release
|
||||
* [Backlog](https://github.com/glanceapp/glance/labels/backlog) - may be implemented in the future but needs further feedback or interest from the community
|
||||
* [Icebox](https://github.com/glanceapp/glance/labels/icebox) - no plans to implement as it doesn't currently align with the project's goals or capabilities, may be revised at a later date
|
||||
|
||||
<br>
|
||||
|
||||
## Building from source
|
||||
|
||||
Choose one of the following methods:
|
||||
|
||||
<details>
|
||||
<summary><strong>Build binary with Go</strong></summary>
|
||||
<br>
|
||||
|
||||
Requirements: [Go](https://go.dev/dl/) >= v1.23
|
||||
|
||||
To build the project for your current OS and architecture, run:
|
||||
|
||||
```bash
|
||||
go build -o build/glance .
|
||||
```
|
||||
|
||||
To build for a specific OS and architecture, run:
|
||||
|
||||
```bash
|
||||
GOOS=linux GOARCH=amd64 go build -o build/glance .
|
||||
```
|
||||
|
||||
[*click here for a full list of GOOS and GOARCH combinations*](https://go.dev/doc/install/source#:~:text=$GOOS%20and%20$GOARCH)
|
||||
|
||||
Alternatively, if you just want to run the app without creating a binary, like when you're testing out changes, you can run:
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Build project and Docker image with Docker</strong></summary>
|
||||
<br>
|
||||
|
||||
Requirements: [Docker](https://docs.docker.com/engine/install/)
|
||||
|
||||
To build the project and image using just Docker, run:
|
||||
|
||||
*(replace `owner` with your name or organization)*
|
||||
|
||||
```bash
|
||||
docker build -t owner/glance:latest .
|
||||
```
|
||||
|
||||
If you wish to push the image to a registry (by default Docker Hub), run:
|
||||
|
||||
```bash
|
||||
docker push owner/glance:latest
|
||||
```
|
||||
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<br>
|
||||
|
||||
## Contributing guidelines
|
||||
|
||||
* Before working on a new feature it's preferable to submit a feature request first and state that you'd like to implement it yourself
|
||||
* Please don't submit PRs for feature requests that are either in the roadmap<sup>[1]</sup>, backlog<sup>[2]</sup> or icebox<sup>[3]</sup>
|
||||
* Use `dev` for the base branch if you're adding new features or fixing bugs, otherwise use `main`
|
||||
* Avoid introducing new dependencies
|
||||
* Avoid making backwards-incompatible configuration changes
|
||||
* Avoid introducing new colors or hard-coding colors, use the standard `primary`, `positive` and `negative`
|
||||
* For icons, try to use [heroicons](https://heroicons.com/) where applicable
|
||||
* Provide a screenshot of the changes if UI related where possible
|
||||
* No `package.json`
|
||||
|
||||
<details>
|
||||
<summary><strong><sup>[1] [2] [3]</sup></strong></summary>
|
||||
|
||||
[1] The feature likely already has work put into it that may conflict with your implementation
|
||||
|
||||
[2] The demand, implementation or functionality for this feature is not yet clear
|
||||
|
||||
[3] No plans to add this feature for the time being
|
||||
|
||||
</details>
|
||||
|
||||
<br>
|
||||
|
||||
## Thank you
|
||||
|
||||
To all the people who were generous enough to [sponsor](https://github.com/sponsors/glanceapp) the project and to everyone who has contributed in any way, be it PRs, submitting issues, helping others in the discussions or Discord server, creating guides and tools or just mentioning Glance on social media. Your support is greatly appreciated and helps keep the project going.
|
||||
|
|
415
docs/custom-api.md
Normal file
|
@ -0,0 +1,415 @@
|
|||
[Jump to function definitions](#functions)
|
||||
|
||||
## Examples
|
||||
|
||||
The best way to get an idea of how the templates work would be with a bunch examples. Here are the most common use cases:
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "My Title",
|
||||
"content": "My Content",
|
||||
}
|
||||
```
|
||||
|
||||
To access the two fields in the JSON response, you would use the following:
|
||||
|
||||
```html
|
||||
<div>{{ .JSON.String "title" }}</div>
|
||||
<div>{{ .JSON.String "content" }}</div>
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>My Title</div>
|
||||
<div>My Content</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"author": "John Doe",
|
||||
"posts": [
|
||||
{
|
||||
"title": "My Title",
|
||||
"content": "My Content"
|
||||
},
|
||||
{
|
||||
"title": "My Title 2",
|
||||
"content": "My Content 2"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
To loop through the array of posts, you would use the following:
|
||||
|
||||
```html
|
||||
{{ range .JSON.Array "posts" }}
|
||||
<div>{{ .String "title" }}</div>
|
||||
<div>{{ .String "content" }}</div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>My Title</div>
|
||||
<div>My Content</div>
|
||||
<div>My Title 2</div>
|
||||
<div>My Content 2</div>
|
||||
```
|
||||
|
||||
Notice the missing `.JSON` when accessing the title and content, this is because the range function sets the context to the current array element.
|
||||
|
||||
If you want to access the top-level context within the range, you can use the following:
|
||||
|
||||
```html
|
||||
{{ range .JSON.Array "posts" }}
|
||||
<div>{{ .String "title" }}</div>
|
||||
<div>{{ .String "content" }}</div>
|
||||
<div>{{ $.JSON.String "author" }}</div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>My Title</div>
|
||||
<div>My Content</div>
|
||||
<div>John Doe</div>
|
||||
<div>My Title 2</div>
|
||||
<div>My Content 2</div>
|
||||
<div>John Doe</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
[
|
||||
"Apple",
|
||||
"Banana",
|
||||
"Cherry",
|
||||
"Watermelon"
|
||||
]
|
||||
```
|
||||
|
||||
Somewhat awkwardly, when the current context is a basic type that isn't an object, the way you specify its type is to use an empty string as the key. So, to loop through the array of strings, you would use the following:
|
||||
|
||||
```html
|
||||
{{ range .JSON.Array "" }}
|
||||
<div>{{ .String "" }}</div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>Apple</div>
|
||||
<div>Banana</div>
|
||||
<div>Cherry</div>
|
||||
<div>Watermelon</div>
|
||||
```
|
||||
|
||||
To access an item at a specific index, you could use the following:
|
||||
|
||||
```html
|
||||
<div>{{ .JSON.String "0" }}</div>
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>Apple</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"address": {
|
||||
"city": "New York",
|
||||
"state": "NY"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To easily access deeply nested objects, you can use the following dot notation:
|
||||
|
||||
```html
|
||||
<div>{{ .JSON.String "user.address.city" }}</div>
|
||||
<div>{{ .JSON.String "user.address.state" }}</div>
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>New York</div>
|
||||
<div>NY</div>
|
||||
```
|
||||
|
||||
Using indexes anywhere in the path is also supported:
|
||||
|
||||
```json
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"name": "John Doe"
|
||||
},
|
||||
{
|
||||
"name": "Jane Doe"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<div>{{ .JSON.String "users.0.name" }}</div>
|
||||
<div>{{ .JSON.String "users.1.name" }}</div>
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>John Doe</div>
|
||||
<div>Jane Doe</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"name": "John Doe",
|
||||
"age": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To check if a field exists, you can use the following:
|
||||
|
||||
```html
|
||||
{{ if .JSON.Exists "user.age" }}
|
||||
<div>{{ .JSON.Int "user.age" }}</div>
|
||||
{{ else }}
|
||||
<div>Age not provided</div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>30</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"price": 100,
|
||||
"discount": 10
|
||||
}
|
||||
```
|
||||
|
||||
Calculations can be performed on either ints or floats. If both numbers are ints, an int will be returned, otherwise a float will be returned. If you try to divide by zero, 0 will be returned. If you provide non-numeric values, `NaN` will be returned.
|
||||
|
||||
```html
|
||||
<div>{{ sub (.JSON.Int "price") (.JSON.Int "discount") }}</div>
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>90</div>
|
||||
```
|
||||
|
||||
Other operations include `add`, `mul`, and `div`.
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"posts": [
|
||||
{
|
||||
"title": "Exploring the Depths of Quantum Computing",
|
||||
"date": "2023-10-27T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"title": "A Beginner's Guide to Sustainable Living",
|
||||
"date": "2023-11-15T14:30:00+01:00"
|
||||
},
|
||||
{
|
||||
"title": "The Art of Baking Sourdough Bread",
|
||||
"date": "2023-12-03T08:45:22-08:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
To parse the date and display the relative time (e.g. 2h, 1d, etc), you would use the following:
|
||||
|
||||
```
|
||||
{{ range .JSON.Array "posts" }}
|
||||
<div>{{ .String "title" }}</div>
|
||||
<div {{ .String "date" | parseTime "rfc3339" | toRelativeTime }}></div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
The `parseTime` function takes two arguments: the layout of the date string and the date string itself. The layout can be one of the following: "RFC3339", "RFC3339Nano", "DateTime", "DateOnly", "TimeOnly" or a custom layout in Go's [date format](https://pkg.go.dev/time#pkg-constants).
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>Exploring the Depths of Quantum Computing</div>
|
||||
<div data-dynamic-relative-time="1698400800"></div>
|
||||
|
||||
<div>A Beginner's Guide to Sustainable Living</div>
|
||||
<div data-dynamic-relative-time="1700055000"></div>
|
||||
|
||||
<div>The Art of Baking Sourdough Bread</div>
|
||||
<div data-dynamic-relative-time="1701621922"></div>
|
||||
```
|
||||
|
||||
You don't have to worry about the internal implementation, this will then be dynamically populated by Glance on the client side to show the correct relative time.
|
||||
|
||||
The important thing to notice here is that the return value of `toRelativeTime` must be used as an attribute in an HTML tag, be it a `div`, `li`, `span`, etc.
|
||||
|
||||
<hr>
|
||||
|
||||
In some instances, you may want to know the status code of the response. This can be done using the following:
|
||||
|
||||
```html
|
||||
{{ if eq .Response.StatusCode 200 }}
|
||||
<p>Success!</p>
|
||||
{{ else }}
|
||||
<p>Failed to fetch data</p>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
You can also access the response headers:
|
||||
|
||||
```html
|
||||
<div>{{ .Response.Header.Get "Content-Type" }}</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{"name": "Steve", "age": 30}
|
||||
{"name": "Alex", "age": 25}
|
||||
{"name": "John", "age": 35}
|
||||
```
|
||||
|
||||
The above format is "[ndjson](https://docs.mulesoft.com/dataweave/latest/dataweave-formats-ndjson)" or "[JSON Lines](https://jsonlines.org/)", where each line is a separate JSON object. To parse this format, you must first disable the JSON validation check in your config, since by default the response is expected to be a single valid JSON object:
|
||||
|
||||
```yaml
|
||||
- type: custom-api
|
||||
skip-json-validation: true
|
||||
```
|
||||
|
||||
Then, to iterate over each object you can use `.JSONLines`:
|
||||
|
||||
```html
|
||||
{{ range .JSONLines }}
|
||||
<p>{{ .String "name" }} is {{ .Int "age" }} years old</p>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<p>Steve is 30 years old</p>
|
||||
<p>Alex is 25 years old</p>
|
||||
<p>John is 35 years old</p>
|
||||
```
|
||||
|
||||
For other ways of selecting data from a JSON Lines response, have a look at the docs for [tidwall/gjson](https://github.com/tidwall/gjson/tree/master?tab=readme-ov-file#json-lines). For example, to get an array of all names, you can use the following:
|
||||
|
||||
```html
|
||||
{{ range .JSON.Array "..#.name" }}
|
||||
<p>{{ .String "" }}</p>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<p>Steve</p>
|
||||
<p>Alex</p>
|
||||
<p>John</p>
|
||||
```
|
||||
|
||||
## Functions
|
||||
|
||||
The following functions are available on the `JSON` object:
|
||||
|
||||
- `String(key string) string`: Returns the value of the key as a string.
|
||||
- `Int(key string) int`: Returns the value of the key as an integer.
|
||||
- `Float(key string) float`: Returns the value of the key as a float.
|
||||
- `Bool(key string) bool`: Returns the value of the key as a boolean.
|
||||
- `Array(key string) []JSON`: Returns the value of the key as an array of `JSON` objects.
|
||||
- `Exists(key string) bool`: Returns true if the key exists in the JSON object.
|
||||
|
||||
The following helper functions provided by Glance are available:
|
||||
|
||||
- `toFloat(i int) float`: Converts an integer to a float.
|
||||
- `toInt(f float) int`: Converts a float to an integer.
|
||||
- `toRelativeTime(t time.Time) template.HTMLAttr`: Converts Time to a relative time such as 2h, 1d, etc which dynamically updates. **NOTE:** the value of this function should be used as an attribute in an HTML tag, e.g. `<span {{ toRelativeTime .Time }}></span>`.
|
||||
- `now() time.Time`: Returns the current time.
|
||||
- `offsetNow(offset string) time.Time`: Returns the current time with an offset. The offset can be positive or negative and must be in the format "3h" "-1h" or "2h30m10s".
|
||||
- `duration(str string) time.Duration`: Parses a string such as `1h`, `24h`, `5h30m`, etc into a `time.Duration`.
|
||||
- `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly".
|
||||
- `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`.
|
||||
- `add(a, b float) float`: Adds two numbers.
|
||||
- `sub(a, b float) float`: Subtracts two numbers.
|
||||
- `mul(a, b float) float`: Multiplies two numbers.
|
||||
- `div(a, b float) float`: Divides two numbers.
|
||||
- `formatApproxNumber(n int) string`: Formats a number to be more human-readable, e.g. 1000 -> 1k.
|
||||
- `formatNumber(n float|int) string`: Formats a number with commas, e.g. 1000 -> 1,000.
|
||||
- `trimPrefix(prefix string, str string) string`: Trims the prefix from a string.
|
||||
- `trimSuffix(suffix string, str string) string`: Trims the suffix from a string.
|
||||
- `trimSpace(str string) string`: Trims whitespace from a string on both ends.
|
||||
- `replaceAll(old string, new string, str string) string`: Replaces all occurrences of a string in a string.
|
||||
- `replaceMatches(pattern string, replacement string, str string) string`: Replaces all occurrences of a regular expression in a string.
|
||||
- `findMatch(pattern string, str string) string`: Finds the first match of a regular expression in a string.
|
||||
- `findSubmatch(pattern string, str string) string`: Finds the first submatch of a regular expression in a string.
|
||||
- `sortByString(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a string key in either ascending or descending order.
|
||||
- `sortByInt(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by an integer key in either ascending or descending order.
|
||||
- `sortByFloat(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a float key in either ascending or descending order.
|
||||
- `sortByTime(key string, layout string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a time key in either ascending or descending order. The format must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants).
|
||||
- `concat(strings ...string) string`: Concatenates multiple strings together.
|
||||
- `unique(key string, arr []JSON) []JSON`: Returns a unique array of JSON objects based on the given key.
|
||||
|
||||
The following helper functions provided by Go's `text/template` are available:
|
||||
|
||||
- `eq(a, b any) bool`: Compares two values for equality.
|
||||
- `ne(a, b any) bool`: Compares two values for inequality.
|
||||
- `lt(a, b any) bool`: Compares two values for less than.
|
||||
- `lte(a, b any) bool`: Compares two values for less than or equal to.
|
||||
- `gt(a, b any) bool`: Compares two values for greater than.
|
||||
- `gte(a, b any) bool`: Compares two values for greater than or equal to.
|
||||
- `and(a, b bool) bool`: Returns true if both values are true.
|
||||
- `or(a, b bool) bool`: Returns true if either value is true.
|
||||
- `not(a bool) bool`: Returns the opposite of the value.
|
||||
- `index(a any, b int) any`: Returns the value at the specified index of an array.
|
||||
- `len(a any) int`: Returns the length of an array.
|
||||
- `printf(format string, a ...any) string`: Returns a formatted string.
|
164
docs/extensions.md
Normal file
|
@ -0,0 +1,164 @@
|
|||
# Extensions
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> **This document as well as the extensions feature are a work in progress. The API may change in the future. You are responsible for maintaining your own extensions.**
|
||||
|
||||
## Overview
|
||||
|
||||
With the intention of requiring minimal knowledge in order to develop extensions, rather than being a convoluted protocol they are nothing more than an HTTP request to a server that returns a few special headers. The exchange between Glance and extensions can be seen in the following diagram:
|
||||
|
||||

|
||||
|
||||
If you know how to setup an HTTP server and a bit of HTML and CSS you're ready to start building your own extensions.
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> By default, the extension widget has a cache time of 30 minutes. To avoid having to restart Glance after every extension change you can set the cache time of the widget to 1 second:
|
||||
> ```yaml
|
||||
> - type: extension
|
||||
> url: http://localhost:8081
|
||||
> cache: 1s
|
||||
> ```
|
||||
|
||||
## Headers
|
||||
|
||||
### `Widget-Title`
|
||||
Used to specify the title of the widget. If not provided, the widget's title will be "Extension".
|
||||
|
||||
### `Widget-Title-URL`
|
||||
Used to specify the URL that will be opened when the widget's title is clicked. If the user has specified a `title-url` in their config, it will take precedence over this header.
|
||||
|
||||
### `Widget-Content-Type`
|
||||
Used to specify the content type that will be returned by the extension. If not provided, the content will be shown as plain text.
|
||||
|
||||
### `Widget-Content-Frameless`
|
||||
When set to `true`, the widget's content will be displayed without the default background or "frame".
|
||||
|
||||
## Content Types
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> Currently, `html` is the only supported content type. The long-term goal is to have generic content types such as `videos`, `forum-posts`, `markets`, `streams`, etc. which will be returned in JSON format and displayed by Glance using existing styles and functionality, allowing extension developers to achieve a native look while only focusing on providing data from their preferred source.
|
||||
|
||||
### `html`
|
||||
Displays the content as HTML. This requires the user to have the `allow-potentially-dangerous-html` property set to `true`, otherwise the content will be shown as plain text.
|
||||
|
||||
|
||||
#### Using existing classes and functionality
|
||||
Most of the features seen throughout Glance can easily be used in your custom HTML extensions. Below is an example of some of these features:
|
||||
|
||||
```html
|
||||
<p class="color-subdue">Text with subdued color</p>
|
||||
<p>Text with base color</p>
|
||||
<p class="color-highlight">Text with highlighted color</p>
|
||||
<p class="color-primary">Text with primary color</p>
|
||||
<p class="color-positive">Text with positive color</p>
|
||||
<p class="color-negative">Text with negative color</p>
|
||||
|
||||
<hr class="margin-block-15">
|
||||
|
||||
<p class="size-h1">Font size 1</p>
|
||||
<p class="size-h2">Font size 2</p>
|
||||
<p class="size-h3">Font size 3</p>
|
||||
<p class="size-h4">Font size 4</p>
|
||||
<p class="size-base">Font size base</p>
|
||||
<p class="size-h5">Font size 5</p>
|
||||
<p class="size-h6">Font size 6</p>
|
||||
|
||||
<hr class="margin-block-15">
|
||||
|
||||
<a class="visited-indicator" href="#notvisitedprobably">Link with visited indicator</a>
|
||||
|
||||
<hr class="margin-block-15">
|
||||
|
||||
<a class="color-primary-if-not-visited" href="#notvisitedprobably">Link with primary color if not visited</a>
|
||||
|
||||
<hr class="margin-block-15">
|
||||
|
||||
<p>Event happened <span data-dynamic-relative-time="<unix timestamp>"></span> ago</p>
|
||||
|
||||
<hr class="margin-block-15">
|
||||
|
||||
<ul class="list-horizontal-text">
|
||||
<li>horizontal</li>
|
||||
<li>list</li>
|
||||
<li>with</li>
|
||||
<li>multiple</li>
|
||||
<li>text</li>
|
||||
<li>items</li>
|
||||
</ul>
|
||||
|
||||
<hr class="margin-block-15">
|
||||
|
||||
<ul class="list list-gap-10 list-with-separator">
|
||||
<li>list</li>
|
||||
<li>with</li>
|
||||
<li>gap</li>
|
||||
<li>and</li>
|
||||
<li>horizontal</li>
|
||||
<li>lines</li>
|
||||
</ul>
|
||||
|
||||
<hr class="margin-block-15">
|
||||
|
||||
<ul class="list collapsible-container" data-collapse-after="3">
|
||||
<li>collapsible</li>
|
||||
<li>list</li>
|
||||
<li>with</li>
|
||||
<li>many</li>
|
||||
<li>items</li>
|
||||
<li>that</li>
|
||||
<li>will</li>
|
||||
<li>appear</li>
|
||||
<li>when</li>
|
||||
<li>you</li>
|
||||
<li>click</li>
|
||||
<li>the</li>
|
||||
<li>button</li>
|
||||
<li>below</li>
|
||||
</ul>
|
||||
|
||||
<hr class="margin-bottom-15">
|
||||
|
||||
<p class="margin-bottom-10">Lazily loaded image:</p>
|
||||
|
||||
<img src="https://picsum.photos/200" alt="" loading="lazy">
|
||||
|
||||
<hr class="margin-block-15">
|
||||
|
||||
<p class="margin-bottom-10">List of posts:</p>
|
||||
|
||||
<ul class="list list-gap-14 collapsible-container" data-collapse-after="5">
|
||||
<li>
|
||||
<a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li data-dynamic-relative-time="<unix timestamp>"></li>
|
||||
<li>3,321 points</li>
|
||||
<li>139 comments</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li data-dynamic-relative-time="<unix timestamp>"></li>
|
||||
<li>3,321 points</li>
|
||||
<li>139 comments</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li data-dynamic-relative-time="<unix timestamp>"></li>
|
||||
<li>3,321 points</li>
|
||||
<li>139 comments</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
All of that will result in the following:
|
||||
|
||||

|
||||
|
||||
**Class names or features may change, once again, you are responsible for maintaining your own extensions.**
|
105
docs/glance.yml
Normal file
|
@ -0,0 +1,105 @@
|
|||
pages:
|
||||
- name: Home
|
||||
# Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look
|
||||
# hide-desktop-navigation: true
|
||||
columns:
|
||||
- size: small
|
||||
widgets:
|
||||
- type: calendar
|
||||
first-day-of-week: monday
|
||||
|
||||
- type: rss
|
||||
limit: 10
|
||||
collapse-after: 3
|
||||
cache: 12h
|
||||
feeds:
|
||||
- url: https://selfh.st/rss/
|
||||
title: selfh.st
|
||||
limit: 4
|
||||
- url: https://ciechanow.ski/atom.xml
|
||||
- url: https://www.joshwcomeau.com/rss.xml
|
||||
title: Josh Comeau
|
||||
- url: https://samwho.dev/rss.xml
|
||||
- url: https://ishadeed.com/feed.xml
|
||||
title: Ahmad Shadeed
|
||||
|
||||
- type: twitch-channels
|
||||
channels:
|
||||
- theprimeagen
|
||||
- j_blow
|
||||
- piratesoftware
|
||||
- cohhcarnage
|
||||
- christitustech
|
||||
- EJ_SA
|
||||
|
||||
- size: full
|
||||
widgets:
|
||||
- type: group
|
||||
widgets:
|
||||
- type: hacker-news
|
||||
- type: lobsters
|
||||
|
||||
- type: videos
|
||||
channels:
|
||||
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
|
||||
- UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
|
||||
- UCsBjURrPoezykLs9EqgamOA # Fireship
|
||||
- UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
|
||||
- UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
|
||||
|
||||
- type: group
|
||||
widgets:
|
||||
- type: reddit
|
||||
subreddit: technology
|
||||
show-thumbnails: true
|
||||
- type: reddit
|
||||
subreddit: selfhosted
|
||||
show-thumbnails: true
|
||||
|
||||
- size: small
|
||||
widgets:
|
||||
- type: weather
|
||||
location: London, United Kingdom
|
||||
units: metric # alternatively "imperial"
|
||||
hour-format: 12h # alternatively "24h"
|
||||
# Optionally hide the location from being displayed in the widget
|
||||
# hide-location: true
|
||||
|
||||
- type: markets
|
||||
markets:
|
||||
- symbol: SPY
|
||||
name: S&P 500
|
||||
- symbol: BTC-USD
|
||||
name: Bitcoin
|
||||
- symbol: NVDA
|
||||
name: NVIDIA
|
||||
- symbol: AAPL
|
||||
name: Apple
|
||||
- symbol: MSFT
|
||||
name: Microsoft
|
||||
|
||||
- type: releases
|
||||
cache: 1d
|
||||
# Without authentication the Github API allows for up to 60 requests per hour. You can create a
|
||||
# read-only token from your Github account settings and use it here to increase the limit.
|
||||
# token: ...
|
||||
repositories:
|
||||
- glanceapp/glance
|
||||
- go-gitea/gitea
|
||||
- immich-app/immich
|
||||
- syncthing/syncthing
|
||||
|
||||
# Add more pages here:
|
||||
# - name: Your page name
|
||||
# columns:
|
||||
# - size: small
|
||||
# widgets:
|
||||
# # Add widgets here
|
||||
|
||||
# - size: full
|
||||
# widgets:
|
||||
# # Add widgets here
|
||||
|
||||
# - size: small
|
||||
# widgets:
|
||||
# # Add widgets here
|
BIN
docs/images/calendar-legacy-widget-preview.png
Normal file
After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
BIN
docs/images/change-detection-widget-preview.png
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
docs/images/clock-widget-preview.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
docs/images/custom-api-preview-1.png
Normal file
After Width: | Height: | Size: 7.9 KiB |
BIN
docs/images/custom-api-preview-2.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
docs/images/custom-api-preview-3.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
docs/images/dns-stats-widget-preview.png
Normal file
After Width: | Height: | Size: 9.7 KiB |
BIN
docs/images/docker-container-parent.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
docs/images/docker-container-parent2.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
docs/images/docker-containers-preview.png
Normal file
After Width: | Height: | Size: 33 KiB |
BIN
docs/images/docker-widget-preview.png
Normal file
After Width: | Height: | Size: 36 KiB |
BIN
docs/images/extension-html-reusing-existing-features-preview.png
Normal file
After Width: | Height: | Size: 133 KiB |
BIN
docs/images/extension-overview.png
Normal file
After Width: | Height: | Size: 87 KiB |
BIN
docs/images/gaming-page-preview.png
Normal file
After Width: | Height: | Size: 946 KiB |
BIN
docs/images/group-widget-preview.png
Normal file
After Width: | Height: | Size: 81 KiB |
BIN
docs/images/lobsters-widget-preview.png
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
docs/images/markets-page-preview.png
Normal file
After Width: | Height: | Size: 637 KiB |
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
BIN
docs/images/mobile-header-preview.png
Normal file
After Width: | Height: | Size: 128 KiB |
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 310 KiB |
BIN
docs/images/monitor-widget-compact-preview.png
Normal file
After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 362 KiB |
Before Width: | Height: | Size: 370 KiB After Width: | Height: | Size: 482 KiB |
BIN
docs/images/reddit-field-search.png
Normal file
After Width: | Height: | Size: 42 KiB |
BIN
docs/images/reddit-widget-vertical-list-thumbnails.png
Normal file
After Width: | Height: | Size: 82 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
BIN
docs/images/repository-preview.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
docs/images/rss-widget-detailed-list-preview.png
Normal file
After Width: | Height: | Size: 229 KiB |
BIN
docs/images/rss-widget-horizontal-cards-2-preview.png
Normal file
After Width: | Height: | Size: 531 KiB |
BIN
docs/images/search-widget-bangs-preview.png
Normal file
After Width: | Height: | Size: 5.5 KiB |
BIN
docs/images/search-widget-preview.png
Normal file
After Width: | Height: | Size: 4.8 KiB |
BIN
docs/images/server-stats-flame-icon.png
Normal file
After Width: | Height: | Size: 9.6 KiB |
BIN
docs/images/server-stats-preview.gif
Normal file
After Width: | Height: | Size: 200 KiB |
BIN
docs/images/split-column-widget-3-columns.png
Normal file
After Width: | Height: | Size: 146 KiB |
BIN
docs/images/split-column-widget-4-columns.png
Normal file
After Width: | Height: | Size: 181 KiB |
BIN
docs/images/split-column-widget-masonry.png
Normal file
After Width: | Height: | Size: 325 KiB |
BIN
docs/images/split-column-widget-preview.png
Normal file
After Width: | Height: | Size: 339 KiB |
BIN
docs/images/startpage-preview.png
Normal file
After Width: | Height: | Size: 47 KiB |
BIN
docs/images/themes/catppuccin-frappe.png
Normal file
After Width: | Height: | Size: 365 KiB |
BIN
docs/images/themes/catppuccin-latte.png
Normal file
After Width: | Height: | Size: 366 KiB |
BIN
docs/images/themes/catppuccin-macchiato.png
Normal file
After Width: | Height: | Size: 368 KiB |
Before Width: | Height: | Size: 341 KiB After Width: | Height: | Size: 341 KiB |
BIN
docs/images/themes/dracula.png
Normal file
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/images/themes/gruvbox.png
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
docs/images/themes/kanagawa-dark.png
Normal file
After Width: | Height: | Size: 549 KiB |
BIN
docs/images/videos-widget-grid-cards-preview.png
Normal file
After Width: | Height: | Size: 330 KiB |
BIN
docs/images/videos-widget-vertical-list-preview.png
Normal file
After Width: | Height: | Size: 77 KiB |
226
docs/preconfigured-pages.md
Normal file
|
@ -0,0 +1,226 @@
|
|||
# Preconfigured pages
|
||||
|
||||
Don't want to spend time configuring pages from scratch? No problem! Simply copy the config from the ones below.
|
||||
|
||||
Pull requests with your page configurations are welcome!
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> Pages must be placed under a top level `pages:` key, you can read more about that [here](configuration.md#pages).
|
||||
|
||||
## Startpage
|
||||
|
||||

|
||||
|
||||
<details>
|
||||
<summary>View config (requires Glance <code>v0.6.0</code> or higher)</summary>
|
||||
|
||||
```yaml
|
||||
- name: Startpage
|
||||
width: slim
|
||||
hide-desktop-navigation: true
|
||||
center-vertically: true
|
||||
columns:
|
||||
- size: full
|
||||
widgets:
|
||||
- type: search
|
||||
autofocus: true
|
||||
|
||||
- type: monitor
|
||||
cache: 1m
|
||||
title: Services
|
||||
sites:
|
||||
- title: Jellyfin
|
||||
url: https://yourdomain.com/
|
||||
icon: si:jellyfin
|
||||
- title: Gitea
|
||||
url: https://yourdomain.com/
|
||||
icon: si:gitea
|
||||
- title: qBittorrent # only for Linux ISOs, of course
|
||||
url: https://yourdomain.com/
|
||||
icon: si:qbittorrent
|
||||
- title: Immich
|
||||
url: https://yourdomain.com/
|
||||
icon: si:immich
|
||||
- title: AdGuard Home
|
||||
url: https://yourdomain.com/
|
||||
icon: si:adguard
|
||||
- title: Vaultwarden
|
||||
url: https://yourdomain.com/
|
||||
icon: si:vaultwarden
|
||||
|
||||
- type: bookmarks
|
||||
groups:
|
||||
- title: General
|
||||
links:
|
||||
- title: Gmail
|
||||
url: https://mail.google.com/mail/u/0/
|
||||
- title: Amazon
|
||||
url: https://www.amazon.com/
|
||||
- title: Github
|
||||
url: https://github.com/
|
||||
- title: Entertainment
|
||||
links:
|
||||
- title: YouTube
|
||||
url: https://www.youtube.com/
|
||||
- title: Prime Video
|
||||
url: https://www.primevideo.com/
|
||||
- title: Disney+
|
||||
url: https://www.disneyplus.com/
|
||||
- title: Social
|
||||
links:
|
||||
- title: Reddit
|
||||
url: https://www.reddit.com/
|
||||
- title: Twitter
|
||||
url: https://twitter.com/
|
||||
- title: Instagram
|
||||
url: https://www.instagram.com/
|
||||
```
|
||||
</details>
|
||||
|
||||
## Markets
|
||||
|
||||

|
||||
|
||||
<details>
|
||||
<summary>View config (requires Glance <code>v0.6.0</code> or higher)</summary>
|
||||
|
||||
```yaml
|
||||
- name: Markets
|
||||
columns:
|
||||
- size: small
|
||||
widgets:
|
||||
- type: markets
|
||||
title: Indices
|
||||
markets:
|
||||
- symbol: SPY
|
||||
name: S&P 500
|
||||
- symbol: DX-Y.NYB
|
||||
name: Dollar Index
|
||||
|
||||
- type: markets
|
||||
title: Crypto
|
||||
markets:
|
||||
- symbol: BTC-USD
|
||||
name: Bitcoin
|
||||
- symbol: ETH-USD
|
||||
name: Ethereum
|
||||
|
||||
- type: markets
|
||||
title: Stocks
|
||||
sort-by: absolute-change
|
||||
markets:
|
||||
- symbol: NVDA
|
||||
name: NVIDIA
|
||||
- symbol: AAPL
|
||||
name: Apple
|
||||
- symbol: MSFT
|
||||
name: Microsoft
|
||||
- symbol: GOOGL
|
||||
name: Google
|
||||
- symbol: AMD
|
||||
name: AMD
|
||||
- symbol: RDDT
|
||||
name: Reddit
|
||||
- symbol: AMZN
|
||||
name: Amazon
|
||||
- symbol: TSLA
|
||||
name: Tesla
|
||||
- symbol: INTC
|
||||
name: Intel
|
||||
- symbol: META
|
||||
name: Meta
|
||||
|
||||
- size: full
|
||||
widgets:
|
||||
- type: rss
|
||||
title: News
|
||||
style: horizontal-cards
|
||||
feeds:
|
||||
- url: https://feeds.bloomberg.com/markets/news.rss
|
||||
title: Bloomberg
|
||||
- url: https://moxie.foxbusiness.com/google-publisher/markets.xml
|
||||
title: Fox Business
|
||||
- url: https://moxie.foxbusiness.com/google-publisher/technology.xml
|
||||
title: Fox Business
|
||||
|
||||
- type: group
|
||||
widgets:
|
||||
- type: reddit
|
||||
show-thumbnails: true
|
||||
subreddit: technology
|
||||
- type: reddit
|
||||
show-thumbnails: true
|
||||
subreddit: wallstreetbets
|
||||
|
||||
- type: videos
|
||||
style: grid-cards
|
||||
collapse-after-rows: 3
|
||||
channels:
|
||||
- UCvSXMi2LebwJEM1s4bz5IBA # New Money
|
||||
- UCV6KDgJskWaEckne5aPA0aQ # Graham Stephan
|
||||
- UCAzhpt9DmG6PnHXjmJTvRGQ # Federal Reserve
|
||||
|
||||
- size: small
|
||||
widgets:
|
||||
- type: rss
|
||||
title: News
|
||||
limit: 30
|
||||
collapse-after: 13
|
||||
feeds:
|
||||
- url: https://www.ft.com/technology?format=rss
|
||||
title: Financial Times
|
||||
- url: https://feeds.a.dj.com/rss/RSSMarketsMain.xml
|
||||
title: Wall Street Journal
|
||||
```
|
||||
</details>
|
||||
|
||||
## Gaming
|
||||
|
||||

|
||||
|
||||
<details>
|
||||
<summary>View config (requires Glance <code>v0.6.0</code> or higher)</summary>
|
||||
|
||||
```yaml
|
||||
- name: Gaming
|
||||
columns:
|
||||
- size: small
|
||||
widgets:
|
||||
- type: twitch-top-games
|
||||
limit: 20
|
||||
collapse-after: 13
|
||||
exclude:
|
||||
- just-chatting
|
||||
- pools-hot-tubs-and-beaches
|
||||
- music
|
||||
- art
|
||||
- asmr
|
||||
|
||||
- size: full
|
||||
widgets:
|
||||
- type: group
|
||||
widgets:
|
||||
- type: reddit
|
||||
show-thumbnails: true
|
||||
subreddit: pcgaming
|
||||
- type: reddit
|
||||
subreddit: games
|
||||
|
||||
- type: videos
|
||||
style: grid-cards
|
||||
collapse-after-rows: 3
|
||||
channels:
|
||||
- UCNvzD7Z-g64bPXxGzaQaa4g # gameranx
|
||||
- UCZ7AeeVbyslLM_8-nVy2B8Q # Skill Up
|
||||
- UCHDxYLv8iovIbhrfl16CNyg # GameLinked
|
||||
- UC9PBzalIcEQCsiIkq36PyUA # Digital Foundry
|
||||
|
||||
- size: small
|
||||
widgets:
|
||||
- type: reddit
|
||||
subreddit: gamingnews
|
||||
limit: 7
|
||||
style: vertical-cards
|
||||
```
|
||||
</details>
|
|
@ -11,8 +11,30 @@ theme:
|
|||
contrast-multiplier: 1.1
|
||||
```
|
||||
|
||||
### Catppuccin
|
||||

|
||||
### Catppuccin Frappe
|
||||

|
||||
```yaml
|
||||
theme:
|
||||
background-color: 229 19 23
|
||||
contrast-multiplier: 1.2
|
||||
primary-color: 222 74 74
|
||||
positive-color: 96 44 68
|
||||
negative-color: 359 68 71
|
||||
```
|
||||
|
||||
### Catppuccin Macchiato
|
||||

|
||||
```yaml
|
||||
theme:
|
||||
background-color: 232 23 18
|
||||
contrast-multiplier: 1.2
|
||||
primary-color: 220 83 75
|
||||
positive-color: 105 48 72
|
||||
negative-color: 351 74 73
|
||||
```
|
||||
|
||||
### Catppuccin Mocha
|
||||

|
||||
```yaml
|
||||
theme:
|
||||
background-color: 240 21 15
|
||||
|
@ -31,6 +53,26 @@ theme:
|
|||
primary-color: 97 13 80
|
||||
```
|
||||
|
||||
### Gruvbox Dark
|
||||

|
||||
```yaml
|
||||
theme:
|
||||
background-color: 0 0 16
|
||||
primary-color: 43 59 81
|
||||
positive-color: 61 66 44
|
||||
negative-color: 6 96 59
|
||||
```
|
||||
|
||||
### Kanagawa Dark
|
||||

|
||||
```yaml
|
||||
theme:
|
||||
background-color: 240 13 14
|
||||
primary-color: 51 33 68
|
||||
negative-color: 358 100 68
|
||||
contrast-multiplier: 1.2
|
||||
```
|
||||
|
||||
### Tucan
|
||||

|
||||
```yaml
|
||||
|
@ -40,8 +82,31 @@ theme:
|
|||
negative-color: 209 88 54
|
||||
```
|
||||
|
||||
### Dracula
|
||||

|
||||
```yaml
|
||||
theme:
|
||||
background-color: 231 15 21
|
||||
primary-color: 265 89 79
|
||||
contrast-multiplier: 1.2
|
||||
positive-color: 135 94 66
|
||||
negative-color: 0 100 67
|
||||
```
|
||||
|
||||
## Light
|
||||
|
||||
### Catppuccin Latte
|
||||

|
||||
```yaml
|
||||
theme:
|
||||
light: true
|
||||
background-color: 220 23 95
|
||||
contrast-multiplier: 1.0
|
||||
primary-color: 220 91 54
|
||||
positive-color: 109 58 40
|
||||
negative-color: 347 87 44
|
||||
```
|
||||
|
||||
### Peachy
|
||||

|
||||
```yaml
|
||||
|
|
57
docs/v0.7.0-upgrade.md
Normal file
|
@ -0,0 +1,57 @@
|
|||
## Upgrading to v0.7.0 from previous versions
|
||||
|
||||
In essence, the `glance.yml` file has been moved from the root of the project to a `config/` directory and you now need to mount that directory to `/app/config` in the container.
|
||||
|
||||
### Before
|
||||
|
||||
Versions before v0.7.0 used a `docker-compose.yml` that looked like the following:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
glance:
|
||||
image: glanceapp/glance
|
||||
volumes:
|
||||
- ./glance.yml:/app/glance.yml
|
||||
ports:
|
||||
- 8080:8080
|
||||
```
|
||||
|
||||
And expected you to have the following directory structure:
|
||||
|
||||
```plaintext
|
||||
glance/
|
||||
docker-compose.yml
|
||||
glance.yml
|
||||
```
|
||||
|
||||
### After
|
||||
|
||||
With the release of v0.7.0, the recommended `docker-compose.yml` looks like the following:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
glance:
|
||||
container_name: glance
|
||||
image: glanceapp/glance
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
ports:
|
||||
- 8080:8080
|
||||
```
|
||||
|
||||
And expects you to have the following directory structure:
|
||||
|
||||
```plaintext
|
||||
glance/
|
||||
docker-compose.yml
|
||||
config/
|
||||
glance.yml
|
||||
```
|
||||
|
||||
## Why this change was necessary
|
||||
|
||||
1. Mounting a file rather than a directory is not common practice and leads to some issues, such as creating a directory if the file is not present, which has tripped up multiple people and caused unnecessary confusion
|
||||
2. v0.7.0 added automatic reloads when the configuration file changes, which based on testing didn't work when mounting a single file
|
||||
3. v0.7.0 added the ability to include config files, so you'd have to make this change anyways if you wanted to take advantage of that feature
|
||||
|
||||
Taking all of these into account, it felt like the right time to implement the change.
|
27
go.mod
|
@ -1,19 +1,32 @@
|
|||
module github.com/glanceapp/glance
|
||||
|
||||
go 1.22.0
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
golang.org/x/text v0.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/mmcdole/gofeed v1.3.0
|
||||
github.com/shirou/gopsutil/v4 v4.25.3
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
golang.org/x/text v0.24.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.10.2 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/ebitengine/purego v0.8.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
|
||||
github.com/mmcdole/goxpp v1.1.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
golang.org/x/net v0.4.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
)
|
||||
|
|
138
go.sum
|
@ -1,34 +1,148 @@
|
|||
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
||||
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
||||
github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
|
||||
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
|
||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
||||
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
|
||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
|
||||
github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
|
||||
github.com/mmcdole/goxpp v1.1.1/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
|
||||
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
|
||||
github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
|
||||
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
|
||||
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
|
||||
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
|
||||
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var _publicFS embed.FS
|
||||
|
||||
//go:embed templates
|
||||
var _templateFS embed.FS
|
||||
|
||||
var PublicFS, _ = fs.Sub(_publicFS, "static")
|
||||
var TemplateFS, _ = fs.Sub(_templateFS, "templates")
|
|
@ -1,163 +0,0 @@
|
|||
function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
||||
let debounceTimeout;
|
||||
let timesDebounced = 0;
|
||||
|
||||
return function () {
|
||||
if (timesDebounced == maxDebounceTimes) {
|
||||
clearTimeout(debounceTimeout);
|
||||
timesDebounced = 0;
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(debounceTimeout);
|
||||
timesDebounced++;
|
||||
|
||||
debounceTimeout = setTimeout(() => {
|
||||
timesDebounced = 0;
|
||||
callback();
|
||||
}, debounceDelay);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
async function fetchPageContents (pageSlug) {
|
||||
// TODO: handle non 200 status codes/time outs
|
||||
// TODO: add retries
|
||||
const response = await fetch(`/api/pages/${pageSlug}/content/`);
|
||||
const content = await response.text();
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function setupCarousels() {
|
||||
const carouselElements = document.getElementsByClassName("carousel-container");
|
||||
|
||||
for (let i = 0; i < carouselElements.length; i++) {
|
||||
const carousel = carouselElements[i];
|
||||
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
|
||||
|
||||
const determineSideCutoffs = () => {
|
||||
if (itemsContainer.scrollLeft != 0) {
|
||||
carousel.classList.add("show-left-cutoff");
|
||||
} else {
|
||||
carousel.classList.remove("show-left-cutoff");
|
||||
}
|
||||
|
||||
if (Math.ceil(itemsContainer.scrollLeft) + itemsContainer.clientWidth < itemsContainer.scrollWidth) {
|
||||
carousel.classList.add("show-right-cutoff");
|
||||
} else {
|
||||
carousel.classList.remove("show-right-cutoff");
|
||||
}
|
||||
}
|
||||
|
||||
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
|
||||
|
||||
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
|
||||
document.addEventListener("resize", determineSideCutoffsRateLimited);
|
||||
|
||||
determineSideCutoffs();
|
||||
}
|
||||
}
|
||||
|
||||
const minuteInSeconds = 60;
|
||||
const hourInSeconds = minuteInSeconds * 60;
|
||||
const dayInSeconds = hourInSeconds * 24;
|
||||
const monthInSeconds = dayInSeconds * 30;
|
||||
const yearInSeconds = monthInSeconds * 12;
|
||||
|
||||
function relativeTimeSince(timestamp) {
|
||||
const delta = Math.round((Date.now() / 1000) - timestamp);
|
||||
|
||||
if (delta < minuteInSeconds) {
|
||||
return "1m";
|
||||
}
|
||||
if (delta < hourInSeconds) {
|
||||
return Math.floor(delta / minuteInSeconds) + "m";
|
||||
}
|
||||
if (delta < dayInSeconds) {
|
||||
return Math.floor(delta / hourInSeconds) + "h";
|
||||
}
|
||||
if (delta < monthInSeconds) {
|
||||
return Math.floor(delta / dayInSeconds) + "d";
|
||||
}
|
||||
if (delta < yearInSeconds) {
|
||||
return Math.floor(delta / monthInSeconds) + "mo";
|
||||
}
|
||||
|
||||
return Math.floor(delta / yearInSeconds) + "y";
|
||||
}
|
||||
|
||||
function updateRelativeTimeForElements(elements)
|
||||
{
|
||||
for (let i = 0; i < elements.length; i++)
|
||||
{
|
||||
const element = elements[i];
|
||||
const timestamp = element.dataset.dynamicRelativeTime;
|
||||
|
||||
if (timestamp === undefined)
|
||||
continue
|
||||
|
||||
element.innerText = relativeTimeSince(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
function setupDynamicRelativeTime() {
|
||||
const elements = document.querySelectorAll("[data-dynamic-relative-time]");
|
||||
const updateInterval = 60 * 1000;
|
||||
let lastUpdateTime = Date.now();
|
||||
|
||||
const updateElementsAndTimestamp = () => {
|
||||
updateRelativeTimeForElements(elements);
|
||||
lastUpdateTime = Date.now();
|
||||
};
|
||||
|
||||
const scheduleRepeatingUpdate = () => setInterval(updateElementsAndTimestamp, updateInterval);
|
||||
|
||||
if (document.hidden === undefined) {
|
||||
scheduleRepeatingUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
let timeout = scheduleRepeatingUpdate();
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
clearTimeout(timeout);
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = Date.now() - lastUpdateTime;
|
||||
|
||||
if (delta >= updateInterval) {
|
||||
updateElementsAndTimestamp();
|
||||
timeout = scheduleRepeatingUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
updateElementsAndTimestamp();
|
||||
timeout = scheduleRepeatingUpdate();
|
||||
}, updateInterval - delta);
|
||||
});
|
||||
}
|
||||
|
||||
async function setupPage() {
|
||||
const pageElement = document.getElementById("page");
|
||||
const pageContents = await fetchPageContents(pageData.slug);
|
||||
|
||||
pageElement.innerHTML = pageContents;
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.classList.add("animate-element-transition");
|
||||
}, 150);
|
||||
|
||||
setupCarousels();
|
||||
setupDynamicRelativeTime();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", setupPage);
|
||||
} else {
|
||||
setupPage();
|
||||
}
|
|
@ -1,112 +0,0 @@
|
|||
package assets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
var (
|
||||
PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
|
||||
PageContentTemplate = compileTemplate("content.html")
|
||||
CalendarTemplate = compileTemplate("calendar.html", "widget-base.html")
|
||||
BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html")
|
||||
IFrameTemplate = compileTemplate("iframe.html", "widget-base.html")
|
||||
WeatherTemplate = compileTemplate("weather.html", "widget-base.html")
|
||||
ForumPostsTemplate = compileTemplate("forum-posts.html", "widget-base.html")
|
||||
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
|
||||
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
|
||||
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
|
||||
VideosTemplate = compileTemplate("videos.html", "widget-base.html")
|
||||
StocksTemplate = compileTemplate("stocks.html", "widget-base.html")
|
||||
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
|
||||
RSSCardsTemplate = compileTemplate("rss-cards.html", "widget-base.html")
|
||||
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
|
||||
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
|
||||
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
|
||||
)
|
||||
|
||||
var globalTemplateFunctions = template.FuncMap{
|
||||
"relativeTime": relativeTimeSince,
|
||||
"formatViewerCount": formatViewerCount,
|
||||
"formatNumber": intl.Sprint,
|
||||
"absInt": func(i int) int {
|
||||
return int(math.Abs(float64(i)))
|
||||
},
|
||||
"formatPrice": func(price float64) string {
|
||||
return intl.Sprintf("%.2f", price)
|
||||
},
|
||||
"formatTime": func(t time.Time) string {
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
},
|
||||
"shouldCollapse": func(i int, collapseAfter int) bool {
|
||||
if collapseAfter < -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
return i >= collapseAfter
|
||||
},
|
||||
"itemAnimationDelay": func(i int, collapseAfter int) string {
|
||||
return fmt.Sprintf("%dms", (i-collapseAfter)*30)
|
||||
},
|
||||
"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
|
||||
return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix()))
|
||||
},
|
||||
}
|
||||
|
||||
func compileTemplate(primary string, dependencies ...string) *template.Template {
|
||||
t, err := template.New(primary).
|
||||
Funcs(globalTemplateFunctions).
|
||||
ParseFS(TemplateFS, append([]string{primary}, dependencies...)...)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
var intl = message.NewPrinter(language.English)
|
||||
|
||||
func formatViewerCount(count int) string {
|
||||
if count < 1_000 {
|
||||
return strconv.Itoa(count)
|
||||
}
|
||||
|
||||
if count < 10_000 {
|
||||
return fmt.Sprintf("%.1fk", float64(count)/1_000)
|
||||
}
|
||||
|
||||
if count < 1_000_000 {
|
||||
return fmt.Sprintf("%dk", count/1_000)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%.1fm", float64(count)/1_000_000)
|
||||
}
|
||||
|
||||
func relativeTimeSince(t time.Time) string {
|
||||
delta := time.Since(t)
|
||||
|
||||
if delta < time.Minute {
|
||||
return "1m"
|
||||
}
|
||||
if delta < time.Hour {
|
||||
return fmt.Sprintf("%dm", delta/time.Minute)
|
||||
}
|
||||
if delta < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh", delta/time.Hour)
|
||||
}
|
||||
if delta < 30*24*time.Hour {
|
||||
return fmt.Sprintf("%dd", delta/(24*time.Hour))
|
||||
}
|
||||
if delta < 12*30*24*time.Hour {
|
||||
return fmt.Sprintf("%dmo", delta/(30*24*time.Hour))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%dy", delta/(365*24*time.Hour))
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-24 list-with-separator">
|
||||
{{ range .Groups }}
|
||||
<li class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
|
||||
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
|
||||
<ul class="list list-gap-2">
|
||||
{{ range .Links }}
|
||||
<li><a href="{{ .URL }}" class="bookmarks-link color-highlight size-h4" target="_blank" rel="noreferrer">{{ .Title }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
|
@ -1,27 +0,0 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div>
|
||||
<ul class="list-horizontal-text color-highlight size-h4">
|
||||
<li>Week {{ .Calendar.CurrentWeekNumber }}</li>
|
||||
<li>{{ .Calendar.CurrentYear }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
|
||||
<div class="calendar-day">Mo</div>
|
||||
<div class="calendar-day">Tu</div>
|
||||
<div class="calendar-day">We</div>
|
||||
<div class="calendar-day">Th</div>
|
||||
<div class="calendar-day">Fr</div>
|
||||
<div class="calendar-day">Sa</div>
|
||||
<div class="calendar-day">Su</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap">
|
||||
{{ range .Calendar.Days }}
|
||||
<div class="calendar-day{{ if eq . $.Calendar.CurrentDay }} calendar-day-today{{ end }}">{{ . }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
|
@ -1,17 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html {{ block "document-root-attrs" . }}{{ end }} lang="en" id="top">
|
||||
<head>
|
||||
{{ block "document-head-before" . }}{{ end }}
|
||||
<title>{{ block "document-title" . }}{{ end }}</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||
<link rel="stylesheet" href="/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}">
|
||||
<script async src="/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script>
|
||||
{{ block "document-head-after" . }}{{ end }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "document-body" . }}
|
||||
</body>
|
||||
</html>
|
|
@ -1,22 +0,0 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 list-collapsible">
|
||||
{{ range $i, $post := .Posts }}
|
||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||
<a href="{{ $post.DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li title="{{ $post.TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs $post.TimePosted }}>{{ $post.TimePosted | relativeTime }}</li>
|
||||
<li>{{ $post.Score | formatNumber }} points</li>
|
||||
<li>{{ $post.CommentCount | formatNumber }} comments</li>
|
||||
{{ if $post.HasTargetUrl }}
|
||||
<li class="shrink min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ $post.TargetUrlDomain }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .Posts) $.CollapseAfter }}
|
||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
||||
{{ end }}
|
||||
{{ end }}
|
|
@ -1,39 +0,0 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-20 list-with-separator">
|
||||
{{ range .Sites }}
|
||||
<li class="monitor-site flex items-center gap-15">
|
||||
{{ if .IconUrl }}
|
||||
<img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy">
|
||||
{{ end }}
|
||||
<div>
|
||||
<a class="size-h3 color-highlight" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
{{ if not .Status.Error }}
|
||||
<li>{{ .StatusText }}</li>
|
||||
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
|
||||
{{ else if .Status.TimedOut }}
|
||||
<li class="color-negative">Timed Out</li>
|
||||
{{ else }}
|
||||
<li class="color-negative" title="{{ .Status.Error }}">ERROR</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{ if eq .StatusStyle "good" }}
|
||||
<div class="monitor-site-status-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
|
||||
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="monitor-site-status-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
|
||||
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
{{ end }}
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
|
@ -1,14 +0,0 @@
|
|||
<style>
|
||||
:root {
|
||||
{{ if .App.Config.Theme.BackgroundColor }}
|
||||
--bgh: {{ .App.Config.Theme.BackgroundColor.Hue }};
|
||||
--bgs: {{ .App.Config.Theme.BackgroundColor.Saturation }}%;
|
||||
--bgl: {{ .App.Config.Theme.BackgroundColor.Lightness }}%;
|
||||
{{ end }}
|
||||
{{ if ne 0.0 .App.Config.Theme.ContrastMultiplier }}--cm: {{ .App.Config.Theme.ContrastMultiplier }};{{ end }}
|
||||
{{ if ne 0.0 .App.Config.Theme.TextSaturationMultiplier }}--tsm: {{ .App.Config.Theme.TextSaturationMultiplier }};{{ end }}
|
||||
{{ if .App.Config.Theme.PrimaryColor }}--color-primary: {{ .App.Config.Theme.PrimaryColor.AsCSSValue }};{{ end }}
|
||||
{{ if .App.Config.Theme.PositiveColor }}--color-positive: {{ .App.Config.Theme.PositiveColor.AsCSSValue }};{{ end }}
|
||||
{{ if .App.Config.Theme.NegativeColor }}--color-negative: {{ .App.Config.Theme.NegativeColor.AsCSSValue }};{{ end }}
|
||||
}
|
||||
</style>
|
|
@ -1,64 +0,0 @@
|
|||
{{ template "document.html" . }}
|
||||
|
||||
{{ define "document-title" }}{{ .Page.Title }} - Glance{{ end }}
|
||||
|
||||
{{ define "document-head-before" }}
|
||||
<script>
|
||||
const pageData = {
|
||||
slug: "{{ .Page.Slug }}",
|
||||
};
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
{{ define "document-root-attrs" }}{{ if .App.Config.Theme.Light }}class="light-scheme"{{ end }}{{ end }}
|
||||
{{ define "document-head-after" }}{{ template "page-style-overrides.gotmpl" . }}{{ end }}
|
||||
|
||||
{{ define "navigation-links" }}
|
||||
{{ range .App.Config.Pages }}
|
||||
<a href="/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ define "document-body" }}
|
||||
<div class="header-container content-bounds">
|
||||
<div class="header flex padding-inline-widget widget-content-frame">
|
||||
<!-- TODO: Replace G with actual logo, first need an actual logo -->
|
||||
<div class="logo">G</div>
|
||||
<div class="nav flex flex-grow">
|
||||
{{ template "navigation-links" . }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-navigation">
|
||||
<div class="mobile-navigation-icons">
|
||||
<a class="mobile-navigation-label" href="#top">↑</a>
|
||||
{{ range $i, $column := .Page.Columns }}
|
||||
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
|
||||
{{ end }}
|
||||
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
|
||||
</div>
|
||||
<div class="mobile-navigation-page-links">
|
||||
{{ template "navigation-links" . }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-bounds">
|
||||
<div class="page" id="page">
|
||||
<div class="page-loading-container">
|
||||
<!-- TODO: add a bigger/better loading indicator -->
|
||||
<div class="loading-icon"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer flex items-center flex-column">
|
||||
<div>
|
||||
<span class="size-h3">Glance</span> ({{ .App.Version }})
|
||||
</div>
|
||||
<ul class="list-horizontal-text margin-top-5 size-h5 color-primary">
|
||||
<li><a href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a></li>
|
||||
<li><a href="https://github.com/glanceapp/glance/discussions" target="_blank" rel="noreferrer">Submit feedback</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{{ end }}
|
|
@ -1,21 +0,0 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 list-collapsible">
|
||||
{{ range $i, $release := .Releases }}
|
||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li title="{{ $release.TimeReleased | formatTime }}" {{ dynamicRelativeTimeAttrs $release.TimeReleased }}>{{ $release.TimeReleased | relativeTime }}</li>
|
||||
<li>{{ $release.Version }}</li>
|
||||
{{ if gt $release.Downvotes 3 }}
|
||||
<li>{{ $release.Downvotes | formatNumber }} ⚠</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .Releases) $.CollapseAfter }}
|
||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
||||
{{ end }}
|
||||
{{ end }}
|
|
@ -1,20 +0,0 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 list-collapsible">
|
||||
{{ range $i, $item := .Items }}
|
||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||
<a class="size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li title="{{ $item.PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs $item.PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
|
||||
{{ if gt (len $.FeedRequests) 1 }}
|
||||
<li><a href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .Items) $.CollapseAfter }}
|
||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
||||
{{ end }}
|
||||
{{ end }}
|
|
@ -1,23 +0,0 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-20 list-with-separator">
|
||||
{{ range .Stocks }}
|
||||
<li class="flex items-center gap-15">
|
||||
<div class="shrink min-width-0">
|
||||
<div class="color-highlight size-h3 text-truncate">{{ .Symbol }}</div>
|
||||
<div class="text-truncate">{{ .Name }}</div>
|
||||
</div>
|
||||
|
||||
<svg class="stock-chart shrink-0" viewBox="0 0 100 50">
|
||||
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
|
||||
</svg>
|
||||
|
||||
<div class="stock-values shrink-0">
|
||||
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
|
||||
<div class="text-right">${{ .Price | formatPrice }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
|
@ -1,40 +0,0 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 list-collapsible">
|
||||
{{ range $i, $channel := .Channels }}
|
||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||
<div class="{{ if $channel.IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-container">
|
||||
<div class="twitch-channel-avatar-container">
|
||||
{{ if $channel.Exists }}
|
||||
<img class="twitch-channel-avatar thumbnail" src="{{ $channel.AvatarUrl }}" alt="" loading="lazy">
|
||||
{{ else }}
|
||||
<svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="shrink min-width-0">
|
||||
<a href="https://twitch.tv/{{ $channel.Login }}" class="size-h3{{ if $channel.IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ $channel.Name }}</a>
|
||||
{{ if $channel.Exists }}
|
||||
{{ if $channel.IsLive }}
|
||||
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ $channel.CategorySlug }}" target="_blank" rel="noreferrer">{{ $channel.Category }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li title="{{ $channel.LiveSince | formatTime }}" {{ dynamicRelativeTimeAttrs $channel.LiveSince }}>{{ $channel.LiveSince | relativeTime }}</li>
|
||||
<li>{{ $channel.ViewersCount | formatViewerCount }} viewers</li>
|
||||
</ul>
|
||||
{{ else }}
|
||||
<div>Offline</div>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<div class="color-negative">Not found</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .Channels) $.CollapseAfter }}
|
||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
||||
{{ end }}
|
||||
{{ end }}
|
|
@ -1,35 +0,0 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 list-collapsible">
|
||||
{{ range $i, $category := .Categories }}
|
||||
{{ $shouldCollapseItem := shouldCollapse $i $.CollapseAfter }}
|
||||
<li class="twitch-category thumbnail-container{{ if $shouldCollapseItem }} list-collapsible-item{{ end }}" {{ if $shouldCollapseItem }}style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
||||
<div class="flex gap-10 items-center">
|
||||
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ $category.AvatarUrl }}" alt="">
|
||||
<div class="shrink min-width-0">
|
||||
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ $category.Slug }}" target="_blank" rel="noreferrer">{{ $category.Name }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li>{{ $category.ViewersCount | formatViewerCount }} viewers</li>
|
||||
{{ if $category.IsNew }}
|
||||
<li class="color-primary">NEW</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
<ul class="list-horizontal-text flex-nowrap">
|
||||
{{ range $i, $tag := $category.Tags }}
|
||||
{{ if eq $i 0 }}
|
||||
<li class="shrink-0">{{ $tag.Name }}</li>
|
||||
{{ else }}
|
||||
<li class="text-truncate shrink min-width-0">{{ $tag.Name }}</li>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ if gt (len .Categories) $.CollapseAfter }}
|
||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
||||
{{ end }}
|
||||
{{ end }}
|
|
@ -1,24 +0,0 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="carousel-container">
|
||||
<div class="videos cards-horizontal carousel-items-container">
|
||||
{{ range .Videos }}
|
||||
<div class="card widget-content-frame thumbnail-container">
|
||||
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
||||
<div class="margin-top-10 margin-bottom-widget flex flex-column flex-grow padding-inline-widget">
|
||||
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
||||
<li class="shrink-0" title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
||||
<li class="shrink min-width-0">
|
||||
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
|
@ -1,29 +0,0 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="size-h2 color-highlight text-center">{{ .Weather.WeatherCodeAsString }}</div>
|
||||
<div class="size-h4 text-center">Feels like {{ .Weather.ApparentTemperature }}°C</div>
|
||||
|
||||
<div class="weather-columns flex margin-top-15 justify-center">
|
||||
{{ range $i, $column := .Weather.Columns }}
|
||||
<div class="weather-column{{ if eq $i $.Weather.CurrentColumn }} weather-column-current{{ end }}">
|
||||
{{ if $column.HasPrecipitation }}
|
||||
<div class="weather-column-rain"></div>
|
||||
{{ end }}
|
||||
{{ if and (ge $i $.Weather.SunriseColumn) (le $i $.Weather.SunsetColumn ) }}
|
||||
<div class="weather-column-daylight{{ if eq $i $.Weather.SunriseColumn }} weather-column-daylight-sunrise{{ else if eq $i $.Weather.SunsetColumn }} weather-column-daylight-sunset{{ end }}"></div>
|
||||
{{ end }}
|
||||
<div class="weather-column-value{{ if lt $column.Temperature 0 }} weather-column-value-negative{{ end }}">{{ $column.Temperature | absInt }}</div>
|
||||
<div class="weather-bar" style='--weather-bar-height: {{ printf "%.2f" $column.Scale }}'></div>
|
||||
<div class="weather-column-time">{{ index $.TimeLabels $i }}</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ if not .HideLocation }}
|
||||
<div class="flex items-center justify-center margin-top-15 gap-7 size-h5">
|
||||
<div class="location-icon"></div>
|
||||
<div class="text-truncate">{{ .Place.Name }}, {{ .Place.Country }}</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
|
@ -1,21 +0,0 @@
|
|||
<div class="widget widget-type-{{ .GetType }}">
|
||||
<div class="widget-header">
|
||||
<div class="uppercase">{{ .Title }}</div>
|
||||
{{ if and .Error .ContentAvailable }}
|
||||
<div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
|
||||
{{ else if .Notice }}
|
||||
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="widget-content {{ if .ContentAvailable }}{{ block "widget-content-classes" . }}{{ end }}{{ end }}">
|
||||
{{ if .ContentAvailable }}
|
||||
{{ block "widget-content" . }}{{ end }}
|
||||
{{ else }}
|
||||
<div class="widget-error-header">
|
||||
<div class="color-negative size-h3">ERROR</div>
|
||||
<div class="widget-error-icon"></div>
|
||||
</div>
|
||||
<p class="break-all">{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}</p>
|
||||
{{ end}}
|
||||
</div>
|
||||
</div>
|
|
@ -1,53 +0,0 @@
|
|||
package feed
|
||||
|
||||
import "time"
|
||||
|
||||
// TODO: very inflexible, refactor to allow more customizability
|
||||
// TODO: allow changing first day of week
|
||||
// TODO: allow changing between showing the previous and next week and the entire month
|
||||
func NewCalendar(now time.Time) *Calendar {
|
||||
year, week := now.ISOWeek()
|
||||
weekday := now.Weekday()
|
||||
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
|
||||
currentMonthDays := daysInMonth(now.Month(), year)
|
||||
|
||||
var previousMonthDays int
|
||||
|
||||
if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
|
||||
previousMonthDays = daysInMonth(12, year-1)
|
||||
} else {
|
||||
previousMonthDays = daysInMonth(previousMonthNumber, year)
|
||||
}
|
||||
|
||||
startDaysFrom := now.Day() - int(weekday+6)
|
||||
|
||||
days := make([]int, 21)
|
||||
|
||||
for i := 0; i < 21; i++ {
|
||||
day := startDaysFrom + i
|
||||
|
||||
if day < 1 {
|
||||
day = previousMonthDays + day
|
||||
} else if day > currentMonthDays {
|
||||
day = day - currentMonthDays
|
||||
}
|
||||
|
||||
days[i] = day
|
||||
}
|
||||
|
||||
return &Calendar{
|
||||
CurrentDay: now.Day(),
|
||||
CurrentWeekNumber: week,
|
||||
CurrentMonthName: now.Month().String(),
|
||||
CurrentYear: year,
|
||||
Days: days,
|
||||
}
|
||||
}
|
||||
|
||||
func daysInMonth(m time.Month, year int) int {
|
||||
return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||
}
|
|
@ -1,117 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type githubReleaseResponseJson struct {
|
||||
TagName string `json:"tag_name"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
HtmlUrl string `json:"html_url"`
|
||||
Draft bool `json:"draft"`
|
||||
PreRelease bool `json:"prerelease"`
|
||||
Reactions struct {
|
||||
Downvotes int `json:"-1"`
|
||||
} `json:"reactions"`
|
||||
}
|
||||
|
||||
func parseGithubTime(t string) time.Time {
|
||||
parsedTime, err := time.Parse("2006-01-02T15:04:05Z", t)
|
||||
|
||||
if err != nil {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
return parsedTime
|
||||
}
|
||||
|
||||
func FetchLatestReleasesFromGithub(repositories []string, token string) (AppReleases, error) {
|
||||
appReleases := make(AppReleases, 0, len(repositories))
|
||||
|
||||
if len(repositories) == 0 {
|
||||
return appReleases, nil
|
||||
}
|
||||
|
||||
requests := make([]*http.Request, len(repositories))
|
||||
|
||||
for i, repository := range repositories {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=10", repository), nil)
|
||||
|
||||
if token != "" {
|
||||
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
}
|
||||
|
||||
requests[i] = request
|
||||
}
|
||||
|
||||
task := decodeJsonFromRequestTask[[]githubReleaseResponseJson](defaultClient)
|
||||
job := newJob(task, requests).withWorkers(15)
|
||||
responses, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var failed int
|
||||
|
||||
for i := range responses {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Error("Failed to fetch or parse github release", "error", errs[i], "url", requests[i].URL)
|
||||
continue
|
||||
}
|
||||
|
||||
releases := responses[i]
|
||||
|
||||
if len(releases) < 1 {
|
||||
failed++
|
||||
slog.Error("No releases found", "repository", repositories[i], "url", requests[i].URL)
|
||||
continue
|
||||
}
|
||||
|
||||
var liveRelease *githubReleaseResponseJson
|
||||
|
||||
for i := range releases {
|
||||
release := &releases[i]
|
||||
|
||||
if !release.Draft && !release.PreRelease {
|
||||
liveRelease = release
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if liveRelease == nil {
|
||||
slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
|
||||
continue
|
||||
}
|
||||
|
||||
version := liveRelease.TagName
|
||||
|
||||
if version[0] != 'v' {
|
||||
version = "v" + version
|
||||
}
|
||||
|
||||
appReleases = append(appReleases, AppRelease{
|
||||
Name: repositories[i],
|
||||
Version: version,
|
||||
NotesUrl: liveRelease.HtmlUrl,
|
||||
TimeReleased: parseGithubTime(liveRelease.PublishedAt),
|
||||
Downvotes: liveRelease.Reactions.Downvotes,
|
||||
})
|
||||
}
|
||||
|
||||
if len(appReleases) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
appReleases.SortByNewest()
|
||||
|
||||
if failed > 0 {
|
||||
return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
|
||||
}
|
||||
|
||||
return appReleases, nil
|
||||
}
|
|
@ -1,89 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type hackerNewsPostResponseJson struct {
|
||||
Id int `json:"id"`
|
||||
Score int `json:"score"`
|
||||
Title string `json:"title"`
|
||||
TargetUrl string `json:"url,omitempty"`
|
||||
CommentCount int `json:"descendants"`
|
||||
TimePosted int64 `json:"time"`
|
||||
}
|
||||
|
||||
func getHackerNewsTopPostIds() ([]int, error) {
|
||||
request, _ := http.NewRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", nil)
|
||||
response, err := decodeJsonFromRequest[[]int](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: could not fetch list of post IDs", ErrNoContent)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func getHackerNewsPostsFromIds(postIds []int) (ForumPosts, error) {
|
||||
requests := make([]*http.Request, len(postIds))
|
||||
|
||||
for i, id := range postIds {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil)
|
||||
requests[i] = request
|
||||
}
|
||||
|
||||
task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultClient)
|
||||
job := newJob(task, requests).withWorkers(30)
|
||||
results, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
posts := make(ForumPosts, 0, len(postIds))
|
||||
|
||||
for i := range results {
|
||||
if errs[i] != nil {
|
||||
slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL)
|
||||
continue
|
||||
}
|
||||
|
||||
posts = append(posts, ForumPost{
|
||||
Title: results[i].Title,
|
||||
DiscussionUrl: "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id),
|
||||
TargetUrl: results[i].TargetUrl,
|
||||
TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
|
||||
CommentCount: results[i].CommentCount,
|
||||
Score: results[i].Score,
|
||||
TimePosted: time.Unix(results[i].TimePosted, 0),
|
||||
})
|
||||
}
|
||||
|
||||
if len(posts) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
if len(posts) != len(postIds) {
|
||||
return posts, fmt.Errorf("%w could not fetch some hacker news posts", ErrPartialContent)
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func FetchHackerNewsTopPosts(limit int) (ForumPosts, error) {
|
||||
postIds, err := getHackerNewsTopPostIds()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(postIds) > limit {
|
||||
postIds = postIds[:limit]
|
||||
}
|
||||
|
||||
return getHackerNewsPostsFromIds(postIds)
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SiteStatus struct {
|
||||
Code int
|
||||
TimedOut bool
|
||||
ResponseTime time.Duration
|
||||
Error error
|
||||
}
|
||||
|
||||
func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
||||
defer cancel()
|
||||
request = request.WithContext(ctx)
|
||||
start := time.Now()
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
took := time.Since(start)
|
||||
status := SiteStatus{ResponseTime: took}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
status.TimedOut = true
|
||||
}
|
||||
|
||||
status.Error = err
|
||||
return status, err
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
status.Code = response.StatusCode
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func FetchStatusesForRequests(requests []*http.Request) ([]SiteStatus, error) {
|
||||
job := newJob(getSiteStatusTask, requests).withWorkers(20)
|
||||
results, _, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
|
@ -1,151 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
type PlacesResponseJson struct {
|
||||
Results []PlaceJson
|
||||
}
|
||||
|
||||
type PlaceJson struct {
|
||||
Name string
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
Timezone string
|
||||
Country string
|
||||
location *time.Location
|
||||
}
|
||||
|
||||
type WeatherResponseJson struct {
|
||||
Daily struct {
|
||||
Sunrise []int64 `json:"sunrise"`
|
||||
Sunset []int64 `json:"sunset"`
|
||||
} `json:"daily"`
|
||||
|
||||
Hourly struct {
|
||||
Temperature []float64 `json:"temperature_2m"`
|
||||
PrecipitationProbability []int `json:"precipitation_probability"`
|
||||
} `json:"hourly"`
|
||||
|
||||
Current struct {
|
||||
Temperature float64 `json:"temperature_2m"`
|
||||
ApparentTemperature float64 `json:"apparent_temperature"`
|
||||
WeatherCode int `json:"weather_code"`
|
||||
} `json:"current"`
|
||||
}
|
||||
|
||||
type weatherColumn struct {
|
||||
Temperature int
|
||||
Scale float64
|
||||
HasPrecipitation bool
|
||||
}
|
||||
|
||||
func FetchPlaceFromName(location string) (*PlaceJson, error) {
|
||||
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=en&format=json", url.QueryEscape(location))
|
||||
request, _ := http.NewRequest("GET", requestUrl, nil)
|
||||
responseJson, err := decodeJsonFromRequest[PlacesResponseJson](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not fetch places data: %v", err)
|
||||
}
|
||||
|
||||
if len(responseJson.Results) == 0 {
|
||||
return nil, fmt.Errorf("no places found for %s", location)
|
||||
}
|
||||
|
||||
place := &responseJson.Results[0]
|
||||
|
||||
loc, err := time.LoadLocation(place.Timezone)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not load location: %v", err)
|
||||
}
|
||||
|
||||
place.location = loc
|
||||
|
||||
return place, nil
|
||||
}
|
||||
|
||||
func barIndexFromHour(h int) int {
|
||||
return h / 2
|
||||
}
|
||||
|
||||
// TODO: bunch of spaget, refactor
|
||||
// TODO: allow changing between C and F
|
||||
func FetchWeatherForPlace(place *PlaceJson) (*Weather, error) {
|
||||
query := url.Values{}
|
||||
|
||||
query.Add("latitude", fmt.Sprintf("%f", place.Latitude))
|
||||
query.Add("longitude", fmt.Sprintf("%f", place.Longitude))
|
||||
query.Add("timeformat", "unixtime")
|
||||
query.Add("timezone", place.Timezone)
|
||||
query.Add("forecast_days", "1")
|
||||
query.Add("current", "temperature_2m,apparent_temperature,weather_code,wind_speed_10m")
|
||||
query.Add("hourly", "temperature_2m,precipitation_probability")
|
||||
query.Add("daily", "sunrise,sunset")
|
||||
|
||||
requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode()
|
||||
request, _ := http.NewRequest("GET", requestUrl, nil)
|
||||
responseJson, err := decodeJsonFromRequest[WeatherResponseJson](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
||||
}
|
||||
|
||||
now := time.Now().In(place.location)
|
||||
bars := make([]weatherColumn, 0, 24)
|
||||
currentBar := barIndexFromHour(now.Hour())
|
||||
sunriseBar := barIndexFromHour(time.Unix(int64(responseJson.Daily.Sunrise[0]), 0).In(place.location).Hour())
|
||||
sunsetBar := barIndexFromHour(time.Unix(int64(responseJson.Daily.Sunset[0]), 0).In(place.location).Hour()) - 1
|
||||
|
||||
if sunsetBar < 0 {
|
||||
sunsetBar = 0
|
||||
}
|
||||
|
||||
if len(responseJson.Hourly.Temperature) == 24 {
|
||||
temperatures := make([]int, 12)
|
||||
precipitations := make([]bool, 12)
|
||||
|
||||
t := responseJson.Hourly.Temperature
|
||||
p := responseJson.Hourly.PrecipitationProbability
|
||||
|
||||
for i := 0; i < 24; i += 2 {
|
||||
if i/2 == currentBar {
|
||||
temperatures[i/2] = int(responseJson.Current.Temperature)
|
||||
} else {
|
||||
temperatures[i/2] = int(math.Round((t[i] + t[i+1]) / 2))
|
||||
}
|
||||
|
||||
precipitations[i/2] = (p[i]+p[i+1])/2 > 75
|
||||
}
|
||||
|
||||
minT := slices.Min(temperatures)
|
||||
maxT := slices.Max(temperatures)
|
||||
|
||||
for i := 0; i < 12; i++ {
|
||||
bars = append(bars, weatherColumn{
|
||||
Temperature: temperatures[i],
|
||||
Scale: float64(temperatures[i]-minT) / float64(maxT-minT),
|
||||
HasPrecipitation: precipitations[i],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &Weather{
|
||||
Temperature: int(responseJson.Current.Temperature),
|
||||
ApparentTemperature: int(responseJson.Current.ApparentTemperature),
|
||||
WeatherCode: responseJson.Current.WeatherCode,
|
||||
CurrentColumn: currentBar,
|
||||
SunriseColumn: sunriseBar,
|
||||
SunsetColumn: sunsetBar,
|
||||
Columns: bars,
|
||||
}, nil
|
||||
}
|
|
@ -1,183 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ForumPost struct {
|
||||
Title string
|
||||
DiscussionUrl string
|
||||
TargetUrl string
|
||||
TargetUrlDomain string
|
||||
ThumbnailUrl string
|
||||
CommentCount int
|
||||
Score int
|
||||
Engagement float64
|
||||
TimePosted time.Time
|
||||
}
|
||||
|
||||
type ForumPosts []ForumPost
|
||||
|
||||
type Calendar struct {
|
||||
CurrentDay int
|
||||
CurrentWeekNumber int
|
||||
CurrentMonthName string
|
||||
CurrentYear int
|
||||
Days []int
|
||||
}
|
||||
|
||||
type Weather struct {
|
||||
Temperature int
|
||||
ApparentTemperature int
|
||||
WeatherCode int
|
||||
CurrentColumn int
|
||||
SunriseColumn int
|
||||
SunsetColumn int
|
||||
Columns []weatherColumn
|
||||
}
|
||||
|
||||
type AppRelease struct {
|
||||
Name string
|
||||
Version string
|
||||
NotesUrl string
|
||||
TimeReleased time.Time
|
||||
Downvotes int
|
||||
}
|
||||
|
||||
type AppReleases []AppRelease
|
||||
|
||||
type Video struct {
|
||||
ThumbnailUrl string
|
||||
Title string
|
||||
Url string
|
||||
Author string
|
||||
AuthorUrl string
|
||||
TimePosted time.Time
|
||||
}
|
||||
|
||||
type Videos []Video
|
||||
|
||||
type Stock struct {
|
||||
Name string
|
||||
Symbol string
|
||||
Price float64
|
||||
PercentChange float64
|
||||
SvgChartPoints string
|
||||
}
|
||||
|
||||
type Stocks []Stock
|
||||
|
||||
func (t Stocks) SortByAbsChange() {
|
||||
sort.Slice(t, func(i, j int) bool {
|
||||
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
|
||||
})
|
||||
}
|
||||
|
||||
var weatherCodeTable = map[int]string{
|
||||
0: "Clear Sky",
|
||||
1: "Mainly Clear",
|
||||
2: "Partly Cloudy",
|
||||
3: "Overcast",
|
||||
45: "Fog",
|
||||
48: "Rime Fog",
|
||||
51: "Drizzle",
|
||||
53: "Drizzle",
|
||||
55: "Drizzle",
|
||||
56: "Drizzle",
|
||||
57: "Drizzle",
|
||||
61: "Rain",
|
||||
63: "Moderate Rain",
|
||||
65: "Heavy Rain",
|
||||
66: "Freezing Rain",
|
||||
67: "Freezing Rain",
|
||||
71: "Snow",
|
||||
73: "Moderate Snow",
|
||||
75: "Heavy Snow",
|
||||
77: "Snow Grains",
|
||||
80: "Rain",
|
||||
81: "Moderate Rain",
|
||||
82: "Heavy Rain",
|
||||
85: "Snow",
|
||||
86: "Snow",
|
||||
95: "Thunderstorm",
|
||||
96: "Thunderstorm",
|
||||
99: "Thunderstorm",
|
||||
}
|
||||
|
||||
func (w *Weather) WeatherCodeAsString() string {
|
||||
if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok {
|
||||
return weatherCode
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
const depreciatePostsOlderThanHours = 7
|
||||
const maxDepreciation = 0.9
|
||||
const maxDepreciationAfterHours = 24
|
||||
|
||||
func (p ForumPosts) CalculateEngagement() {
|
||||
var totalComments int
|
||||
var totalScore int
|
||||
|
||||
for i := range p {
|
||||
totalComments += p[i].CommentCount
|
||||
totalScore += p[i].Score
|
||||
}
|
||||
|
||||
numberOfPosts := float64(len(p))
|
||||
averageComments := float64(totalComments) / numberOfPosts
|
||||
averageScore := float64(totalScore) / numberOfPosts
|
||||
|
||||
for i := range p {
|
||||
p[i].Engagement = (float64(p[i].CommentCount)/averageComments + float64(p[i].Score)/averageScore) / 2
|
||||
|
||||
elapsed := time.Since(p[i].TimePosted)
|
||||
|
||||
if elapsed < time.Hour*depreciatePostsOlderThanHours {
|
||||
continue
|
||||
}
|
||||
|
||||
p[i].Engagement *= 1.0 - (math.Max(elapsed.Hours()-depreciatePostsOlderThanHours, maxDepreciationAfterHours)/maxDepreciationAfterHours)*maxDepreciation
|
||||
}
|
||||
}
|
||||
|
||||
func (p ForumPosts) SortByEngagement() {
|
||||
sort.Slice(p, func(i, j int) bool {
|
||||
return p[i].Engagement > p[j].Engagement
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ForumPost) HasTargetUrl() bool {
|
||||
return s.TargetUrl != ""
|
||||
}
|
||||
|
||||
func (p ForumPosts) FilterPostedBefore(postedBefore time.Duration) []ForumPost {
|
||||
recent := make([]ForumPost, 0, len(p))
|
||||
|
||||
for i := range p {
|
||||
if time.Since(p[i].TimePosted) < postedBefore {
|
||||
recent = append(recent, p[i])
|
||||
}
|
||||
}
|
||||
|
||||
return recent
|
||||
}
|
||||
|
||||
func (r AppReleases) SortByNewest() AppReleases {
|
||||
sort.Slice(r, func(i, j int) bool {
|
||||
return r[i].TimeReleased.After(r[j].TimeReleased)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (v Videos) SortByNewest() Videos {
|
||||
sort.Slice(v, func(i, j int) bool {
|
||||
return v[i].TimePosted.After(v[j].TimePosted)
|
||||
})
|
||||
|
||||
return v
|
||||
}
|
|
@ -1,83 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type subredditResponseJson struct {
|
||||
Data struct {
|
||||
Children []struct {
|
||||
Data struct {
|
||||
Title string `json:"title"`
|
||||
Upvotes int `json:"ups"`
|
||||
Url string `json:"url"`
|
||||
Time float64 `json:"created"`
|
||||
CommentsCount int `json:"num_comments"`
|
||||
Domain string `json:"domain"`
|
||||
Permalink string `json:"permalink"`
|
||||
Stickied bool `json:"stickied"`
|
||||
Pinned bool `json:"pinned"`
|
||||
IsSelf bool `json:"is_self"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
} `json:"data"`
|
||||
} `json:"children"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func FetchSubredditPosts(subreddit string) (ForumPosts, error) {
|
||||
requestUrl := fmt.Sprintf("https://www.reddit.com/r/%s/hot.json", url.QueryEscape(subreddit))
|
||||
request, err := http.NewRequest("GET", requestUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
|
||||
addBrowserUserAgentHeader(request)
|
||||
responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(responseJson.Data.Children) == 0 {
|
||||
return nil, fmt.Errorf("no posts found")
|
||||
}
|
||||
|
||||
posts := make(ForumPosts, 0, len(responseJson.Data.Children))
|
||||
|
||||
for i := range responseJson.Data.Children {
|
||||
post := &responseJson.Data.Children[i].Data
|
||||
|
||||
if post.Stickied || post.Pinned {
|
||||
continue
|
||||
}
|
||||
|
||||
forumPost := ForumPost{
|
||||
Title: html.UnescapeString(post.Title),
|
||||
DiscussionUrl: "https://www.reddit.com" + post.Permalink,
|
||||
TargetUrlDomain: post.Domain,
|
||||
CommentCount: post.CommentsCount,
|
||||
Score: post.Upvotes,
|
||||
TimePosted: time.Unix(int64(post.Time), 0),
|
||||
}
|
||||
|
||||
if post.Thumbnail != "" && post.Thumbnail != "self" && post.Thumbnail != "default" {
|
||||
forumPost.ThumbnailUrl = post.Thumbnail
|
||||
}
|
||||
|
||||
if !post.IsSelf {
|
||||
forumPost.TargetUrl = post.Url
|
||||
}
|
||||
|
||||
posts = append(posts, forumPost)
|
||||
}
|
||||
|
||||
posts.CalculateEngagement()
|
||||
|
||||
return posts, nil
|
||||
}
|
|
@ -1,117 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/mmcdole/gofeed"
|
||||
)
|
||||
|
||||
type RSSFeedItem struct {
|
||||
ChannelName string
|
||||
ChannelURL string
|
||||
Title string
|
||||
Link string
|
||||
ImageURL string
|
||||
PublishedAt time.Time
|
||||
}
|
||||
|
||||
type RSSFeedRequest struct {
|
||||
Url string `yaml:"url"`
|
||||
Title string `yaml:"title"`
|
||||
}
|
||||
|
||||
type RSSFeedItems []RSSFeedItem
|
||||
|
||||
func (f RSSFeedItems) SortByNewest() RSSFeedItems {
|
||||
sort.Slice(f, func(i, j int) bool {
|
||||
return f[i].PublishedAt.After(f[j].PublishedAt)
|
||||
})
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
var feedParser = gofeed.NewParser()
|
||||
|
||||
func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
feed, err := feedParser.ParseURLWithContext(request.Url, ctx)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make(RSSFeedItems, 0, len(feed.Items))
|
||||
|
||||
for i := range feed.Items {
|
||||
item := feed.Items[i]
|
||||
|
||||
rssItem := RSSFeedItem{
|
||||
ChannelURL: feed.Link,
|
||||
Title: item.Title,
|
||||
Link: item.Link,
|
||||
}
|
||||
|
||||
if request.Title != "" {
|
||||
rssItem.ChannelName = request.Title
|
||||
} else {
|
||||
rssItem.ChannelName = feed.Title
|
||||
}
|
||||
|
||||
if item.Image != nil {
|
||||
rssItem.ImageURL = item.Image.URL
|
||||
} else if feed.Image != nil {
|
||||
rssItem.ImageURL = feed.Image.URL
|
||||
}
|
||||
|
||||
if item.PublishedParsed != nil {
|
||||
rssItem.PublishedAt = *item.PublishedParsed
|
||||
} else {
|
||||
rssItem.PublishedAt = time.Now()
|
||||
}
|
||||
|
||||
items = append(items, rssItem)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
|
||||
job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10)
|
||||
feeds, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
||||
}
|
||||
|
||||
failed := 0
|
||||
|
||||
entries := make(RSSFeedItems, 0, len(feeds)*10)
|
||||
|
||||
for i := range feeds {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Error("failed to get rss feed", "error", errs[i], "url", requests[i].Url)
|
||||
continue
|
||||
}
|
||||
|
||||
entries = append(entries, feeds[i]...)
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
entries.SortByNewest()
|
||||
|
||||
if failed > 0 {
|
||||
return entries, fmt.Errorf("%w: missing %d RSS feeds", ErrPartialContent, failed)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
|
@ -1,248 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TwitchCategory struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
AvatarUrl string `json:"avatarURL"`
|
||||
ViewersCount int `json:"viewersCount"`
|
||||
Tags []struct {
|
||||
Name string `json:"tagName"`
|
||||
} `json:"tags"`
|
||||
GameReleaseDate string `json:"originalReleaseDate"`
|
||||
IsNew bool `json:"-"`
|
||||
}
|
||||
|
||||
type TwitchChannel struct {
|
||||
Login string
|
||||
Exists bool
|
||||
Name string
|
||||
AvatarUrl string
|
||||
IsLive bool
|
||||
LiveSince time.Time
|
||||
Category string
|
||||
CategorySlug string
|
||||
ViewersCount int
|
||||
}
|
||||
|
||||
type TwitchChannels []TwitchChannel
|
||||
|
||||
func (channels TwitchChannels) SortByViewers() {
|
||||
sort.Slice(channels, func(i, j int) bool {
|
||||
return channels[i].ViewersCount > channels[j].ViewersCount
|
||||
})
|
||||
}
|
||||
|
||||
type twitchOperationResponse struct {
|
||||
Data json.RawMessage
|
||||
Extensions struct {
|
||||
OperationName string `json:"operationName"`
|
||||
}
|
||||
}
|
||||
|
||||
type twitchChannelShellOperationResponse struct {
|
||||
UserOrError struct {
|
||||
Type string `json:"__typename"`
|
||||
DisplayName string `json:"displayName"`
|
||||
ProfileImageUrl string `json:"profileImageURL"`
|
||||
Stream *struct {
|
||||
ViewersCount int `json:"viewersCount"`
|
||||
}
|
||||
} `json:"userOrError"`
|
||||
}
|
||||
|
||||
type twitchStreamMetadataOperationResponse struct {
|
||||
UserOrNull *struct {
|
||||
Stream *struct {
|
||||
StartedAt string `json:"createdAt"`
|
||||
Game *struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
} `json:"game"`
|
||||
} `json:"stream"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
type twitchDirectoriesOperationResponse struct {
|
||||
Data struct {
|
||||
DirectoriesWithTags struct {
|
||||
Edges []struct {
|
||||
Node TwitchCategory `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"directoriesWithTags"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
const twitchGqlEndpoint = "https://gql.twitch.tv/gql"
|
||||
const twitchGqlClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko"
|
||||
|
||||
const twitchDirectoriesOperationRequestBody = `[{"operationName": "BrowsePage_AllDirectories","variables": {"limit": %d,"options": {"sort": "VIEWER_COUNT","tags": []}},"extensions": {"persistedQuery": {"version": 1,"sha256Hash": "2f67f71ba89f3c0ed26a141ec00da1defecb2303595f5cda4298169549783d9e"}}}]`
|
||||
|
||||
func FetchTopGamesFromTwitch(exclude []string, limit int) ([]TwitchCategory, error) {
|
||||
reader := strings.NewReader(fmt.Sprintf(twitchDirectoriesOperationRequestBody, len(exclude)+limit))
|
||||
request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader)
|
||||
request.Header.Add("Client-ID", twitchGqlClientId)
|
||||
response, err := decodeJsonFromRequest[[]twitchDirectoriesOperationResponse](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(response) == 0 {
|
||||
return nil, errors.New("no categories could be retrieved")
|
||||
}
|
||||
|
||||
edges := (response)[0].Data.DirectoriesWithTags.Edges
|
||||
categories := make([]TwitchCategory, 0, len(edges))
|
||||
|
||||
for i := range edges {
|
||||
if slices.Contains(exclude, edges[i].Node.Slug) {
|
||||
continue
|
||||
}
|
||||
|
||||
category := &edges[i].Node
|
||||
category.AvatarUrl = strings.Replace(category.AvatarUrl, "285x380", "144x192", 1)
|
||||
|
||||
if len(category.Tags) > 2 {
|
||||
category.Tags = category.Tags[:2]
|
||||
}
|
||||
|
||||
gameReleasedDate, err := time.Parse("2006-01-02T15:04:05Z", category.GameReleaseDate)
|
||||
|
||||
if err == nil {
|
||||
if time.Since(gameReleasedDate) < 14*24*time.Hour {
|
||||
category.IsNew = true
|
||||
}
|
||||
}
|
||||
|
||||
categories = append(categories, *category)
|
||||
}
|
||||
|
||||
if len(categories) > limit {
|
||||
categories = categories[:limit]
|
||||
}
|
||||
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
const twitchChannelStatusOperationRequestBody = `[{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}]`
|
||||
|
||||
// TODO: rework
|
||||
// The operations for multiple channels can all be sent in a single request
|
||||
// rather than sending a separate request for each channel. Need to figure out
|
||||
// what the limit is for max operations per request and batch operations in
|
||||
// multiple requests if number of channels exceeds allowed limit.
|
||||
|
||||
func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
|
||||
result := TwitchChannel{
|
||||
Login: strings.ToLower(channel),
|
||||
}
|
||||
|
||||
reader := strings.NewReader(fmt.Sprintf(twitchChannelStatusOperationRequestBody, channel, channel))
|
||||
request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader)
|
||||
request.Header.Add("Client-ID", twitchGqlClientId)
|
||||
|
||||
response, err := decodeJsonFromRequest[[]twitchOperationResponse](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if len(response) != 2 {
|
||||
return result, fmt.Errorf("expected 2 operation responses, got %d", len(response))
|
||||
}
|
||||
|
||||
var channelShell twitchChannelShellOperationResponse
|
||||
var streamMetadata twitchStreamMetadataOperationResponse
|
||||
|
||||
for i := range response {
|
||||
switch response[i].Extensions.OperationName {
|
||||
case "ChannelShell":
|
||||
err = json.Unmarshal(response[i].Data, &channelShell)
|
||||
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to unmarshal channel shell: %w", err)
|
||||
}
|
||||
case "StreamMetadata":
|
||||
err = json.Unmarshal(response[i].Data, &streamMetadata)
|
||||
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to unmarshal stream metadata: %w", err)
|
||||
}
|
||||
default:
|
||||
return result, fmt.Errorf("unknown operation name: %s", response[i].Extensions.OperationName)
|
||||
}
|
||||
}
|
||||
|
||||
if channelShell.UserOrError.Type != "User" {
|
||||
result.Name = result.Login
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.Exists = true
|
||||
result.Name = channelShell.UserOrError.DisplayName
|
||||
result.AvatarUrl = channelShell.UserOrError.ProfileImageUrl
|
||||
|
||||
if channelShell.UserOrError.Stream != nil {
|
||||
result.IsLive = true
|
||||
result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
|
||||
|
||||
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil && streamMetadata.UserOrNull.Stream.Game != nil {
|
||||
result.Category = streamMetadata.UserOrNull.Stream.Game.Name
|
||||
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
|
||||
startedAt, err := time.Parse("2006-01-02T15:04:05Z", streamMetadata.UserOrNull.Stream.StartedAt)
|
||||
|
||||
if err == nil {
|
||||
result.LiveSince = startedAt
|
||||
} else {
|
||||
slog.Warn("failed to parse twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func FetchChannelsFromTwitch(channelLogins []string) (TwitchChannels, error) {
|
||||
result := make(TwitchChannels, 0, len(channelLogins))
|
||||
|
||||
job := newJob(fetchChannelFromTwitchTask, channelLogins).withWorkers(10)
|
||||
channels, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
var failed int
|
||||
|
||||
for i := range channels {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Warn("failed to fetch twitch channel", "channel", channelLogins[i], "error", errs[i])
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, channels[i])
|
||||
}
|
||||
|
||||
if failed == len(channelLogins) {
|
||||
return result, ErrNoContent
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return result, fmt.Errorf("%w: failed to fetch %d channels", ErrPartialContent, failed)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
|
@ -1,79 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoContent = errors.New("failed to retrieve any content")
|
||||
ErrPartialContent = errors.New("failed to retrieve some of the content")
|
||||
)
|
||||
|
||||
func percentChange(current, previous float64) float64 {
|
||||
return (current/previous - 1) * 100
|
||||
}
|
||||
|
||||
func extractDomainFromUrl(u string) string {
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(u)
|
||||
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimPrefix(parsed.Host, "www.")
|
||||
}
|
||||
|
||||
func SvgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
|
||||
if len(values) < 2 {
|
||||
return ""
|
||||
}
|
||||
|
||||
verticalPadding := height * 0.02
|
||||
height -= verticalPadding * 2
|
||||
coordinates := make([]string, len(values))
|
||||
distanceBetweenPoints := width / float64(len(values)-1)
|
||||
min := slices.Min(values)
|
||||
max := slices.Max(values)
|
||||
|
||||
for i := range values {
|
||||
coordinates[i] = fmt.Sprintf(
|
||||
"%.2f,%.2f",
|
||||
float64(i)*distanceBetweenPoints,
|
||||
((max-values[i])/(max-min))*height+verticalPadding,
|
||||
)
|
||||
}
|
||||
|
||||
return strings.Join(coordinates, " ")
|
||||
}
|
||||
|
||||
func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
|
||||
if len(values) == 0 {
|
||||
return values
|
||||
}
|
||||
|
||||
for i := range values {
|
||||
if values[i] != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
c := make([]T, 0, len(values)-1)
|
||||
|
||||
for i := range values {
|
||||
if values[i] != 0 {
|
||||
c = append(c, values[i])
|
||||
}
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
return values
|
||||
}
|