Compare commits

...

366 commits

Author SHA1 Message Date
Svilen Markov
3b79c8e09f Remove symbol-link-template 2025-03-15 10:27:39 +00:00
Svilen Markov
d7bbf2b8e2 Accessibility improvements 2025-03-12 18:01:35 +00:00
Svilen Markov
b8df34309f Add note to docs about page width 2025-03-12 18:01:35 +00:00
Svilen Markov
278fa3c397 Add gap between domain and percent 2025-03-12 18:01:35 +00:00
Svilen Markov
6f48ee98e5 Uppercase currency before looking for matching symbol #365 2025-03-12 18:01:35 +00:00
Svilen Markov
774b0c104b
Merge pull request #399 from KadoBOT/patch-1
Update configuration.md
2025-03-08 09:46:37 +00:00
Ricardo Ambrogi
fbd4d9a74e
Update configuration.md
Correct `title` to `name`.

I'm unsure if `title` is also accepted, I haven't tested it. Please close this PR if this fix is not valid.
2025-03-07 09:07:10 +01:00
Svilen Markov
3f2fefe4f7
Merge pull request #382 from legoraft/main
Added gruvbox theme
2025-03-01 22:44:22 +00:00
Legoraft
fb8513bc9f
Added gruvbox theme code and screenshot 2025-03-01 14:19:36 +01:00
Legoraft
e4ec958edb
Added gruvbox screenshot 2025-03-01 14:17:17 +01:00
Svilen Markov
488a1f6070 Update link to point to repo instead of pkg.go.dev 2025-02-22 13:32:50 +00:00
Svilen Markov
1e12d937aa
Merge pull request #372 from DavisYe/main
Correct document
2025-02-22 12:58:56 +00:00
DavisYe
7319870289 Correct the docker compose environment format of GITHUB_TOKEN in the configuration document. 2025-02-20 21:30:34 +08:00
Svilen Markov
d4565acfe7 Markets widget rate limit fix 2025-02-19 02:25:07 +00:00
Svilen Markov
16129c53bd
Merge pull request #358 from rubiojr/rubiojr/reload-on-rename2
Auto-reload config file on RENAME
2025-02-19 01:25:08 +00:00
Svilen Markov
cbf1961510 Don't try to get sensor info on openbsd 2025-02-17 23:48:16 +00:00
Svilen Markov
c76a4d4be7 Increase docker containers widget timeout 2025-02-17 23:45:57 +00:00
Svilen Markov
27af0400c0 Tweak impl for handling config renames 2025-02-17 22:28:10 +00:00
Sergio Rubio
76a80ff034 Add clarifying comment 2025-02-17 19:17:49 +01:00
Sergio Rubio
f7f333ad52 Auto-reload config file on RENAME
Some editors (like Vim), create a temp file when saving, then replace
the file being edited with the temp file. This causes the FS notify
event to be RENAME, not WRITE, so auto-reload misses this.

In addition to that, the file is removed from the watcher and the
auto-reload functionality stops working entirely after the first RENAME.

https://github.com/fsnotify/fsnotify/issues/255 describes this.
2025-02-17 19:08:54 +01:00
Svilen Markov
0ce45e32aa Update dashboard icons repo 2025-02-15 14:59:20 +00:00
Svilen Markov
abeb11c8a6 Add hide-mountpoints-by-default prop for server-stats 2025-02-15 14:43:25 +00:00
Svilen Markov
e01af4adec Delay changing popover display
Previously would make the popover visible and then reposition
it on the next frame in order to avoid getting called recursively
due to the observer, however this causes the scrollbar to appear
if it wasn't already visible for a single frame which is janky.

This change fixes that.
2025-02-15 13:53:13 +00:00
Svilen Markov
3043a0bd15 Rework getting host info (#340) 2025-02-15 13:46:58 +00:00
Svilen Markov
232cab01f8 Fix typo 2025-02-10 23:15:12 +00:00
Svilen Markov
b301953249 Update docs 2025-02-10 20:38:50 +00:00
Svilen Markov
bb9cb03c8a Fix index out of range 2025-02-10 11:22:48 +00:00
Svilen Markov
d8a4d39849
Merge pull request #330 from glanceapp/release/v0.7.0
Release/v0.7.0
2025-02-09 19:07:48 +00:00
Svilen Markov
0d6966726e Update pull request template 2025-02-09 18:45:23 +00:00
Svilen Markov
bb26725c67 Include pkg dir when building docker image 2025-02-09 18:21:23 +00:00
Svilen Markov
103fe5718b Tidy mod 2025-02-09 18:15:50 +00:00
Svilen Markov
a0d1bf1788 Bump versions 2025-02-09 18:04:25 +00:00
Svilen Markov
37f35281b4 Add server-stats widget 2025-02-09 17:52:22 +00:00
Svilen Markov
306fb3cb33 Add new calendar and deprecate old 2025-02-09 17:47:20 +00:00
Svilen Markov
24a6107171 Update config docs 2025-02-09 17:33:20 +00:00
Svilen Markov
e495fbfd99 Remove comment 2025-02-09 17:33:07 +00:00
Svilen Markov
da6fbc4995 Update custom-api doc 2025-02-09 17:32:55 +00:00
Svilen Markov
8d673e1229 Update readme 2025-02-09 17:32:43 +00:00
Svilen Markov
d8c7d02f5e Add custom-api examples 2025-02-09 05:33:08 +00:00
Svilen Markov
8331c4b305 Add Exists function 2025-02-09 05:32:30 +00:00
Svilen Markov
5a06f4788b Return NaN instead of 0 when dividing by 0 2025-02-09 05:32:05 +00:00
Svilen Markov
827171e206 Add upgrade guide 2025-02-09 03:04:29 +00:00
Svilen Markov
c3f3fa9876 Update readme 2025-02-09 00:40:19 +00:00
Svilen Markov
825ef84da5 Add comment 2025-02-09 00:39:51 +00:00
Svilen Markov
4445d46f61 Add example glance.yml to docs 2025-02-09 00:39:36 +00:00
Svilen Markov
c583057d45 Allow specifying playlists separately 2025-02-09 00:31:53 +00:00
Svilen Markov
982f3ee2a1 Add issue templates 2025-02-07 11:11:58 +00:00
Svilen Markov
cd1e4e9f91 Update update warn link 2025-02-07 11:06:44 +00:00
Svilen Markov
33f8e6d144 Update notice page 2025-02-07 11:06:20 +00:00
Svilen Markov
af70cc83ba Add preserve-order and limit props to RSS widget 2025-02-07 11:05:53 +00:00
Svilen Markov
8b731fd9e5 Invert colors in docs image 2025-02-07 11:01:46 +00:00
Svilen Markov
a0c697b982 Only ignore glance*.yml in root dir 2025-02-07 11:00:58 +00:00
Svilen Markov
21a6fe407b Remove println 2025-02-04 03:55:41 +00:00
Svilen Markov
1c1a7b3e27 Change label from title to name 2025-02-02 13:02:59 +00:00
Svilen Markov
61fd5481cc Update custom API 2025-02-02 13:02:46 +00:00
Svilen Markov
d400b27545 Fix observer console errors 2025-01-22 16:59:10 +00:00
Svilen Markov
ec9d455d18
Merge pull request #307 from Mystically11/add-prereleases-to-releases
feat: Add option to include GitHub repository pre-releases in releases widget
2025-01-17 20:18:47 +00:00
Svilen Markov
8a75f303bb Remove extra whitespace 2025-01-17 20:16:55 +00:00
Svilen Markov
0ce706e02e Update releases widget 2025-01-17 20:14:07 +00:00
Svilen Markov
3076593021 Merge remote-tracking branch 'upstream/release/v0.7.0' into add-prereleases-to-releases 2025-01-17 19:35:46 +00:00
Svilen Markov
a83389bb7d
Merge pull request #314 from JeckDev/release/v0.7.0
feat: add alternate link (error-url) for monitor widget sites
2025-01-17 19:10:41 +00:00
Svilen Markov
ee1cde8a04 Update monitor widget 2025-01-17 19:09:49 +00:00
Svilen Markov
a9efe88461 Revert points abbreviation 2025-01-15 07:49:08 +00:00
Jeck
c8200f2972 Added error-url parameter to monitor sites 2025-01-14 21:32:04 -08:00
Svilen Markov
80b43d3e0c Further simplify points abbreviation implementation 2025-01-14 11:58:26 +00:00
Svilen Markov
a7e235441b Simplify points abbreviation implementation 2025-01-14 11:55:52 +00:00
Svilen Markov
bb80637e72 Only hide comment count if there's a target URL 2025-01-14 11:35:02 +00:00
Svilen Markov
8fb5de4e87 Remove whitespace in template 2025-01-14 10:48:35 +00:00
Svilen Markov
1c03f0a07a Update forum post templates
* Use approx number for points & comments
* Hide comments when limited on horizontal space
* Abbreviate "points" to "pts" when limited on horizontal space
* Don't let domain wrap
2025-01-14 10:41:46 +00:00
Svilen Markov
b4ac96ccaf Remove whitespace in template & increase popover max width 2025-01-13 10:47:55 +00:00
Svilen Markov
5723fbdea5 Add markets widget link templates & automatic name 2025-01-12 04:21:47 +00:00
Svilen Markov
260bc15577 Remove reddit widget title forward slash prefix 2025-01-11 22:31:45 +00:00
Svilen Markov
59bfe3e835 Add vertical-list style for videos widget 2025-01-11 19:48:06 +00:00
Svilen Markov
086fac4120 Fix monitor widget showing incorrect status icon 2025-01-11 17:59:07 +00:00
Mystically
46bc36ad4a oops 2025-01-09 21:20:11 +01:00
Mystically
ed477e8cc6 add option to include github prereleases 2025-01-09 21:19:13 +01:00
Svilen Markov
8b878149d4 Update docs 2025-01-09 17:17:56 +00:00
Svilen Markov
db512419ea Add new text color class 2025-01-07 15:34:58 +00:00
Svilen Markov
491bc65a38 Add target property on bookmarks widget 2025-01-07 10:45:54 +00:00
Svilen Markov
94ec286de6 Add codeblock highlighting 2025-01-06 21:28:53 +00:00
Svilen Markov
108c83588c Allow using a standard HTTP proxy in reddit widget 2025-01-06 20:40:46 +00:00
Svilen Markov
abbb4950a5 Fix HTML entities in RSS item titles 2025-01-04 07:49:29 +00:00
Svilen Markov
0fe0b34898
Merge pull request #298 from hecht-a/add_search_placeholder
feat: add possibility to set search input placeholder
2025-01-04 03:47:51 +00:00
HECHT Axel
8ad7d4be0a
feat: add possibility to set search input placeholder 2025-01-03 19:36:43 +01:00
Svilen Markov
ac7f3805d4 Add custom unmarshalling for pihole queries stats
+ hide-graph and hide-top-domains options for DNS stats widget
2024-12-28 14:17:48 +00:00
Svilen Markov
e524dd111e
Merge pull request #286 from DallasHoff/mobile-style-fixes-0.7.0
Mobile style fixes
2024-12-26 17:04:21 +00:00
Svilen Markov
4a14709f13 Tweak iOS fixes 2024-12-26 17:02:15 +00:00
Svilen Markov
85b8ee493e Update dependencies 2024-12-26 13:24:40 +00:00
Svilen Markov
e546488aeb Fix broken reddit video & nsfw post thumbnails 2024-12-26 13:20:03 +00:00
Svilen Markov
0702e0abd3 Fix broken reddit video thumbnail URLs 2024-12-26 13:16:38 +00:00
Dallas Hoffman
29318005b2 Mobile style fixes 2024-12-24 14:42:28 -05:00
Svilen Markov
b4b61c94d7 Merge branch 'main' into release/v0.7.0 2024-12-22 14:34:11 +00:00
Svilen Markov
f18620d890 Add note about extension URL query parameters 2024-12-22 14:32:56 +00:00
Svilen Markov
eb86c3fb79 Allow setting same-tab and hide-arrow on bookmark groups 2024-12-22 14:22:11 +00:00
Svilen Markov
f89fc2ee1d Preserve formatting of non-html extension widget content 2024-12-22 13:44:41 +00:00
Svilen Markov
495eaa0a37 Add Widget-Content-Frameless option to extension widget 2024-12-22 13:31:26 +00:00
Svilen Markov
b44b7b80ff Don't use sprintf for relative time attribute 2024-12-20 11:41:48 +00:00
Svilen Markov
c8ff5362a3 Update dependencies 2024-12-19 23:05:47 +00:00
Svilen Markov
d575a0a29f Remove console.log 2024-12-18 12:47:30 +00:00
Svilen Markov
2e1134fdfb Properly handle future timestamps for relative time 2024-12-18 11:30:17 +00:00
Svilen Markov
ab6ae15836 Add automatic native titles to all truncateable elements 2024-12-17 23:34:32 +00:00
Svilen Markov
a4840c7365 Remove unnecessary variables 2024-12-17 00:30:34 +00:00
Svilen Markov
dbcc13a5cf Allow inserting env variables anywhere in the config 2024-12-16 23:59:25 +00:00
Svilen Markov
8d2639b349 Allow RSS feed URLs to be specified by env variables 2024-12-16 14:08:47 +00:00
Svilen Markov
889b034a79 Rename struct 2024-12-16 14:07:37 +00:00
Svilen Markov
e49145023f Don't escape user-defined URLs 2024-12-16 14:05:52 +00:00
Svilen Markov
ae1fb05607 Better convention for safe template types 2024-12-16 13:58:58 +00:00
Svilen Markov
80a957bc5b Change extension user-defined title to take precedence 2024-12-15 15:04:33 +00:00
Svilen Markov
24f93d8258
Merge pull request #275 from glanceapp/release/v0.6.3
Release/v0.6.3
2024-12-11 18:56:48 +00:00
Svilen Markov
01016e08d2 Merge branch 'main' into release/v0.6.3 2024-12-11 18:53:40 +00:00
Svilen Markov
949fde1517 Fix edge case in weather widget graph 2024-12-11 18:50:34 +00:00
Svilen Markov
804cf9916b Change simple icons provider and always use latest version 2024-12-11 18:47:30 +00:00
Svilen Markov
ef24680baf Fix overlap issues in weather and dns-stats widgets 2024-12-11 18:38:09 +00:00
Svilen Markov
d4b1d240b9 Fix releasing page mutex during template execution 2024-12-08 14:55:53 +00:00
Svilen Markov
448c795982 Properly close code block in docs 2024-12-07 16:47:58 +00:00
Svilen Markov
7f667e2d1c Allow using env variables in monitor and bookmarks widget URLs 2024-12-07 16:40:09 +00:00
Svilen Markov
a1afdb0c63 Allow digits in env variable names 2024-12-07 16:17:09 +00:00
Svilen Markov
ee1d7599d5 Add note to preconfigured pages doc 2024-12-07 09:28:36 +00:00
Svilen Markov
8adad18198 Move variable definition outside of closure 2024-12-07 09:20:01 +00:00
Svilen Markov
49a0758aae Fix trying to load incorrect thumbnail if post marked nsfw 2024-12-07 08:57:51 +00:00
Svilen Markov
e3bdc73013 Allow specifying playlist in videos widget
Co-authored-by: vishalkadam47 <vishal@jeevops.com>
2024-12-06 10:54:14 +00:00
Svilen Markov
1922e1e895 Increase RSS widget workers 2024-12-06 10:54:14 +00:00
Svilen Markov
7ee8e7c8d2
Merge pull request #242 from CremaLuca/main
feat: HEALTHCHECK in Dockerfile
2024-12-05 13:33:01 +00:00
Svilen Markov
e5106c0704
Merge branch 'release/v0.7.0' into main 2024-12-05 13:32:16 +00:00
Svilen Markov
80331aa217 Also add healthcheck to the other Dockerfile 2024-12-05 13:31:25 +00:00
Svilen Markov
84537e6884 Update status icons 2024-12-04 08:19:24 +00:00
Svilen Markov
f7062115bc Add support for selfhst icons 2024-12-04 08:11:18 +00:00
Svilen Markov
24f9f20eb7 Reduce list gap 2024-12-03 22:11:51 +00:00
Svilen Markov
217c6f2ed0 Remove container created time 2024-12-03 20:13:23 +00:00
Svilen Markov
82490ef5dd
Merge pull request #261 from baranovskis/release/v0.7.0
Docker widget
2024-12-03 19:10:15 +00:00
Svilen Markov
fcda017c39 Update docker containers widget 2024-12-03 19:04:43 +00:00
Svilen Markov
0c36925783 Fix off by 1px popover triangle 2024-12-03 16:52:28 +00:00
Svilen Markov
fcb67e62c5 Change widget error header icon to SVG 2024-12-03 16:51:33 +00:00
Svilen Markov
0651a2886e Merge latest changes 2024-12-03 13:59:20 +00:00
Svilen Markov
d876f4cb39 Add allow-insecure for dns-stats widget 2024-11-30 14:24:56 +00:00
Svilen Markov
b5259d1a98 Rename variables & interface 2024-11-30 14:24:19 +00:00
Svilen Markov
ffe053ffc5 Even more config watcher fixes 2024-11-30 12:54:19 +00:00
Svilen Markov
1785af4749 More config watcher fixes 2024-11-30 12:37:56 +00:00
Svilen Markov
98b4b7330e Further fixes for config file watcher 2024-11-30 12:23:57 +00:00
Svilen Markov
f68e5ae9ef Optimize cache control header assignment in file server 2024-11-30 11:16:07 +00:00
Svilen Markov
b3e73ce86a Move log outside of func 2024-11-30 10:59:58 +00:00
Svilen Markov
02cbb5f812 Fix CS 2024-11-30 10:48:33 +00:00
Svilen Markov
1d9ae72c81 Rename variables 2024-11-29 22:58:05 +00:00
Svilen Markov
d6470ae814 Somewhat working fix for config watcher 2024-11-29 21:45:50 +00:00
Svilen Markov
a816d1a913 Update error messages 2024-11-29 21:12:10 +00:00
Svilen Markov
2c03316f86 Update error 2024-11-29 21:06:58 +00:00
Svilen Markov
d19214a390 Update error messages 2024-11-29 20:52:09 +00:00
Svilen Markov
03035d1a2d Move template vars 2024-11-29 20:45:33 +00:00
Svilen Markov
6886716e67 Add comment 2024-11-29 18:28:10 +00:00
Svilen Markov
6165308c23 Fix random overlapping issues after browser update... wtf? 2024-11-29 17:34:25 +00:00
Svilen Markov
77a9469ff8 Add console message about new config location 2024-11-29 17:11:07 +00:00
Svilen Markov
2dce9b4c48 Listen on all interfaces 2024-11-29 17:03:52 +00:00
Svilen Markov
74e05763f7 Re-add tzdata 2024-11-29 16:45:18 +00:00
Svilen Markov
a4185fde07 Rename file 2024-11-29 16:41:15 +00:00
Svilen Markov
ebb519e6d8 Capitalize console messages 2024-11-29 16:38:16 +00:00
Svilen Markov
90fbba600f Restructure & refactor codebase 2024-11-29 16:34:15 +00:00
Svilen Markov
4bd4921131
Merge pull request #267 from xendke/calendar-start-day
Optionally start calendar weeks on Sunday
2024-11-26 17:02:26 +00:00
Juan Xavier Gomez
44ee813c6f simplify cal weekday index 2024-11-25 15:49:28 -05:00
Juan Xavier Gomez
3e467c5021 optionally start calendar weeks on sunday 2024-11-25 13:04:52 -05:00
Svilen Markov
2b0dd3ab99 Refactor 1/2 + new stuff
* Refactor CLI
* Add config:print command
* Add diagnose command
* Allow including other files in config
* Watch for file changes and automatically restart server
2024-11-24 15:39:14 +00:00
Andrejs Baranovskis
2618346a32 Rewrote docker client to reduce dependencies ♻️ 2024-11-20 17:03:37 +01:00
Andrejs Baranovskis
eacbb14279 Added docker widget with documentation 2024-11-19 17:58:55 +01:00
Andrejs Baranovskis
c8570d07ef Added debugging Dockerfile 2024-11-19 17:31:42 +01:00
Svilen Markov
7e345dd1f9 Add comment 2024-11-17 10:18:11 +00:00
Svilen Markov
afe6ad6bfc Don't focus new tab on middle click
This is more in line with how the browser behaves on middle click
2024-11-17 10:06:54 +00:00
Svilen Markov
af4c1e8514 Prevent default on middle click 2024-11-17 09:57:46 +00:00
Svilen Markov
d4fecd1dd8 Fix middle mouse click 2024-11-17 09:49:56 +00:00
Svilen Markov
79779eb721 Restore title-url functionality on group titles 2024-11-17 09:36:46 +00:00
Svilen Markov
6af666b58c Reduce margin 2024-11-16 15:00:05 +00:00
Svilen Markov
42872136a0 Reduce gap 2024-11-16 11:52:09 +00:00
Svilen Markov
aca7b2ac5f Reduce status icon size 2024-11-16 11:45:23 +00:00
Svilen Markov
a646f19b3f Make monitor widget compact style title size dynamic 2024-11-16 11:25:29 +00:00
Svilen Markov
9c5298aebf Add compact style for monitor widget 2024-11-16 11:10:46 +00:00
Svilen Markov
c6b07852fe Clear search input on submission 2024-11-16 08:29:58 +00:00
Svilen Markov
40d3053df9
Merge pull request #249 from ralphocdol/default-expand-mobile-navigation
Default expand mobile navigation
2024-11-16 07:21:25 +00:00
Svilen Markov
5e576a58e9
Merge branch 'release/v0.7.0' into default-expand-mobile-navigation 2024-11-16 07:20:49 +00:00
Svilen Markov
4d63b5dda1 Update docs 2024-11-16 07:17:06 +00:00
Svilen Markov
c1efd3f68b Fix formatting 2024-11-16 07:15:26 +00:00
Svilen Markov
2841cc67e8
Merge pull request #255 from 2q2code/main
Add Dashboard Icons support
2024-11-16 07:00:00 +00:00
Svilen Markov
becf34b0d9
Merge branch 'release/v0.7.0' into main 2024-11-16 06:49:11 +00:00
Svilen Markov
1bba915aef Update docs 2024-11-16 06:37:09 +00:00
Svilen Markov
f7239137d6 Update icons implementation to use custom type 2024-11-16 06:37:05 +00:00
Doc Em
d656986413 Add DashboardIcon (PNG) Support
- Refactored to make better naming sense
- Added capability to select between the PNG and SVG offerings of DashboardIcons
2024-11-14 20:06:54 +11:00
2Q2C0DE
d0c4e9d846 Add Dashboard Icons prefix support
- defined new type IconSource and constants, presently supporting:
  - LocalFile
  - SimpleIcon
  - DashboardIcon
- added new field to bookmarks and monitors to hold IconSource
- adjusted IsSimpleIcon to get truthiness from IconSource field
- generalised `toSimpleIconIfPrefixed` into `toRemoteResourceIconIfPrefixed`,
  adding support for `walkxcode`'s dashboard icons via CDN (svg)
2024-11-14 18:30:41 +11:00
Svilen Markov
bacb607d90 Allow using env variables in custom API URL 2024-11-03 04:46:24 +00:00
Svilen Markov
3914e24b3d Update twitch channel stream preview popover 2024-11-01 22:57:34 +00:00
Svilen Markov
4582be1da5 Allow changing horizontal offset of popover 2024-11-01 22:57:06 +00:00
Svilen Markov
c3c7f8b14f Allow changing max columns on split columns widget 2024-10-31 18:23:56 +00:00
Svilen Markov
863d4f117b
Merge pull request #251 from ehaughee/market-name-hovertext
Add title prop to Market Name for full text of truncated names on hover
2024-10-30 23:57:11 +00:00
Eric Haughee
35038ed56b Add title prop to Market Name for full text of truncated names on hover 2024-10-30 15:25:19 -07:00
Svilen Markov
c4149f5d94
Merge pull request #248 from oliver-mitchell/fix-non-whole-hour-timezone-display-on-clock
fix: non-whole-hour timezones now correctly shown on Clock widget
2024-10-30 15:48:24 +00:00
Svilen Markov
de2db71e54 Simplify clock zone diff text and add title for more details 2024-10-30 15:30:34 +00:00
Svilen Markov
2e3ed896cc Give clock time min width to avoid inconsistent layout for 12 hour format 2024-10-30 15:25:30 +00:00
Ralph Ocdol
f6f352e9e0 fix: indention 2024-10-29 18:15:28 +08:00
Ralph Ocdol
33dede8abc feat: added auto expand mobile page navigation 2024-10-29 18:13:24 +08:00
Oliver Mitchell
1e2e66ecf7 fix: non-whole-hour timezones now correctly shown on Clock
Timezones that used non-whole-hour definitions (ie. Australia/ACDT) not correctly shown on the Clock widget. This fix changes how the difference is calculated to account for both minutes, and hours.

Due to the change, the text shown for relative timezone differences also had to be changed.
2024-10-29 00:00:37 +10:30
Svilen Markov
6e5140d859 Allow specifying headers in RSS feeds 2024-10-21 23:49:09 +01:00
Svilen Markov
84a7f90129 Add custom API widget 2024-10-21 23:27:25 +01:00
Luca Crema
aff02c2def
Added HEALTHCHECK to Dockerfile 2024-10-18 14:07:24 +02:00
Svilen Markov
2dd5b29303 Remove unused class 2024-10-18 11:10:00 +01:00
Svilen Markov
bc8f17393a Fix simple icons URL 2024-10-18 10:54:34 +01:00
Svilen Markov
72f78f1c8d
Merge pull request #240 from cmeadowstech/alt-status-codes
Add alternative status code option to monitor widget
2024-10-18 10:53:18 +01:00
Svilen Markov
201b4ca1e8 Use AltStatusCodes for HasFailing check 2024-10-18 10:51:46 +01:00
Cody Meadows
04811a0658 Merge remote-tracking branch 'upstream/release/v0.7.0' into alt-status-codes
Update to current release
2024-10-17 22:01:56 +00:00
Cody Meadows
ee94d6aa89 Add alternative status code prameter 2024-10-17 21:58:18 +00:00
Svilen Markov
3382cd6208 Show twitch stream preview and title on avatar hover 2024-10-17 21:53:07 +01:00
Svilen Markov
56f9ec1d10 Include title in twitch channel data 2024-10-17 21:37:32 +01:00
Svilen Markov
1b42312d02 Fix edge case in popover 2024-10-17 21:36:41 +01:00
Svilen Markov
8a41a9f506 Improve formatting of request body 2024-10-17 21:36:22 +01:00
Svilen Markov
18bb2d7501 Change simple icons provider 2024-10-16 10:58:09 +01:00
Svilen Markov
6f8e576c9b Add fallback-content-type property 2024-10-16 10:52:57 +01:00
Svilen Markov
e5bb102ab1 Add split-column widget 2024-10-15 18:05:29 +01:00
Svilen Markov
13700fe2b2 Change masonry options source 2024-10-15 16:04:22 +01:00
Svilen Markov
3cfbe65855 Make the first full column the primary on mobile rather than the last 2024-10-15 15:58:30 +01:00
Svilen Markov
ab0b11cc92 Add masonry layout functionality 2024-10-13 18:33:26 +01:00
Svilen Markov
17c8071de9 Extract group widget functionality into generic widget container 2024-10-13 18:30:47 +01:00
Svilen Markov
1fe7f61ec8 Change environment variables behavior
* Can now be inserted anywhere in the string
* Can now insert multiple environment variables in a single string
* Can now be escaped if prefixed with \
2024-10-13 17:12:59 +01:00
Svilen Markov
ea3b8124fc Remove unused class 2024-10-13 17:08:52 +01:00
Svilen Markov
c41cfadb19
Merge pull request #231 from ehaughee/markets-sort-by-change
Add Markets sort-by: change to sort by percent change
2024-10-04 11:09:59 +01:00
Eric Haughee
e434fe0847 Add Markets sort-by: change to sort by percent change 2024-10-03 11:26:33 -07:00
Svilen Markov
8a8aaa752e Fix edge case in weather widget
If the temperature for the entire day is the same the range is 0, divide by 0 no bueno
2024-10-02 10:40:25 +01:00
Svilen Markov
4420d1df2c Bump versions & update dependencies 2024-10-01 15:47:56 +01:00
Svilen Markov
d90d39933a
Merge pull request #228 from Dakhtara/patch-1
Fix typo on configuration for search autofocus property description
2024-10-01 15:28:42 +01:00
Anthony
7e38ab624a
Update configuration.md 2024-10-01 08:59:11 +02:00
Svilen Markov
9a36187333 Add /api/healthz endpoint 2024-09-30 00:42:19 +01:00
Svilen Markov
b0c4d70628 Fix bug with collapsible grids inside of group widget
Also move utils to new file
2024-09-30 00:42:19 +01:00
Svilen Markov
d5fa6424c1 Fix bookmark icons shrinking when text wraps 2024-09-30 00:42:19 +01:00
Svilen Markov
07fe5b3cb1
Merge pull request #224 from SimJunYou/piholePrivacy
DNS Stats widget - fix Pihole privacy edge case
2024-09-30 00:31:00 +01:00
Svilen Markov
672547cd07 Switch to using custom type for pihole's top blocked domains 2024-09-30 00:24:44 +01:00
SimJunYou
28167403a4 Cover edge case when Pihole's privacy settings are enabled 2024-09-19 17:43:43 +08:00
Svilen Markov
eaf08d42dd
Merge pull request #220 from paricbat/main
Add important details about the server.base-url property
2024-09-16 08:35:33 +01:00
Veronika Bušová
bae95a5e07
Add important details about the server.base-url property 2024-09-15 00:29:46 +02:00
Svilen Markov
db1ed9e257 Fix & refactor adguard stats 2024-09-10 05:38:14 +01:00
Svilen Markov
adef35049f Add warning when pihole doesn't return expected data points 2024-09-10 05:38:14 +01:00
Svilen Markov
bd9abf2e52 Fix mobile nav links wrapping 2024-09-10 05:38:14 +01:00
Svilen Markov
aa74c00fd4
Merge pull request #210 from micash545/main
releases: Add support for Codeberg
2024-09-08 19:36:20 +01:00
Svilen Markov
a68a907fa7 Update SVG 2024-09-08 19:35:01 +01:00
Svilen Markov
1a05d6d03a Don't export function 2024-09-08 19:34:43 +01:00
micash
60f4183057 add codeberg releases 2024-09-08 16:59:24 +02:00
Svilen Markov
04c3afc850 Document center-vertically property 2024-09-08 06:25:06 +01:00
Svilen Markov
066e512844
Merge pull request #206 from glanceapp/release/v0.6.0
Release/v0.6.0
2024-09-08 04:18:11 +01:00
Svilen Markov
d60457afaf Merge branch 'main' into release/v0.6.0 2024-09-08 04:17:23 +01:00
Svilen Markov
f5a788220e Add link to Discord server 2024-09-08 01:13:26 +01:00
Svilen Markov
2fd5edc6e4 Update CSS 2024-09-08 00:43:26 +01:00
Svilen Markov
26716d0b89 Add preconfigured pages 2024-09-08 00:39:48 +01:00
Svilen Markov
3e0f57d0c7 Update README images 2024-09-07 23:13:15 +01:00
Svilen Markov
5347717f3d Ignore all yml files starting with glance 2024-09-07 23:12:12 +01:00
Svilen Markov
6b0a569998 Limit width of widgets 2024-09-06 21:45:03 +01:00
Svilen Markov
4476391287 Fix missing class 2024-09-06 20:33:04 +01:00
Svilen Markov
16a9d8c244 Update group widget styling 2024-09-06 20:32:20 +01:00
Svilen Markov
bbda9a0ee8 Add single-line-titles property to RSS widget 2024-09-04 23:02:16 +01:00
Svilen Markov
776fdcc6ce Merge branch 'release/v0.6.0' into features 2024-09-03 00:26:33 +01:00
Svilen Markov
f76e06ec57 Fix videos widget channel URL 2024-09-01 02:27:09 +01:00
Svilen Markov
3a9cff697b Add and fix monitor widget class 2024-08-31 18:51:59 +01:00
Svilen Markov
e6979c77e3 Update dynamic columns & remove the need to specify style 2024-08-31 18:37:08 +01:00
Svilen Markov
99866507f5 Fix overflowing text in monitor widget 2024-08-31 17:35:53 +01:00
Svilen Markov
cf0dd07c21 Add center-vertically property 2024-08-31 17:35:38 +01:00
Svilen Markov
b9bf8c6c96 Fix version formatting in releases widget 2024-08-30 16:19:20 +01:00
Svilen Markov
725d0da15d Add show-failing-only property to the monitor widget 2024-08-29 21:14:50 +01:00
Svilen Markov
37164070d2 Merge branch 'release/v0.6.0' into features 2024-08-29 20:06:40 +01:00
Svilen Markov
d7bd34531f
Merge pull request #77 from aharivel/github-commit
Add last Github commits
2024-08-29 18:19:19 +01:00
Svilen Markov
71ca1753ef Update repository widget last commits 2024-08-29 18:18:38 +01:00
Svilen Markov
c6bfbf4ac1
Merge branch 'release/v0.6.0' into github-commit 2024-08-29 17:40:14 +01:00
Svilen Markov
a27fde72ee Fix group widget 2024-08-27 21:41:18 +01:00
Svilen Markov
785f6a36bf Increase assets cache time 2024-08-27 17:29:31 +01:00
Svilen Markov
1a4c12d851 Fix hardcoded release icons 2024-08-27 17:17:41 +01:00
Svilen Markov
b484e32b08 Improve dockerhub releases 2024-08-27 05:19:35 +01:00
Svilen Markov
303438834b
Merge pull request #165 from Fumesover/release/v0.6.0
releases: Add support for gitlab
2024-08-27 03:37:45 +01:00
Svilen Markov
70b7b7615a Fix capitalization 2024-08-27 03:36:36 +01:00
Svilen Markov
5c42e60faf Update docs 2024-08-27 03:35:35 +01:00
Svilen Markov
48ef60e0eb Add todo 2024-08-27 03:33:37 +01:00
Svilen Markov
01af97ddab Allow fetching releases from multiple sources 2024-08-27 03:26:16 +01:00
Svilen Markov
038794fa1c
Merge pull request #75 from realdavidops/main
Allow some branding customization.
2024-08-27 00:18:04 +01:00
Svilen Markov
ffb1bccb10 Move default logo text to the template 2024-08-27 00:15:55 +01:00
Svilen Markov
371eb3bee6 Change styling of custom logo 2024-08-27 00:14:31 +01:00
Svilen Markov
466d935d02 Update docs 2024-08-27 00:09:35 +01:00
Svilen Markov
7ffca9c3f1 Allow setting custom footer 2024-08-27 00:09:26 +01:00
Svilen Markov
a5981b3106 Fix RSS thumbnails where feed image is absolute 2024-08-26 23:50:37 +01:00
Svilen Markov
1df080983a Add DNS Stats widget 2024-08-22 23:11:45 +01:00
Svilen Markov
822b72eee4 Delay showing next popover by 2 frames
This resets the CSS animation so it can be played again, otherwise it gets skipped
2024-08-22 22:44:27 +01:00
Svilen Markov
014abdcc00 Allow setting text-align on popovers 2024-08-22 22:43:41 +01:00
Svilen Markov
b35cc437d3 Remove unused functions 2024-08-22 22:43:10 +01:00
Svilen Markov
7ce87c7168
Merge pull request #197 from ccjjxl/bugfix_rss
Fix RSS item link empty <link/>
2024-08-22 11:16:33 +01:00
ccjjxl
2a3f8eedf6 Fix RSS item link empty <link/> 2024-08-22 17:24:00 +08:00
Svilen Markov
895c3f2f97 Allow changing popover anchor 2024-08-20 16:19:21 +01:00
Svilen Markov
8860f4591b Allow showing popover above & tweaks 2024-08-20 13:29:44 +01:00
Svilen Markov
984f26807e Remove duplicate rule & add non-prefixed line-clamp 2024-08-19 14:24:05 +01:00
Svilen Markov
88c58e6108 Add <details> styling 2024-08-19 14:03:48 +01:00
Svilen Markov
83c7c4a14a Add popover functionality 2024-08-19 02:15:22 +01:00
Svilen Markov
b4a4df480e Add utility classes 2024-08-18 18:26:56 +01:00
Svilen Markov
9843b4d218 Fix armv7 docker image 2024-08-15 13:33:42 +01:00
Svilen Markov
c1251af597 Tidy up 2024-08-11 14:13:00 +01:00
Svilen Markov
7d09b8bf1b Fix reddit crossposts & add show-flairs property 2024-08-11 14:04:18 +01:00
Svilen Markov
f57bdeec12 Update branding config 2024-08-10 19:43:50 +01:00
Svilen Markov
9899f6b761
Merge branch 'release/v0.6.0' into main 2024-08-10 18:47:39 +01:00
Svilen Markov
328e10b89f Don't show error if RSS feeds didn't return any items 2024-08-10 18:01:54 +01:00
Svilen Markov
b37f8a8375
Merge pull request #194 from glanceapp/backport-fixes
Backport fixes from v0.6.0 to v0.5.0
2024-08-09 16:08:30 +01:00
Svilen Markov
139937f887 Fix missing RSS thumbnail images 2024-08-09 16:03:24 +01:00
Wyatt Gill
b8b90451b6 Use GitHub's latest release API endpoint
The current releases widget uses the releases endpoint to pull the 10
most recent releases and filter them to find the latest release. This
causes a problem when a repository's latest release is outside of the 10
most recent (e.g. 10 prereleases):

ERROR No live release found repository=cross-seed/cross-seed url="https://api.github.com/repos/cross-seed/cross-seed/releases?per_page=10"

This is no longer a problem when using the latest release endpoint which
grabs the latest release, ignoring draft releases and prereleases.
2024-08-09 16:02:15 +01:00
Svilen Markov
c9a2aff6a6 Fix search input color 2024-08-09 16:01:15 +01:00
Svilen Markov
b25b117717 Reduce mb on widget header 2024-08-09 11:25:11 +01:00
Svilen Markov
e174fd731c Fix size-title-dynamic class 2024-08-08 16:55:46 +01:00
Svilen Markov
34e6d463be Make forum post title size dynamic 2024-08-08 16:37:41 +01:00
Svilen Markov
808169f43c Make horizontal scrollbars thinner 2024-08-08 16:00:41 +01:00
Svilen Markov
82b6531a07 Add funding 2024-08-07 19:18:17 +01:00
Svilen Markov
917e2cabfc Merge branch 'main' into release/v0.6.0 2024-08-07 19:03:12 +01:00
Svilen Markov
a981025679
Merge pull request #108 from wfg/github-actions
Create release workflow
2024-08-07 18:58:13 +01:00
Svilen Markov
f9ea2d73e1 Tidy up previous build files 2024-08-07 18:50:06 +01:00
Svilen Markov
a5728e9407 Update release workflow 2024-08-07 18:47:50 +01:00
Svilen Markov
404a2acc8f Remove extra margin from mobile nav offset 2024-08-07 11:16:30 +01:00
Svilen Markov
7bf61ee8ed Remove app name from title 2024-08-07 04:05:10 +01:00
Svilen Markov
bf1cc47525 Fix & increase offset of loading indicator 2024-08-05 23:16:32 +01:00
Svilen Markov
74ad27d5fb Rejig CSS 2024-08-05 22:50:44 +01:00
Svilen Markov
60b73aff24
Merge pull request #167 from CremaLuca/main
feat: serving behind reverse proxy
2024-08-05 14:12:20 +01:00
Svilen Markov
4de465e544 Add some foolproofery 2024-08-05 14:11:19 +01:00
Svilen Markov
e1161b9227 Refactor base-url & add documentation 2024-08-05 14:08:16 +01:00
Svilen Markov
c2cdd0fa08
Merge branch 'release/v0.6.0' into main 2024-08-05 13:26:46 +01:00
Svilen Markov
b365a03e71 Allow hiding desktop navigation 2024-08-05 12:42:41 +01:00
Svilen Markov
2e629efc7f Remove link from footer 2024-08-05 12:28:08 +01:00
Svilen Markov
fcaa2bef6e Vertically center loading indicator 2024-08-05 12:15:55 +01:00
Svilen Markov
3b2a9fedef Move footer to bottom of viewport 2024-08-05 12:00:20 +01:00
Svilen Markov
64256f2638 Fix value 2024-08-05 11:40:32 +01:00
Svilen Markov
5c8122d93b Fix extra margin at bottom of page 2024-08-05 11:17:46 +01:00
Svilen Markov
6ffb95779d Don't force scrollbar when not needed 2024-08-05 11:12:25 +01:00
Svilen Markov
af975d0e7f Bump versions 2024-08-05 00:00:11 +01:00
Svilen Markov
34b2b88cf3 Merge branch 'main' into release/v0.6.0 2024-08-04 23:46:28 +01:00
Svilen Markov
3fea166274 Add page width customizability 2024-08-04 23:40:58 +01:00
Svilen Markov
233b905492
Merge pull request #177 from MrExplode/monitor-urls
Different monitor urls
2024-08-04 20:55:02 +01:00
Svilen Markov
7afad765cb Use hyphen instead of underscore 2024-08-04 20:54:30 +01:00
MrExplode
39663cb594
Document monitor URL changes 2024-08-03 14:14:42 +02:00
MrExplode
457e0539fa
Allow empty check URL 2024-08-03 14:14:24 +02:00
MrExplode
871ba619a3
Add separate URL for monitor check 2024-08-03 14:06:52 +02:00
Svilen Markov
f5427310e8 Document include-shorts 2024-08-03 04:25:07 +01:00
Svilen Markov
e4435252f4
Merge pull request #176 from 3rd/release/v0.6.0
feat: add no_shorts option for YouTube feeds
2024-08-03 04:05:56 +01:00
Svilen Markov
a66aa74c7f Invert property 2024-08-03 04:04:02 +01:00
Svilen Markov
3949e15f77 Remove previous scuffed attempt at filtering shorts 2024-08-03 04:03:16 +01:00
Svilen Markov
acaf50bf8a Use author > name instead of title
This is because the playlist response returns the name of the playlist in the title,
which meant that the channel name was always "Videos"
2024-08-03 04:02:23 +01:00
3rd
09eedc08c1 feat: add no_shorts option for YouTube feeds 2024-08-02 23:39:27 +03:00
Svilen Markov
738bcf8bcb Add group widget 2024-08-01 21:34:07 +01:00
Svilen Markov
795caa5d9d Allow widgets to handle HTTP requests 2024-08-01 18:26:38 +01:00
Svilen Markov
c041197f3f
Merge pull request #173 from DVDAndroid/dvd/assets-fixes
Fix crash when using a custom CSS file; fix error 404 for manifest.json
2024-07-26 16:30:55 +01:00
dvdandroid
0730789cb3 Fix crash when using a custom CSS file; fix error 404 for manifest.json 2024-07-25 16:57:10 +02:00
Svilen Markov
1eb0fbd2c9
Merge pull request #171 from DVDAndroid/dvd/twich-nocategory-fix
Fix incorrect LiveSince if API returned no game
2024-07-25 00:42:59 +01:00
Svilen Markov
0484122b7f
Merge pull request #172 from DVDAndroid/dvd/clickable-twitch-avatar
Make Twitch avatar clickable
2024-07-25 00:42:46 +01:00
dvdandroid
7bc9c704d1 Don't render "empty" game category 2024-07-24 21:50:00 +02:00
dvdandroid
c9efdc2c16 Fix incorrect LiveSince if API returned no game
(streamer can stream without a category/game associated)
2024-07-24 21:38:35 +02:00
dvdandroid
e0dee998d4 Make Twitch avatar clickable 2024-07-23 23:17:28 +02:00
CremaLuca
23d6d41148 fix(serve): using paths without base url 2024-07-10 15:40:35 +02:00
Luca Crema
a07fbfd7b7 Added baseurl somewhere 2024-07-10 15:35:25 +02:00
Svilen Markov
1411268e35
Merge pull request #80 from c0smicdev/main
Add Kanagawa Dark theme
2024-07-03 19:51:42 +01:00
Albin Parou
7d1ede8c91 releases: Add support for gitlab 2024-07-03 08:20:02 +02:00
c0smic
645802981e
Update theme preview 2024-06-14 16:56:33 +00:00
c0smic
0348a2475c
Delete preview of old version 2024-06-14 16:55:44 +00:00
Wyatt Gill
d5cf881d7a Produce example 2024-06-03 16:16:43 -05:00
Wyatt Gill
050d3e6cd2 Add release workflow using GoReleaser 2024-06-03 16:16:43 -05:00
c0smic
d27993d2c9
fix: use negative color with higher contrast 2024-05-29 16:53:31 +00:00
c0smic
bfc65d51e3
Merge branch 'glanceapp:main' into main 2024-05-29 16:50:18 +00:00
c0smic
3c29d38599
fix: Fix typo
Co-authored-by: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com>
2024-05-27 18:11:35 +00:00
c0smic
060609e2cb
Add Kanagawa Dark to themes 2024-05-18 14:25:08 +00:00
c0smic
a1c48bf012
Use new Kanagawa Dark Image 2024-05-18 14:24:15 +00:00
c0smic
a67fb66d2e
Delete old Kanagawa Dark image 2024-05-18 14:23:32 +00:00
c0smic
3001f4845a
Add new Kanagawa Dark image 2024-05-18 14:20:17 +00:00
c0smic
192c1ad669
Rename kanagawa dark preview image 2024-05-18 14:15:35 +00:00
c0smic
89ae8a3607
Add Kanagawa Dark Theme preview 2024-05-18 14:13:56 +00:00
Anthony Harivel
90d718c186
Add last Github commits
Signed-off-by: Anthony Harivel <aharivel@redhat.com>
2024-05-18 11:51:44 +02:00
David Leonard
3cfbe0c89b Allow some branding customization. 2024-05-17 14:37:05 -07:00
192 changed files with 11264 additions and 4549 deletions

View file

@ -5,6 +5,7 @@
# Only add necessary files to the Docker build context (Dockerfiles are always included implicitly)
!/build/
!/internal/
!/pkg/
!/go.mod
!/go.sum
!main.go

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
github: [glanceapp]

37
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View 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
View 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

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

View file

@ -1,7 +1 @@
<!--
If your pull request adds new features or changes existing ones please use the latest release/* branch as the base.
Documentation updates (including new themes) can be submitted to the main branch.
-->
<!-- 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 -->

39
.github/workflows/release.yaml vendored Normal file
View 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
View file

@ -1,4 +1,5 @@
/assets
/build
/playground
glance.yml
/.idea
/glance*.yml

69
.goreleaser.yaml Normal file
View 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

View file

@ -1,11 +1,16 @@
FROM alpine:3.20.1
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
FROM golang:1.23.6-alpine3.21 AS builder
WORKDIR /app
COPY build/glance-$TARGETOS-$TARGETARCH${TARGETVARIANT} /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
View 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"]

View file

@ -1,14 +0,0 @@
FROM golang:1.22.3-alpine3.19 AS builder
WORKDIR /app
COPY . /app
RUN CGO_ENABLED=0 go build .
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /app/glance .
EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance"]

426
README.md
View file

@ -1,111 +1,405 @@
<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="docs/preconfigured-pages.md">Preconfigured pages</a><a href="docs/themes.md">Themes</a><a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a></p>
![example homepage](docs/images/readme-main-image.png)
![](docs/images/readme-main-image.png)
### Features
#### Various widgets
## Features
### Various widgets
* RSS feeds
* Subreddit posts
* Weather
* Bookmarks
* Hacker News
* Lobsters
* Latest YouTube videos from specific channels
* Clock
* Calendar
* Stocks
* iframe
* Twitch channels & top games
* GitHub releases
* Repository overview
* Site monitor
* Search box
* 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
![multiple color schemes example](docs/images/themes-example.png)
### 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
![mobile device previews](docs/images/mobile-preview.png)
### 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.
![](docs/images/mobile-preview.png)
### 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:
![](docs/images/themes-example.png)
<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
```
</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
```
*[click here to view the files that will be created](https://github.com/glanceapp/docker-compose-template/tree/main/root)*
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
```
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
volumes:
- ./config:/app/config
ports:
- 8080:8080
```
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:
```bash
mkdir config && wget -O config/glance.yml https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
```
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
```
#### Docker
> [!IMPORTANT]
>
> Make sure you have a valid `glance.yml` file in the same directory before running the container.
To grab a starting template for the config file, run:
```bash
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
wget https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
```
Or if you prefer docker compose:
### Windows
```yaml
services:
glance:
image: glanceapp/glance
volumes:
- ./glance.yml:/app/glance.yml
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- 8080:8080
restart: unless-stopped
```
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.
### Building from source
Requirements: [Go](https://go.dev/dl/) >= v1.22
To build:
<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>
## 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 run:
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>
### Building Docker image
<details>
<summary><strong>Build project and Docker image with Docker</strong></summary>
<br>
Build the image:
Requirements: [Docker](https://docs.docker.com/engine/install/)
**Make sure to replace "owner" with your name or organization.**
To build the project and image using just Docker, run:
*(replace `owner` with your name or organization)*
```bash
docker build -t owner/glance:latest -f Dockerfile.single-platform .
docker build -t owner/glance:latest .
```
Push the image to your registry:
If you wish to push the image to a registry (by default Docker Hub), run:
```bash
docker push owner/glance:latest
```
<hr>
</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>
## 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.

File diff suppressed because it is too large Load diff

296
docs/custom-api.md Normal file
View file

@ -0,0 +1,296 @@
[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, however all numbers must be converted to floats first if they are not already:
```html
<div>{{ sub (.JSON.Int "price" | toFloat) (.JSON.Int "discount" | toFloat) }}</div>
```
Output:
```html
<div>90</div>
```
Other operations include `add`, `mul`, and `div`.
<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>
```
## 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.
- `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.
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.

View file

@ -29,6 +29,9 @@ Used to specify the title of the widget. If not provided, the widget's title wil
### `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]

105
docs/glance.yml Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 946 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

226
docs/preconfigured-pages.md Normal file
View 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
![](images/startpage-preview.png)
<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
![](images/markets-page-preview.png)
<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
![](images/gaming-page-preview.png)
<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>

View file

@ -53,6 +53,26 @@ theme:
primary-color: 97 13 80
```
### Gruvbox Dark
![screenshot](images/themes/gruvbox.png)
```yaml
theme:
background-color: 0 0 16
primary-color: 43 59 81
positive-color: 61 66 44
negative-color: 6 96 59
```
### Kanagawa Dark
![screenshot](images/themes/kanagawa-dark.png)
```yaml
theme:
background-color: 240 13 14
primary-color: 51 33 68
negative-color: 358 100 68
contrast-multiplier: 1.2
```
### Tucan
![screenshot](images/themes/tucan.png)
```yaml

57
docs/v0.7.0-upgrade.md Normal file
View 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.

23
go.mod
View file

@ -1,19 +1,32 @@
module github.com/glanceapp/glance
go 1.22.3
go 1.23.6
require (
github.com/fsnotify/fsnotify v1.8.0
github.com/mmcdole/gofeed v1.3.0
golang.org/x/text v0.16.0
github.com/shirou/gopsutil/v4 v4.25.1
github.com/tidwall/gjson v1.18.0
golang.org/x/text v0.22.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/PuerkitoBio/goquery v1.9.2 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/PuerkitoBio/goquery v1.10.1 // 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/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // 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.27.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.14 // indirect
github.com/tklauser/numcpus v0.9.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.30.0 // indirect
)

89
go.sum
View file

@ -1,13 +1,24 @@
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
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/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/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/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 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
@ -19,47 +30,99 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
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/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
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.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
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/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-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.7.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/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/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.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
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.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
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/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=

View file

@ -1,56 +0,0 @@
package assets
import (
"crypto/md5"
"embed"
"encoding/hex"
"io"
"io/fs"
"log/slog"
"strconv"
"time"
)
//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")
func getFSHash(files fs.FS) string {
hash := md5.New()
err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
file, err := files.Open(path)
if err != nil {
return err
}
if _, err := io.Copy(hash, file); err != nil {
return err
}
return nil
})
if err == nil {
return hex.EncodeToString(hash.Sum(nil))[:10]
}
slog.Warn("Could not compute assets cache", "err", err)
return strconv.FormatInt(time.Now().Unix(), 10)
}
var PublicFSHash = getFSHash(PublicFS)

View file

@ -1,120 +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")
ClockTemplate = compileTemplate("clock.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")
ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html")
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
MarketsTemplate = compileTemplate("markets.html", "widget-base.html")
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html")
RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.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")
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
SearchTemplate = compileTemplate("search.html", "widget-base.html")
ExtensionTemplate = compileTemplate("extension.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))
}

View file

@ -1,37 +0,0 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
{{ if ne .Style "dynamic-columns-experimental" }}
<ul class="list list-gap-24 list-with-separator">
{{ range .Groups }}
<li class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
{{ template "group" . }}
</li>
{{ end }}
</ul>
{{ else }}
<div class="dynamic-columns">
{{ range .Groups }}
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
{{ template "group" . }}
</div>
{{ end }}
</div>
{{ end }}
{{ end }}
{{ define "group" }}
{{ 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 class="flex items-center gap-10">
{{ if ne "" .Icon }}
<div class="bookmarks-icon-container">
<img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
</div>
{{ end }}
<a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
</li>
{{ end }}
</ul>
{{ end }}

View file

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

View file

@ -1,5 +0,0 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
{{ .Extension.Content }}
{{ end }}

View file

@ -1,45 +0,0 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Posts }}
<li>
<div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
{{ if $.ShowThumbnails }}
{{ if ne .ThumbnailUrl "" }}
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
{{ else if .HasTargetUrl }}
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg>
{{ else }}
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
</svg>
{{ end }}
{{ end }}
<div class="grow min-width-0">
<a href="{{ .DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
{{ if gt (len .Tags) 0 }}
<div class="inline-block forum-post-tags-container">
<ul class="attachments">
{{ range .Tags }}
<li>{{ . }}</li>
{{ end }}
</ul>
</div>
{{ end }}
<ul class="list-horizontal-text">
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ .Score | formatNumber }} points</li>
<li>{{ .CommentCount | formatNumber }} comments</li>
{{ if .HasTargetUrl }}
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
{{ end }}
</ul>
</div>
</div>
</li>
{{ end }}
</ul>
{{ end }}

View file

@ -1,39 +0,0 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
{{ if ne .Style "dynamic-columns-experimental" }}
<ul class="list list-gap-20 list-with-separator">
{{ range .Markets }}
<li class="flex items-center gap-15">
{{ template "market" . }}
</li>
{{ end }}
</ul>
{{ else }}
<div class="dynamic-columns">
{{ range .Markets }}
<div class="flex items-center gap-15">
{{ template "market" . }}
</div>
{{ end }}
</div>
{{ end }}
{{ end }}
{{ define "market" }}
<div class="min-width-0">
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
<div class="text-truncate">{{ .Name }}</div>
</div>
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
<svg class="market-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>
</a>
<div class="market-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">{{ .Currency }}{{ .Price | formatPrice }}</div>
</div>
{{ end }}

View file

@ -1,53 +0,0 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
{{ if ne .Style "dynamic-columns-experimental" }}
<ul class="list list-gap-20 list-with-separator">
{{ range .Sites }}
<li class="monitor-site flex items-center gap-15">
{{ template "site" . }}
</li>
{{ end }}
</ul>
{{ else }}
<ul class="dynamic-columns">
{{ range .Sites }}
<div class="flex items-center gap-15">
{{ template "site" . }}
</div>
{{ end }}
</ul>
{{ end }}
{{ end }}
{{ define "site" }}
{{ if .IconUrl }}
<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy">
{{ end }}
<div>
<a class="size-h3 color-highlight" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text">
{{ if not .Status.Error }}
<li title="{{ .Status.Code }}">{{ .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 "ok" }}
<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 }}
{{ end }}

View file

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

View file

@ -1,67 +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" . }}
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
{{ end }}
{{ 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 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-content" id="page-content"></div>
<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>
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
</div>
<a class="color-primary block margin-top-5 size-h5" href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a>
</div>
{{ end }}

View file

@ -1,18 +0,0 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range $i, $release := .Releases }}
<li>
<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 {{ dynamicRelativeTimeAttrs $release.TimeReleased }}></li>
<li>{{ $release.Version }}</li>
{{ if gt $release.Downvotes 3 }}
<li>{{ $release.Downvotes | formatNumber }} ⚠</li>
{{ end }}
</ul>
</li>
{{ end }}
</ul>
{{ end }}

View file

@ -1,44 +0,0 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<a class="size-h4 color-highlight" href="https://github.com/{{ $.RepositoryDetails.Name }}" target="_blank" rel="noreferrer">{{ .RepositoryDetails.Name }}</a>
<ul class="list-horizontal-text">
<li>{{ .RepositoryDetails.Stars | formatNumber }} stars</li>
<li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
</ul>
{{ if gt (len .RepositoryDetails.PullRequests) 0 }}
<hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .RepositoryDetails.PullRequests }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .RepositoryDetails.PullRequests }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
{{ if gt (len .RepositoryDetails.Issues) 0 }}
<hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .RepositoryDetails.OpenIssues | formatNumber }} total)</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .RepositoryDetails.Issues }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .RepositoryDetails.Issues }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
{{ end }}

View file

@ -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 }}°{{ if eq .Units "metric" }}C{{ else }}F{{ end }}</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 }},{{ if .ShowAreaName }} {{ .Place.Area }},{{ end }} {{ .Place.Country }}</div>
</div>
{{ end }}
{{ end }}

View file

@ -1,21 +0,0 @@
<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
<div class="widget-header">
{{ if ne "" .TitleURL}}<a href="{{ .TitleURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>{{ else }}<div class="uppercase">{{ .Title }}</div>{{ end }}
{{ 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>

View file

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

View file

@ -1,97 +0,0 @@
package feed
import (
"fmt"
"html"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
)
type ExtensionType int
const (
ExtensionContentHTML ExtensionType = iota
ExtensionContentUnknown = iota
)
var ExtensionStringToType = map[string]ExtensionType{
"html": ExtensionContentHTML,
}
const (
ExtensionHeaderTitle = "Widget-Title"
ExtensionHeaderContentType = "Widget-Content-Type"
)
type ExtensionRequestOptions struct {
URL string `yaml:"url"`
Parameters map[string]string `yaml:"parameters"`
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
}
type Extension struct {
Title string
Content template.HTML
}
func convertExtensionContent(options ExtensionRequestOptions, content []byte, contentType ExtensionType) template.HTML {
switch contentType {
case ExtensionContentHTML:
if options.AllowHtml {
return template.HTML(content)
}
fallthrough
default:
return template.HTML(html.EscapeString(string(content)))
}
}
func FetchExtension(options ExtensionRequestOptions) (Extension, error) {
request, _ := http.NewRequest("GET", options.URL, nil)
query := url.Values{}
for key, value := range options.Parameters {
query.Set(key, value)
}
request.URL.RawQuery = query.Encode()
response, err := http.DefaultClient.Do(request)
if err != nil {
slog.Error("failed fetching extension", "error", err, "url", options.URL)
return Extension{}, fmt.Errorf("%w: request failed: %w", ErrNoContent, err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
slog.Error("failed reading response body of extension", "error", err, "url", options.URL)
return Extension{}, fmt.Errorf("%w: could not read body: %w", ErrNoContent, err)
}
extension := Extension{}
if response.Header.Get(ExtensionHeaderTitle) == "" {
extension.Title = "Extension"
} else {
extension.Title = response.Header.Get(ExtensionHeaderTitle)
}
contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)]
if !ok {
contentType = ExtensionContentUnknown
}
extension.Content = convertExtensionContent(options, body, contentType)
return extension, nil
}

View file

@ -1,229 +0,0 @@
package feed
import (
"fmt"
"log/slog"
"net/http"
"sync"
"time"
)
type githubReleaseLatestResponseJson struct {
TagName string `json:"tag_name"`
PublishedAt string `json:"published_at"`
HtmlUrl string `json:"html_url"`
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/latest", repository), nil)
if token != "" {
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
}
requests[i] = request
}
task := decodeJsonFromRequestTask[githubReleaseLatestResponseJson](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
}
liveRelease := &responses[i]
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
}
type GithubTicket struct {
Number int
CreatedAt time.Time
Title string
}
type RepositoryDetails struct {
Name string
Stars int
Forks int
OpenPullRequests int
PullRequests []GithubTicket
OpenIssues int
Issues []GithubTicket
}
type githubRepositoryDetailsResponseJson struct {
Name string `json:"full_name"`
Stars int `json:"stargazers_count"`
Forks int `json:"forks_count"`
}
type githubTicketResponseJson struct {
Count int `json:"total_count"`
Tickets []struct {
Number int `json:"number"`
CreatedAt string `json:"created_at"`
Title string `json:"title"`
} `json:"items"`
}
func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int) (RepositoryDetails, error) {
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
if err != nil {
return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err)
}
PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil)
issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil)
if token != "" {
token = fmt.Sprintf("Bearer %s", token)
repositoryRequest.Header.Add("Authorization", token)
PRsRequest.Header.Add("Authorization", token)
issuesRequest.Header.Add("Authorization", token)
}
var detailsResponse githubRepositoryDetailsResponseJson
var detailsErr error
var PRsResponse githubTicketResponseJson
var PRsErr error
var issuesResponse githubTicketResponseJson
var issuesErr error
var wg sync.WaitGroup
wg.Add(1)
go (func() {
defer wg.Done()
detailsResponse, detailsErr = decodeJsonFromRequest[githubRepositoryDetailsResponseJson](defaultClient, repositoryRequest)
})()
if maxPRs > 0 {
wg.Add(1)
go (func() {
defer wg.Done()
PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, PRsRequest)
})()
}
if maxIssues > 0 {
wg.Add(1)
go (func() {
defer wg.Done()
issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, issuesRequest)
})()
}
wg.Wait()
if detailsErr != nil {
return RepositoryDetails{}, fmt.Errorf("%w: could not get repository details: %s", ErrNoContent, detailsErr)
}
details := RepositoryDetails{
Name: detailsResponse.Name,
Stars: detailsResponse.Stars,
Forks: detailsResponse.Forks,
PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)),
}
err = nil
if maxPRs > 0 {
if PRsErr != nil {
err = fmt.Errorf("%w: could not get PRs: %s", ErrPartialContent, PRsErr)
} else {
details.OpenPullRequests = PRsResponse.Count
for i := range PRsResponse.Tickets {
details.PullRequests = append(details.PullRequests, GithubTicket{
Number: PRsResponse.Tickets[i].Number,
CreatedAt: parseGithubTime(PRsResponse.Tickets[i].CreatedAt),
Title: PRsResponse.Tickets[i].Title,
})
}
}
}
if maxIssues > 0 {
if issuesErr != nil {
// TODO: fix, overwriting the previous error
err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, issuesErr)
} else {
details.OpenIssues = issuesResponse.Count
for i := range issuesResponse.Tickets {
details.Issues = append(details.Issues, GithubTicket{
Number: issuesResponse.Tickets[i].Number,
CreatedAt: parseGithubTime(issuesResponse.Tickets[i].CreatedAt),
Title: issuesResponse.Tickets[i].Title,
})
}
}
}
return details, err
}

View file

@ -1,98 +0,0 @@
package feed
import (
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"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 getHackerNewsPostIds(sort string) ([]int, error) {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), 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, commentsUrlTemplate string) (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
}
var commentsUrl string
if commentsUrlTemplate == "" {
commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
} else {
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
}
posts = append(posts, ForumPost{
Title: results[i].Title,
DiscussionUrl: commentsUrl,
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 FetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (ForumPosts, error) {
postIds, err := getHackerNewsPostIds(sort)
if err != nil {
return nil, err
}
if len(postIds) > limit {
postIds = postIds[:limit]
}
return getHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
}

View file

@ -1,91 +0,0 @@
package feed
import (
"net/http"
"strings"
"time"
)
type lobstersPostResponseJson struct {
CreatedAt string `json:"created_at"`
Title string `json:"title"`
URL string `json:"url"`
Score int `json:"score"`
CommentCount int `json:"comment_count"`
CommentsURL string `json:"comments_url"`
Tags []string `json:"tags"`
}
type lobstersFeedResponseJson []lobstersPostResponseJson
func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) {
request, err := http.NewRequest("GET", feedUrl, nil)
if err != nil {
return nil, err
}
feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request)
if err != nil {
return nil, err
}
posts := make(ForumPosts, 0, len(feed))
for i := range feed {
createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt)
posts = append(posts, ForumPost{
Title: feed[i].Title,
DiscussionUrl: feed[i].CommentsURL,
TargetUrl: feed[i].URL,
TargetUrlDomain: extractDomainFromUrl(feed[i].URL),
CommentCount: feed[i].CommentCount,
Score: feed[i].Score,
TimePosted: createdAt,
Tags: feed[i].Tags,
})
}
if len(posts) == 0 {
return nil, ErrNoContent
}
return posts, nil
}
func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) {
var feedUrl string
if customURL != "" {
feedUrl = customURL
} else {
if instanceURL != "" {
instanceURL = strings.TrimRight(instanceURL, "/") + "/"
} else {
instanceURL = "https://lobste.rs/"
}
if sortBy == "hot" {
sortBy = "hottest"
} else if sortBy == "new" {
sortBy = "newest"
}
if len(tags) == 0 {
feedUrl = instanceURL + sortBy + ".json"
} else {
tags := strings.Join(tags, ",")
feedUrl = instanceURL + "t/" + tags + ".json"
}
}
posts, err := getLobstersPostsFromFeed(feedUrl)
if err != nil {
return nil, err
}
return posts, nil
}

View file

@ -1,70 +0,0 @@
package feed
import (
"context"
"errors"
"net/http"
"time"
)
type SiteStatusRequest struct {
URL string `yaml:"url"`
AllowInsecure bool `yaml:"allow-insecure"`
}
type SiteStatus struct {
Code int
TimedOut bool
ResponseTime time.Duration
Error error
}
func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
request, err := http.NewRequest(http.MethodGet, statusRequest.URL, nil)
if err != nil {
return SiteStatus{
Error: err,
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel()
request = request.WithContext(ctx)
requestSentAt := time.Now()
var response *http.Response
if !statusRequest.AllowInsecure {
response, err = defaultClient.Do(request)
} else {
response, err = defaultInsecureClient.Do(request)
}
status := SiteStatus{ResponseTime: time.Since(requestSentAt)}
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
status.TimedOut = true
}
status.Error = err
return status, nil
}
defer response.Body.Close()
status.Code = response.StatusCode
return status, nil
}
func FetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) {
job := newJob(getSiteStatusTask, requests).withWorkers(20)
results, _, err := workerPoolDo(job)
if err != nil {
return nil, err
}
return results, nil
}

View file

@ -1,216 +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
Tags []string
}
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
var currencyToSymbol = map[string]string{
"USD": "$",
"EUR": "€",
"JPY": "¥",
"CAD": "C$",
"AUD": "A$",
"GBP": "£",
"CHF": "Fr",
"NZD": "N$",
"INR": "₹",
"BRL": "R$",
"RUB": "₽",
"TRY": "₺",
"ZAR": "R",
"CNY": "¥",
"KRW": "₩",
"HKD": "HK$",
"SGD": "S$",
"SEK": "kr",
"NOK": "kr",
"DKK": "kr",
"PLN": "zł",
"PHP": "₱",
}
type MarketRequest struct {
Name string `yaml:"name"`
Symbol string `yaml:"symbol"`
ChartLink string `yaml:"chart-link"`
SymbolLink string `yaml:"symbol-link"`
}
type Market struct {
MarketRequest
Currency string `yaml:"-"`
Price float64 `yaml:"-"`
PercentChange float64 `yaml:"-"`
SvgChartPoints string `yaml:"-"`
}
type Markets []Market
func (t Markets) 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
}

View file

@ -1,114 +0,0 @@
package feed
import (
"fmt"
"html"
"net/http"
"net/url"
"strings"
"time"
)
type subredditResponseJson struct {
Data struct {
Children []struct {
Data struct {
Id string `json:"id"`
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, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string) (ForumPosts, error) {
query := url.Values{}
var requestUrl string
if search != "" {
query.Set("q", search+" subreddit:"+subreddit)
query.Set("sort", sort)
}
if sort == "top" {
query.Set("t", topPeriod)
}
if search != "" {
requestUrl = fmt.Sprintf("https://www.reddit.com/search.json?%s", query.Encode())
} else {
requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode())
}
if requestUrlTemplate != "" {
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
}
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
}
var commentsUrl string
if commentsUrlTemplate == "" {
commentsUrl = "https://www.reddit.com" + post.Permalink
} else {
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{SUBREDDIT}", subreddit)
commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-ID}", post.Id)
commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-PATH}", strings.TrimLeft(post.Permalink, "/"))
}
forumPost := ForumPost{
Title: html.UnescapeString(post.Title),
DiscussionUrl: commentsUrl,
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)
}
return posts, nil
}

View file

@ -1,242 +0,0 @@
package feed
import (
"context"
"fmt"
"html"
"log/slog"
"net/url"
"regexp"
"sort"
"strings"
"time"
"github.com/mmcdole/gofeed"
gofeedext "github.com/mmcdole/gofeed/extensions"
)
type RSSFeedItem struct {
ChannelName string
ChannelURL string
Title string
Link string
ImageURL string
Categories []string
Description string
PublishedAt time.Time
}
// doesn't cover all cases but works the vast majority of the time
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
func sanitizeFeedDescription(description string) string {
if description == "" {
return ""
}
description = strings.ReplaceAll(description, "\n", " ")
description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
description = strings.TrimSpace(description)
description = html.UnescapeString(description)
return description
}
func shortenFeedDescriptionLen(description string, maxLen int) string {
description, _ = limitStringLength(description, 1000)
description = sanitizeFeedDescription(description)
description, limited := limitStringLength(description, maxLen)
if limited {
description += "…"
}
return description
}
type RSSFeedRequest struct {
Url string `yaml:"url"`
Title string `yaml:"title"`
HideCategories bool `yaml:"hide-categories"`
HideDescription bool `yaml:"hide-description"`
ItemLinkPrefix string `yaml:"item-link-prefix"`
IsDetailed bool `yaml:"-"`
}
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,
}
if request.ItemLinkPrefix != "" {
rssItem.Link = request.ItemLinkPrefix + item.Link
} else if strings.HasPrefix(item.Link, "http://") || strings.HasPrefix(item.Link, "https://") {
rssItem.Link = item.Link
} else {
parsedUrl, err := url.Parse(feed.Link)
if err != nil {
parsedUrl, err = url.Parse(request.Url)
}
if err == nil {
var link string
if item.Link[0] == '/' {
link = item.Link
} else {
link = "/" + item.Link
}
rssItem.Link = parsedUrl.Scheme + "://" + parsedUrl.Host + link
}
}
if item.Title != "" {
rssItem.Title = item.Title
} else {
rssItem.Title = shortenFeedDescriptionLen(item.Description, 100)
}
if request.IsDetailed {
if !request.HideDescription && item.Description != "" && item.Title != "" {
rssItem.Description = shortenFeedDescriptionLen(item.Description, 200)
}
if !request.HideCategories {
var categories = make([]string, 0, 6)
for _, category := range item.Categories {
if len(categories) == 6 {
break
}
if len(category) == 0 || len(category) > 30 {
continue
}
categories = append(categories, category)
}
rssItem.Categories = categories
}
}
if request.Title != "" {
rssItem.ChannelName = request.Title
} else {
rssItem.ChannelName = feed.Title
}
if item.Image != nil {
rssItem.ImageURL = item.Image.URL
} else if url := findThumbnailInItemExtensions(item); url != "" {
rssItem.ImageURL = 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 recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extension) string {
for _, exts := range extensions {
for _, ext := range exts {
if ext.Name == "thumbnail" || ext.Name == "image" {
if url, ok := ext.Attrs["url"]; ok {
return url
}
}
if ext.Children != nil {
if url := recursiveFindThumbnailInExtensions(ext.Children); url != "" {
return url
}
}
}
}
return ""
}
func findThumbnailInItemExtensions(item *gofeed.Item) string {
media, ok := item.Extensions["media"]
if !ok {
return ""
}
return recursiveFindThumbnailInExtensions(media)
}
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
}

View file

@ -1,254 +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
})
}
func (channels TwitchChannels) SortByLive() {
sort.SliceStable(channels, func(i, j int) bool {
return channels[i].IsLive && !channels[j].IsLive
})
}
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
}

View file

@ -1,97 +0,0 @@
package feed
import (
"errors"
"fmt"
"net/url"
"regexp"
"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(strings.ToLower(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
}
var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
func stripURLScheme(url string) string {
return urlSchemePattern.ReplaceAllString(url, "")
}
func limitStringLength(s string, max int) (string, bool) {
asRunes := []rune(s)
if len(asRunes) > max {
return string(asRunes[:max]), true
}
return s, false
}

View file

@ -1,104 +0,0 @@
package feed
import (
"fmt"
"log/slog"
"net/http"
)
type marketResponseJson struct {
Chart struct {
Result []struct {
Meta struct {
Currency string `json:"currency"`
Symbol string `json:"symbol"`
RegularMarketPrice float64 `json:"regularMarketPrice"`
ChartPreviousClose float64 `json:"chartPreviousClose"`
} `json:"meta"`
Indicators struct {
Quote []struct {
Close []float64 `json:"close,omitempty"`
} `json:"quote"`
} `json:"indicators"`
} `json:"result"`
} `json:"chart"`
}
// TODO: allow changing chart time frame
const marketChartDays = 21
func FetchMarketsDataFromYahoo(marketRequests []MarketRequest) (Markets, error) {
requests := make([]*http.Request, 0, len(marketRequests))
for i := range marketRequests {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil)
requests = append(requests, request)
}
job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests)
responses, errs, err := workerPoolDo(job)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
}
markets := make(Markets, 0, len(responses))
var failed int
for i := range responses {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i])
continue
}
response := responses[i]
if len(response.Chart.Result) == 0 {
failed++
slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
continue
}
prices := response.Chart.Result[0].Indicators.Quote[0].Close
if len(prices) > marketChartDays {
prices = prices[len(prices)-marketChartDays:]
}
previous := response.Chart.Result[0].Meta.RegularMarketPrice
if len(prices) >= 2 && prices[len(prices)-2] != 0 {
previous = prices[len(prices)-2]
}
points := SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency]
if !exists {
currency = response.Chart.Result[0].Meta.Currency
}
markets = append(markets, Market{
MarketRequest: marketRequests[i],
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
Currency: currency,
PercentChange: percentChange(
response.Chart.Result[0].Meta.RegularMarketPrice,
previous,
),
SvgChartPoints: points,
})
}
if len(markets) == 0 {
return nil, ErrNoContent
}
if failed > 0 {
return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", ErrPartialContent, failed)
}
return markets, nil
}

View file

@ -1,115 +0,0 @@
package feed
import (
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)
type youtubeFeedResponseXml struct {
Channel string `xml:"title"`
ChannelLink struct {
Href string `xml:"href,attr"`
} `xml:"link"`
Videos []struct {
Title string `xml:"title"`
Published string `xml:"published"`
Link struct {
Href string `xml:"href,attr"`
} `xml:"link"`
Group struct {
Thumbnail struct {
Url string `xml:"url,attr"`
} `xml:"http://search.yahoo.com/mrss/ thumbnail"`
} `xml:"http://search.yahoo.com/mrss/ group"`
} `xml:"entry"`
}
func parseYoutubeFeedTime(t string) time.Time {
parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t)
if err != nil {
return time.Now()
}
return parsedTime
}
func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (Videos, error) {
requests := make([]*http.Request, 0, len(channelIds))
for i := range channelIds {
request, _ := http.NewRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id="+channelIds[i], nil)
requests = append(requests, request)
}
job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultClient), requests).withWorkers(30)
responses, errs, err := workerPoolDo(job)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
}
videos := make(Videos, 0, len(channelIds)*15)
var failed int
for i := range responses {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch youtube feed", "channel", channelIds[i], "error", errs[i])
continue
}
response := responses[i]
for j := range response.Videos {
video := &response.Videos[j]
// TODO: figure out a better way of skipping shorts
if strings.Contains(video.Title, "#shorts") {
continue
}
var videoUrl string
if videoUrlTemplate == "" {
videoUrl = video.Link.Href
} else {
parsedUrl, err := url.Parse(video.Link.Href)
if err == nil {
videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v"))
} else {
videoUrl = "#"
}
}
videos = append(videos, Video{
ThumbnailUrl: video.Group.Thumbnail.Url,
Title: video.Title,
Url: videoUrl,
Author: response.Channel,
AuthorUrl: response.ChannelLink.Href + "/videos",
TimePosted: parseYoutubeFeedTime(video.Published),
})
}
}
if len(videos) == 0 {
return nil, ErrNoContent
}
videos.SortByNewest()
if failed > 0 {
return videos, fmt.Errorf("%w: missing videos from %d channels", ErrPartialContent, failed)
}
return videos, nil
}

View file

@ -2,41 +2,66 @@ package glance
import (
"flag"
"fmt"
"os"
"strings"
)
type CliIntent uint8
type cliIntent uint8
const (
CliIntentServe CliIntent = iota
CliIntentCheckConfig = iota
cliIntentServe cliIntent = iota
cliIntentConfigValidate = iota
cliIntentConfigPrint = iota
cliIntentDiagnose = iota
)
type CliOptions struct {
Intent CliIntent
ConfigPath string
type cliOptions struct {
intent cliIntent
configPath string
}
func ParseCliOptions() (*CliOptions, error) {
func parseCliOptions() (*cliOptions, error) {
flags := flag.NewFlagSet("", flag.ExitOnError)
flags.Usage = func() {
fmt.Println("Usage: glance [options] command")
checkConfig := flags.Bool("check-config", false, "Check whether the config is valid")
fmt.Println("\nOptions:")
flags.PrintDefaults()
fmt.Println("\nCommands:")
fmt.Println(" config:validate Validate the config file")
fmt.Println(" config:print Print the parsed config file with embedded includes")
fmt.Println(" diagnose Run diagnostic checks")
}
configPath := flags.String("config", "glance.yml", "Set config path")
err := flags.Parse(os.Args[1:])
if err != nil {
return nil, err
}
intent := CliIntentServe
var intent cliIntent
var args = flags.Args()
unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
if *checkConfig {
intent = CliIntentCheckConfig
if len(args) == 0 {
intent = cliIntentServe
} else if len(args) == 1 {
if args[0] == "config:validate" {
intent = cliIntentConfigValidate
} else if args[0] == "config:print" {
intent = cliIntentConfigPrint
} else if args[0] == "diagnose" {
intent = cliIntentDiagnose
} else {
return nil, unknownCommandErr
}
} else {
return nil, unknownCommandErr
}
return &CliOptions{
Intent: intent,
ConfigPath: *configPath,
return &cliOptions{
intent: intent,
configPath: *configPath,
}, nil
}

View file

@ -0,0 +1,221 @@
package glance
import (
"crypto/tls"
"fmt"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
const (
hslHueMax = 360
hslSaturationMax = 100
hslLightnessMax = 100
)
type hslColorField struct {
Hue uint16
Saturation uint8
Lightness uint8
}
func (c *hslColorField) String() string {
return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness)
}
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
var value string
if err := node.Decode(&value); err != nil {
return err
}
matches := hslColorFieldPattern.FindStringSubmatch(value)
if len(matches) != 4 {
return fmt.Errorf("invalid HSL color format: %s", value)
}
hue, err := strconv.ParseUint(matches[1], 10, 16)
if err != nil {
return err
}
if hue > hslHueMax {
return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax)
}
saturation, err := strconv.ParseUint(matches[2], 10, 8)
if err != nil {
return err
}
if saturation > hslSaturationMax {
return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax)
}
lightness, err := strconv.ParseUint(matches[3], 10, 8)
if err != nil {
return err
}
if lightness > hslLightnessMax {
return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
}
c.Hue = uint16(hue)
c.Saturation = uint8(saturation)
c.Lightness = uint8(lightness)
return nil
}
var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
type durationField time.Duration
func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
var value string
if err := node.Decode(&value); err != nil {
return err
}
matches := durationFieldPattern.FindStringSubmatch(value)
if len(matches) != 3 {
return fmt.Errorf("invalid duration format: %s", value)
}
duration, err := strconv.Atoi(matches[1])
if err != nil {
return err
}
switch matches[2] {
case "s":
*d = durationField(time.Duration(duration) * time.Second)
case "m":
*d = durationField(time.Duration(duration) * time.Minute)
case "h":
*d = durationField(time.Duration(duration) * time.Hour)
case "d":
*d = durationField(time.Duration(duration) * 24 * time.Hour)
}
return nil
}
type customIconField struct {
URL string
IsFlatIcon bool
// TODO: along with whether the icon is flat, we also need to know
// whether the icon is black or white by default in order to properly
// invert the color based on the theme being light or dark
}
func newCustomIconField(value string) customIconField {
field := customIconField{}
prefix, icon, found := strings.Cut(value, ":")
if !found {
field.URL = value
return field
}
switch prefix {
case "si":
field.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg"
field.IsFlatIcon = true
case "di", "sh":
// syntax: di:<icon_name>[.svg|.png]
// syntax: sh:<icon_name>[.svg|.png]
// if the icon name is specified without extension, it is assumed to be wanting the SVG icon
// otherwise, specify the extension of either .svg or .png to use either of the CDN offerings
// any other extension will be interpreted as .svg
basename, ext, found := strings.Cut(icon, ".")
if !found {
ext = "svg"
basename = icon
}
if ext != "svg" && ext != "png" {
ext = "svg"
}
if prefix == "di" {
field.URL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext
} else {
field.URL = "https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext
}
default:
field.URL = value
}
return field
}
func (i *customIconField) UnmarshalYAML(node *yaml.Node) error {
var value string
if err := node.Decode(&value); err != nil {
return err
}
*i = newCustomIconField(value)
return nil
}
type proxyOptionsField struct {
URL string `yaml:"url"`
AllowInsecure bool `yaml:"allow-insecure"`
Timeout durationField `yaml:"timeout"`
client *http.Client `yaml:"-"`
}
func (p *proxyOptionsField) UnmarshalYAML(node *yaml.Node) error {
type proxyOptionsFieldAlias proxyOptionsField
alias := (*proxyOptionsFieldAlias)(p)
var proxyURL string
if err := node.Decode(&proxyURL); err != nil {
if err := node.Decode(alias); err != nil {
return err
}
}
if proxyURL == "" && p.URL == "" {
return nil
}
if p.URL != "" {
proxyURL = p.URL
}
parsedUrl, err := url.Parse(proxyURL)
if err != nil {
return fmt.Errorf("parsing proxy URL: %v", err)
}
var timeout = defaultClientTimeout
if p.Timeout > 0 {
timeout = time.Duration(p.Timeout)
}
p.client = &http.Client{
Timeout: timeout,
Transport: &http.Transport{
Proxy: http.ProxyURL(parsedUrl),
TLSClientConfig: &tls.Config{InsecureSkipVerify: p.AllowInsecure},
},
}
return nil
}

View file

@ -1,68 +1,384 @@
package glance
import (
"bytes"
"fmt"
"io"
"html/template"
"log"
"maps"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"gopkg.in/yaml.v3"
)
type Config struct {
Server Server `yaml:"server"`
Theme Theme `yaml:"theme"`
Pages []Page `yaml:"pages"`
type config struct {
Server struct {
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
AssetsPath string `yaml:"assets-path"`
BaseURL string `yaml:"base-url"`
StartedAt time.Time `yaml:"-"` // used in custom css file
} `yaml:"server"`
Document struct {
Head template.HTML `yaml:"head"`
} `yaml:"document"`
Theme struct {
BackgroundColor *hslColorField `yaml:"background-color"`
PrimaryColor *hslColorField `yaml:"primary-color"`
PositiveColor *hslColorField `yaml:"positive-color"`
NegativeColor *hslColorField `yaml:"negative-color"`
Light bool `yaml:"light"`
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
CustomCSSFile string `yaml:"custom-css-file"`
} `yaml:"theme"`
Branding struct {
HideFooter bool `yaml:"hide-footer"`
CustomFooter template.HTML `yaml:"custom-footer"`
LogoText string `yaml:"logo-text"`
LogoURL string `yaml:"logo-url"`
FaviconURL string `yaml:"favicon-url"`
} `yaml:"branding"`
Pages []page `yaml:"pages"`
}
func NewConfigFromYml(contents io.Reader) (*Config, error) {
config := NewConfig()
contentBytes, err := io.ReadAll(contents)
type page struct {
Title string `yaml:"name"`
Slug string `yaml:"slug"`
Width string `yaml:"width"`
ShowMobileHeader bool `yaml:"show-mobile-header"`
ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"`
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
CenterVertically bool `yaml:"center-vertically"`
Columns []struct {
Size string `yaml:"size"`
Widgets widgets `yaml:"widgets"`
} `yaml:"columns"`
PrimaryColumnIndex int8 `yaml:"-"`
mu sync.Mutex `yaml:"-"`
}
func newConfigFromYAML(contents []byte) (*config, error) {
contents, err := parseConfigEnvVariables(contents)
if err != nil {
return nil, err
}
err = yaml.Unmarshal(contentBytes, config)
config := &config{}
config.Server.Port = 8080
err = yaml.Unmarshal(contents, config)
if err != nil {
return nil, err
}
if err = configIsValid(config); err != nil {
if err = isConfigStateValid(config); err != nil {
return nil, err
}
for p := range config.Pages {
for c := range config.Pages[p].Columns {
for w := range config.Pages[p].Columns[c].Widgets {
if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil {
return nil, formatWidgetInitError(err, config.Pages[p].Columns[c].Widgets[w])
}
}
}
}
return config, nil
}
func NewConfig() *Config {
config := &Config{}
// TODO: change the pattern so that it doesn't match commented out lines
var configEnvVariablePattern = regexp.MustCompile(`(^|.)\$\{([A-Z0-9_]+)\}`)
config.Server.Host = ""
config.Server.Port = 8080
func parseConfigEnvVariables(contents []byte) ([]byte, error) {
var err error
return config
replaced := configEnvVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte {
if err != nil {
return nil
}
groups := configEnvVariablePattern.FindSubmatch(match)
if len(groups) != 3 {
return match
}
prefix, key := string(groups[1]), string(groups[2])
if prefix == `\` {
if len(match) >= 2 {
return match[1:]
} else {
return nil
}
}
value, found := os.LookupEnv(key)
if !found {
err = fmt.Errorf("environment variable %s not found", key)
return nil
}
return []byte(prefix + value)
})
if err != nil {
return nil, err
}
return replaced, nil
}
func configIsValid(config *Config) error {
func formatWidgetInitError(err error, w widget) error {
return fmt.Errorf("%s widget: %v", w.GetType(), err)
}
var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`)
func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
mainFileContents, err := os.ReadFile(mainFilePath)
if err != nil {
return nil, nil, fmt.Errorf("reading main YAML file: %w", err)
}
mainFileAbsPath, err := filepath.Abs(mainFilePath)
if err != nil {
return nil, nil, fmt.Errorf("getting absolute path of main YAML file: %w", err)
}
mainFileDir := filepath.Dir(mainFileAbsPath)
includes := make(map[string]struct{})
var includesLastErr error
mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
if includesLastErr != nil {
return nil
}
matches := includePattern.FindSubmatch(match)
if len(matches) != 3 {
includesLastErr = fmt.Errorf("invalid include match: %v", matches)
return nil
}
indent := string(matches[1])
includeFilePath := strings.TrimSpace(string(matches[2]))
if !filepath.IsAbs(includeFilePath) {
includeFilePath = filepath.Join(mainFileDir, includeFilePath)
}
var fileContents []byte
var err error
fileContents, err = os.ReadFile(includeFilePath)
if err != nil {
includesLastErr = fmt.Errorf("reading included file %s: %w", includeFilePath, err)
return nil
}
includes[includeFilePath] = struct{}{}
return []byte(prefixStringLines(indent, string(fileContents)))
})
if includesLastErr != nil {
return nil, nil, includesLastErr
}
return mainFileContents, includes, nil
}
func configFilesWatcher(
mainFilePath string,
lastContents []byte,
lastIncludes map[string]struct{},
onChange func(newContents []byte),
onErr func(error),
) (func() error, error) {
mainFileAbsPath, err := filepath.Abs(mainFilePath)
if err != nil {
return nil, fmt.Errorf("getting absolute path of main file: %w", err)
}
// TODO: refactor, flaky
lastIncludes[mainFileAbsPath] = struct{}{}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("creating watcher: %w", err)
}
updateWatchedFiles := func(previousWatched map[string]struct{}, newWatched map[string]struct{}) {
for filePath := range previousWatched {
if _, ok := newWatched[filePath]; !ok {
watcher.Remove(filePath)
}
}
for filePath := range newWatched {
if _, ok := previousWatched[filePath]; !ok {
if err := watcher.Add(filePath); err != nil {
log.Printf(
"Could not add file to watcher, changes to this file will not trigger a reload. path: %s, error: %v",
filePath, err,
)
}
}
}
}
updateWatchedFiles(nil, lastIncludes)
// needed for lastContents and lastIncludes because they get updated in multiple goroutines
mu := sync.Mutex{}
parseAndCompareBeforeCallback := func() {
currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath)
if err != nil {
onErr(fmt.Errorf("parsing main file contents for comparison: %w", err))
return
}
// TODO: refactor, flaky
currentIncludes[mainFileAbsPath] = struct{}{}
mu.Lock()
defer mu.Unlock()
if !maps.Equal(currentIncludes, lastIncludes) {
updateWatchedFiles(lastIncludes, currentIncludes)
lastIncludes = currentIncludes
}
if !bytes.Equal(lastContents, currentContents) {
lastContents = currentContents
onChange(currentContents)
}
}
const debounceDuration = 500 * time.Millisecond
var debounceTimer *time.Timer
debouncedParseAndCompareBeforeCallback := func() {
if debounceTimer != nil {
debounceTimer.Stop()
debounceTimer.Reset(debounceDuration)
} else {
debounceTimer = time.AfterFunc(debounceDuration, parseAndCompareBeforeCallback)
}
}
deleteLastInclude := func(filePath string) {
mu.Lock()
defer mu.Unlock()
fileAbsPath, _ := filepath.Abs(filePath)
delete(lastIncludes, fileAbsPath)
}
go func() {
for {
select {
case event, isOpen := <-watcher.Events:
if !isOpen {
return
}
if event.Has(fsnotify.Write) {
debouncedParseAndCompareBeforeCallback()
} else if event.Has(fsnotify.Rename) {
// on linux the file will no longer be watched after a rename, on windows
// it will continue to be watched with the new name but we have no access to
// the new name in this event in order to stop watching it manually and match the
// behavior in linux, may lead to weird unintended behaviors on windows as we're
// only handling renames from linux's perspective
// see https://github.com/fsnotify/fsnotify/issues/255
// remove the old file from our manually tracked includes, calling
// debouncedParseAndCompareBeforeCallback will re-add it if it's still
// required after it triggers
deleteLastInclude(event.Name)
// wait for file to maybe get created again
// see https://github.com/glanceapp/glance/pull/358
for i := 0; i < 10; i++ {
if _, err := os.Stat(event.Name); err == nil {
break
}
time.Sleep(200 * time.Millisecond)
}
debouncedParseAndCompareBeforeCallback()
} else if event.Has(fsnotify.Remove) {
deleteLastInclude(event.Name)
debouncedParseAndCompareBeforeCallback()
}
case err, isOpen := <-watcher.Errors:
if !isOpen {
return
}
onErr(fmt.Errorf("watcher error: %w", err))
}
}
}()
onChange(lastContents)
return func() error {
if debounceTimer != nil {
debounceTimer.Stop()
}
return watcher.Close()
}, nil
}
func isConfigStateValid(config *config) error {
if len(config.Pages) == 0 {
return fmt.Errorf("no pages configured")
}
if config.Server.AssetsPath != "" {
if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) {
return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath)
}
}
for i := range config.Pages {
if config.Pages[i].Title == "" {
return fmt.Errorf("Page %d has no title", i+1)
return fmt.Errorf("page %d has no name", i+1)
}
if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") {
return fmt.Errorf("page %d: width can only be either wide or slim", i+1)
}
if len(config.Pages[i].Columns) == 0 {
return fmt.Errorf("Page %d has no columns", i+1)
return fmt.Errorf("page %d has no columns", i+1)
}
if len(config.Pages[i].Columns) > 3 {
return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns))
if config.Pages[i].Width == "slim" {
if len(config.Pages[i].Columns) > 2 {
return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1)
}
} else {
if len(config.Pages[i].Columns) > 3 {
return fmt.Errorf("page %d has more than 3 columns", i+1)
}
}
columnSizesCount := make(map[string]int)
for j := range config.Pages[i].Columns {
if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" {
return fmt.Errorf("Column %d of page %d: size can only be either small or full", j+1, i+1)
return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1)
}
columnSizesCount[config.Pages[i].Columns[j].Size]++
@ -71,7 +387,7 @@ func configIsValid(config *Config) error {
full := columnSizesCount["full"]
if full > 2 || full == 0 {
return fmt.Errorf("Page %d must have either 1 or 2 full width columns", i+1)
return fmt.Errorf("page %d must have either 1 or 2 full width columns", i+1)
}
}

205
internal/glance/diagnose.go Normal file
View file

@ -0,0 +1,205 @@
package glance
import (
"context"
"fmt"
"io"
"net"
"net/http"
"runtime"
"strings"
"sync"
"time"
)
const httpTestRequestTimeout = 10 * time.Second
var diagnosticSteps = []diagnosticStep{
{
name: "resolve cloudflare.com through Cloudflare DoH",
fn: func() (string, error) {
return testHttpRequestWithHeaders("GET", "https://1.1.1.1/dns-query?name=cloudflare.com", map[string]string{
"accept": "application/dns-json",
}, 200)
},
},
{
name: "resolve cloudflare.com through Google DoH",
fn: func() (string, error) {
return testHttpRequest("GET", "https://8.8.8.8/resolve?name=cloudflare.com", 200)
},
},
{
name: "resolve github.com",
fn: func() (string, error) {
return testDNSResolution("github.com")
},
},
{
name: "resolve reddit.com",
fn: func() (string, error) {
return testDNSResolution("reddit.com")
},
},
{
name: "resolve twitch.tv",
fn: func() (string, error) {
return testDNSResolution("twitch.tv")
},
},
{
name: "fetch data from YouTube RSS feed",
fn: func() (string, error) {
return testHttpRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id=UCZU9T1ceaOgwfLRq7OKFU4Q", 200)
},
},
{
name: "fetch data from Twitch.tv GQL",
fn: func() (string, error) {
// this should always return 0 bytes, we're mainly looking for a 200 status code
return testHttpRequest("OPTIONS", "https://gql.twitch.tv/gql", 200)
},
},
{
name: "fetch data from GitHub API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://api.github.com", 200)
},
},
{
name: "fetch data from Open-Meteo API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://geocoding-api.open-meteo.com/v1/search?name=London", 200)
},
},
{
name: "fetch data from Reddit API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://www.reddit.com/search.json", 200)
},
},
{
name: "fetch data from Yahoo finance API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", 200)
},
},
{
name: "fetch data from Hacker News Firebase API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", 200)
},
},
{
name: "fetch data from Docker Hub API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://hub.docker.com/v2/namespaces/library/repositories/ubuntu/tags/latest", 200)
},
},
}
func runDiagnostic() {
fmt.Println("```")
fmt.Println("Glance version: " + buildVersion)
fmt.Println("Go version: " + runtime.Version())
fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU())
fmt.Println("In Docker container: " + boolToString(isRunningInsideDockerContainer(), "yes", "no"))
fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds()))
var wg sync.WaitGroup
for i := range diagnosticSteps {
step := &diagnosticSteps[i]
wg.Add(1)
go func() {
defer wg.Done()
start := time.Now()
step.extraInfo, step.err = step.fn()
step.elapsed = time.Since(start)
}()
}
wg.Wait()
for _, step := range diagnosticSteps {
var extraInfo string
if step.extraInfo != "" {
extraInfo = "| " + step.extraInfo + " "
}
fmt.Printf(
"%s %s %s| %dms\n",
boolToString(step.err == nil, "✓ Can", "✗ Can't"),
step.name,
extraInfo,
step.elapsed.Milliseconds(),
)
if step.err != nil {
fmt.Printf("└╴ error: %v\n", step.err)
}
}
fmt.Println("```")
}
type diagnosticStep struct {
name string
fn func() (string, error)
extraInfo string
err error
elapsed time.Duration
}
func testHttpRequest(method, url string, expectedStatusCode int) (string, error) {
return testHttpRequestWithHeaders(method, url, nil, expectedStatusCode)
}
func testHttpRequestWithHeaders(method, url string, headers map[string]string, expectedStatusCode int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), httpTestRequestTimeout)
defer cancel()
request, _ := http.NewRequestWithContext(ctx, method, url, nil)
for key, value := range headers {
request.Header.Add(key, value)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
printableBody := strings.ReplaceAll(string(body), "\n", "")
if len(printableBody) > 50 {
printableBody = printableBody[:50] + "..."
}
if len(printableBody) > 0 {
printableBody = ", " + printableBody
}
extraInfo := fmt.Sprintf("%d bytes%s", len(body), printableBody)
if response.StatusCode != expectedStatusCode {
return extraInfo, fmt.Errorf("expected status code %d, got %d", expectedStatusCode, response.StatusCode)
}
return extraInfo, nil
}
func testDNSResolution(domain string) (string, error) {
ips, err := net.LookupIP(domain)
var ipStrings []string
if err == nil {
for i := range ips {
ipStrings = append(ipStrings, ips[i].String())
}
}
return strings.Join(ipStrings, ", "), err
}

62
internal/glance/embed.go Normal file
View file

@ -0,0 +1,62 @@
package glance
import (
"crypto/md5"
"embed"
"encoding/hex"
"io"
"io/fs"
"log"
"strconv"
"time"
)
//go:embed static
var _staticFS embed.FS
//go:embed templates
var _templateFS embed.FS
var staticFS, _ = fs.Sub(_staticFS, "static")
var templateFS, _ = fs.Sub(_templateFS, "templates")
var staticFSHash = func() string {
hash, err := computeFSHash(staticFS)
if err != nil {
log.Printf("Could not compute static assets cache key: %v", err)
return strconv.FormatInt(time.Now().Unix(), 10)
}
return hash
}()
func computeFSHash(files fs.FS) (string, error) {
hash := md5.New()
err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
file, err := files.Open(path)
if err != nil {
return err
}
if _, err := io.Copy(hash, file); err != nil {
return err
}
return nil
})
if err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil))[:10], nil
}

View file

@ -4,65 +4,94 @@ import (
"bytes"
"context"
"fmt"
"log/slog"
"html/template"
"log"
"net/http"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/glanceapp/glance/internal/assets"
"github.com/glanceapp/glance/internal/widget"
)
var buildVersion = "dev"
var (
pageTemplate = mustParseTemplate("page.html", "document.html")
pageContentTemplate = mustParseTemplate("page-content.html")
pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
)
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
type application struct {
Version string
Config config
ParsedThemeStyle template.HTML
type Application struct {
Version string
Config Config
slugToPage map[string]*Page
slugToPage map[string]*page
widgetByID map[uint64]widget
}
type Theme struct {
BackgroundColor *widget.HSLColorField `yaml:"background-color"`
PrimaryColor *widget.HSLColorField `yaml:"primary-color"`
PositiveColor *widget.HSLColorField `yaml:"positive-color"`
NegativeColor *widget.HSLColorField `yaml:"negative-color"`
Light bool `yaml:"light"`
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
CustomCSSFile string `yaml:"custom-css-file"`
func newApplication(config *config) (*application, error) {
app := &application{
Version: buildVersion,
Config: *config,
slugToPage: make(map[string]*page),
widgetByID: make(map[uint64]widget),
}
app.slugToPage[""] = &config.Pages[0]
providers := &widgetProviders{
assetResolver: app.AssetPath,
}
var err error
app.ParsedThemeStyle, err = executeTemplateToHTML(pageThemeStyleTemplate, &app.Config.Theme)
if err != nil {
return nil, fmt.Errorf("parsing theme style: %v", err)
}
for p := range config.Pages {
page := &config.Pages[p]
page.PrimaryColumnIndex = -1
if page.Slug == "" {
page.Slug = titleToSlug(page.Title)
}
app.slugToPage[page.Slug] = page
for c := range page.Columns {
column := &page.Columns[c]
if page.PrimaryColumnIndex == -1 && column.Size == "full" {
page.PrimaryColumnIndex = int8(c)
}
for w := range column.Widgets {
widget := column.Widgets[w]
app.widgetByID[widget.GetID()] = widget
widget.setProviders(providers)
}
}
}
config = &app.Config
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
config.Theme.CustomCSSFile = app.transformUserDefinedAssetPath(config.Theme.CustomCSSFile)
if config.Branding.FaviconURL == "" {
config.Branding.FaviconURL = app.AssetPath("favicon.png")
} else {
config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL)
}
config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL)
return app, nil
}
type Server struct {
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
AssetsPath string `yaml:"assets-path"`
AssetsHash string `yaml:"-"`
}
type Column struct {
Size string `yaml:"size"`
Widgets widget.Widgets `yaml:"widgets"`
}
type templateData struct {
App *Application
Page *Page
}
type Page struct {
Title string `yaml:"name"`
Slug string `yaml:"slug"`
ShowMobileHeader bool `yaml:"show-mobile-header"`
Columns []Column `yaml:"columns"`
mu sync.Mutex
}
func (p *Page) UpdateOutdatedWidgets() {
func (p *page) updateOutdatedWidgets() {
now := time.Now()
var wg sync.WaitGroup
@ -72,14 +101,14 @@ func (p *Page) UpdateOutdatedWidgets() {
for w := range p.Columns[c].Widgets {
widget := p.Columns[c].Widgets[w]
if !widget.RequiresUpdate(&now) {
if !widget.requiresUpdate(&now) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
widget.Update(context)
widget.update(context)
}()
}
}
@ -87,55 +116,34 @@ func (p *Page) UpdateOutdatedWidgets() {
wg.Wait()
}
// TODO: fix, currently very simple, lots of uncovered edge cases
func titleToSlug(s string) string {
s = strings.ToLower(s)
s = sequentialWhitespacePattern.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
func (a *application) transformUserDefinedAssetPath(path string) string {
if strings.HasPrefix(path, "/assets/") {
return a.Config.Server.BaseURL + path
}
return s
return path
}
func NewApplication(config *Config) (*Application, error) {
if len(config.Pages) == 0 {
return nil, fmt.Errorf("no pages configured")
}
app := &Application{
Version: buildVersion,
Config: *config,
slugToPage: make(map[string]*Page),
}
app.slugToPage[""] = &config.Pages[0]
for i := range config.Pages {
if config.Pages[i].Slug == "" {
config.Pages[i].Slug = titleToSlug(config.Pages[i].Title)
}
app.slugToPage[config.Pages[i].Slug] = &config.Pages[i]
}
return app, nil
type pageTemplateData struct {
App *application
Page *page
}
func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) {
func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) {
page, exists := a.slugToPage[r.PathValue("page")]
if !exists {
a.HandleNotFound(w, r)
a.handleNotFound(w, r)
return
}
pageData := templateData{
pageData := pageTemplateData{
Page: page,
App: a,
}
var responseBytes bytes.Buffer
err := assets.PageTemplate.Execute(&responseBytes, pageData)
err := pageTemplate.Execute(&responseBytes, pageData)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
@ -145,24 +153,28 @@ func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request)
w.Write(responseBytes.Bytes())
}
func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Request) {
func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) {
page, exists := a.slugToPage[r.PathValue("page")]
if !exists {
a.HandleNotFound(w, r)
a.handleNotFound(w, r)
return
}
pageData := templateData{
pageData := pageTemplateData{
Page: page,
}
page.mu.Lock()
defer page.mu.Unlock()
page.UpdateOutdatedWidgets()
var err error
var responseBytes bytes.Buffer
err := assets.PageContentTemplate.Execute(&responseBytes, pageData)
func() {
page.mu.Lock()
defer page.mu.Unlock()
page.updateOutdatedWidgets()
err = pageContentTemplate.Execute(&responseBytes, pageData)
}()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
@ -173,50 +185,58 @@ func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Re
w.Write(responseBytes.Bytes())
}
func (a *Application) HandleNotFound(w http.ResponseWriter, r *http.Request) {
func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
// TODO: add proper not found page
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Page not found"))
}
func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler {
server := http.FileServer(fs)
func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) {
widgetValue := r.PathValue("widget")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: fix always setting cache control even if the file doesn't exist
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds())))
server.ServeHTTP(w, r)
})
widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
if err != nil {
a.handleNotFound(w, r)
return
}
widget, exists := a.widgetByID[widgetID]
if !exists {
a.handleNotFound(w, r)
return
}
widget.handleRequest(w, r)
}
func (a *Application) AssetPath(asset string) string {
return "/static/" + a.Config.Server.AssetsHash + "/" + asset
func (a *application) AssetPath(asset string) string {
return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset
}
func (a *Application) Serve() error {
a.Config.Server.AssetsHash = assets.PublicFSHash
func (a *application) server() (func() error, func() error) {
// TODO: add gzip support, static files must have their gzipped contents cached
// TODO: add HTTPS support
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", a.HandlePageRequest)
mux.HandleFunc("GET /{page}", a.HandlePageRequest)
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
mux.HandleFunc("GET /{$}", a.handlePageRequest)
mux.HandleFunc("GET /{page}", a.handlePageRequest)
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest)
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.Handle(
fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 8*time.Hour)),
fmt.Sprintf("GET /static/%s/{path...}", staticFSHash),
http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)),
)
var absAssetsPath string
if a.Config.Server.AssetsPath != "" {
absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
if err != nil {
return fmt.Errorf("invalid assets path: %s", a.Config.Server.AssetsPath)
}
slog.Info("Serving assets", "path", absAssetsPath)
assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
assetsFS := fileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS))
}
@ -225,6 +245,25 @@ func (a *Application) Serve() error {
Handler: mux,
}
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port)
return server.ListenAndServe()
start := func() error {
a.Config.Server.StartedAt = time.Now()
log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n",
a.Config.Server.Host,
a.Config.Server.Port,
a.Config.Server.BaseURL,
absAssetsPath,
)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
stop := func() error {
return server.Close()
}
return start, stop
}

View file

@ -2,45 +2,172 @@ package glance
import (
"fmt"
"io"
"log"
"net/http"
"os"
)
func Main() int {
options, err := ParseCliOptions()
var buildVersion = "dev"
func Main() int {
options, err := parseCliOptions()
if err != nil {
fmt.Println(err)
return 1
}
configFile, err := os.Open(options.ConfigPath)
if err != nil {
fmt.Printf("failed opening config file: %v\n", err)
return 1
}
config, err := NewConfigFromYml(configFile)
configFile.Close()
if err != nil {
fmt.Printf("failed parsing config file: %v\n", err)
return 1
}
if options.Intent == CliIntentServe {
app, err := NewApplication(config)
switch options.intent {
case cliIntentServe:
// remove in v0.10.0
if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) {
return 1
}
if err := serveApp(options.configPath); err != nil {
fmt.Println(err)
return 1
}
case cliIntentConfigValidate:
contents, _, err := parseYAMLIncludes(options.configPath)
if err != nil {
fmt.Printf("failed creating application: %v\n", err)
fmt.Printf("Could not parse config file: %v\n", err)
return 1
}
if err := app.Serve(); err != nil {
fmt.Printf("http server error: %v\n", err)
if _, err := newConfigFromYAML(contents); err != nil {
fmt.Printf("Config file is invalid: %v\n", err)
return 1
}
case cliIntentConfigPrint:
contents, _, err := parseYAMLIncludes(options.configPath)
if err != nil {
fmt.Printf("Could not parse config file: %v\n", err)
return 1
}
fmt.Println(string(contents))
case cliIntentDiagnose:
runDiagnostic()
}
return 0
}
func serveApp(configPath string) error {
exitChannel := make(chan struct{})
hadValidConfigOnStartup := false
var stopServer func() error
onChange := func(newContents []byte) {
if stopServer != nil {
log.Println("Config file changed, reloading...")
}
config, err := newConfigFromYAML(newContents)
if err != nil {
log.Printf("Config has errors: %v", err)
if !hadValidConfigOnStartup {
close(exitChannel)
}
return
} else if !hadValidConfigOnStartup {
hadValidConfigOnStartup = true
}
app, err := newApplication(config)
if err != nil {
log.Printf("Failed to create application: %v", err)
return
}
if stopServer != nil {
if err := stopServer(); err != nil {
log.Printf("Error while trying to stop server: %v", err)
}
}
go func() {
var startServer func() error
startServer, stopServer = app.server()
if err := startServer(); err != nil {
log.Printf("Failed to start server: %v", err)
}
}()
}
onErr := func(err error) {
log.Printf("Error watching config files: %v", err)
}
configContents, configIncludes, err := parseYAMLIncludes(configPath)
if err != nil {
return fmt.Errorf("parsing config: %w", err)
}
stopWatching, err := configFilesWatcher(configPath, configContents, configIncludes, onChange, onErr)
if err == nil {
defer stopWatching()
} else {
log.Printf("Error starting file watcher, config file changes will require a manual restart. (%v)", err)
config, err := newConfigFromYAML(configContents)
if err != nil {
return fmt.Errorf("validating config file: %w", err)
}
app, err := newApplication(config)
if err != nil {
return fmt.Errorf("creating application: %w", err)
}
startServer, _ := app.server()
if err := startServer(); err != nil {
return fmt.Errorf("starting server: %w", err)
}
}
<-exitChannel
return nil
}
func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool {
if !isRunningInsideDockerContainer() {
return false
}
if _, err := os.Stat(configPath); err == nil {
return false
}
// glance.yml wasn't mounted to begin with or was incorrectly mounted as a directory
if stat, err := os.Stat("glance.yml"); err != nil || stat.IsDir() {
return false
}
templateFile, _ := templateFS.Open("v0.7-update-notice-page.html")
bodyContents, _ := io.ReadAll(templateFile)
fmt.Println("!!! WARNING !!!")
fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0.")
fmt.Println("Please see https://github.com/glanceapp/glance/blob/main/docs/v0.7.0-upgrade.md for more information.")
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(bodyContents))
})
server := http.Server{
Addr: ":8080",
Handler: mux,
}
server.ListenAndServe()
return true
}

View file

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.955.49A12 12 0 0 0 0 12.49a12 12 0 0 0 1.832 6.373L11.838 5.928a.187.14 0 0 1 .324 0l10.006 12.935A12 12 0 0 0 24 12.49a12 12 0 0 0-12-12 12 12 0 0 0-.045 0zm.375 6.467l4.416 16.553a12 12 0 0 0 5.137-4.213z"/></svg>

After

Width:  |  Height:  |  Size: 300 B

View file

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>

After

Width:  |  Height:  |  Size: 802 B

View file

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"/></svg>

After

Width:  |  Height:  |  Size: 553 B

View file

@ -0,0 +1,33 @@
export const easeOutQuint = 'cubic-bezier(0.22, 1, 0.36, 1)';
export function directions(anim, opt, ...dirs) {
return dirs.map(dir => anim({ direction: dir, ...opt }));
}
export function slideFade({
direction = 'left',
fill = 'backwards',
duration = 200,
distance = '1rem',
easing = 'ease',
offset = 0,
}) {
const axis = direction === 'left' || direction === 'right' ? 'X' : 'Y';
const negative = direction === 'left' || direction === 'up' ? '-' : '';
const amount = negative + distance;
return {
keyframes: [
{
offset: offset,
opacity: 0,
transform: `translate${axis}(${amount})`,
}
],
options: {
duration: duration,
easing: easing,
fill: fill,
},
};
}

View file

@ -0,0 +1,212 @@
import { directions, easeOutQuint, slideFade } from "./animations.js";
import { elem, repeat, text } from "./templating.js";
const FULL_MONTH_SLOTS = 7*6;
const WEEKDAY_ABBRS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const leftArrowSvg = `<svg stroke="var(--color-text-base)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>`;
const rightArrowSvg = `<svg stroke="var(--color-text-base)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>`;
const undoArrowSvg = `<svg stroke="var(--color-text-base)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
</svg>`;
const [datesExitLeft, datesExitRight] = directions(
slideFade, { distance: "2rem", duration: 120, offset: 1 },
"left", "right"
);
const [datesEntranceLeft, datesEntranceRight] = directions(
slideFade, { distance: "0.8rem", duration: 500, easing: easeOutQuint },
"left", "right"
);
const undoEntrance = slideFade({ direction: "left", distance: "100%", duration: 300 });
export default function(element) {
element.swap(Calendar(
Number(element.dataset.firstDayOfWeek ?? 1)
));
}
// TODO: when viewing the previous/next month, display the current date if it's within the spill-over days
function Calendar(firstDay) {
let header, dates;
let advanceTimeTicker;
let now = new Date();
let activeDate;
const update = (newDate) => {
header.component.update(now, newDate);
dates.component.update(now, newDate);
activeDate = newDate;
};
const autoAdvanceNow = () => {
advanceTimeTicker = setTimeout(() => {
// TODO: don't auto advance if looking at a different month
update(now = new Date());
autoAdvanceNow();
}, msTillNextDay());
};
const adjacentMonth = (dir) => new Date(activeDate.getFullYear(), activeDate.getMonth() + dir, 1);
const nextClicked = () => update(adjacentMonth(1));
const prevClicked = () => update(adjacentMonth(-1));
const undoClicked = () => update(now);
const calendar = elem().classes("calendar").append(
header = Header(nextClicked, prevClicked, undoClicked),
dates = Dates(firstDay)
);
update(now);
autoAdvanceNow();
return calendar.component({
suspend: () => clearTimeout(advanceTimeTicker)
});
}
function Header(nextClicked, prevClicked, undoClicked) {
let month, monthNumber, year, undo;
const button = () => elem("button").classes("calendar-header-button");
const monthAndYear = elem().classes("size-h2", "color-highlight").append(
month = text(),
" ",
year = elem("span").classes("size-h3"),
undo = button()
.hide()
.classes("calendar-undo-button")
.attr("title", "Back to current month")
.on("click", undoClicked)
.html(undoArrowSvg)
);
const monthSwitcher = elem()
.classes("flex", "gap-7", "items-center")
.append(
button()
.attr("title", "Previous month")
.on("click", prevClicked)
.html(leftArrowSvg),
monthNumber = elem()
.classes("color-highlight")
.styles({ marginTop: "0.1rem" }),
button()
.attr("title", "Next month")
.on("click", nextClicked)
.html(rightArrowSvg),
);
return elem().classes("flex", "justify-between", "items-center").append(
monthAndYear,
monthSwitcher
).component({
update: function (now, newDate) {
month.text(MONTH_NAMES[newDate.getMonth()]);
year.text(newDate.getFullYear());
const m = newDate.getMonth() + 1;
monthNumber.text((m < 10 ? "0" : "") + m);
if (!datesWithinSameMonth(now, newDate)) {
if (undo.isHidden()) undo.show().animate(undoEntrance);
} else {
undo.hide();
}
return this;
}
});
}
function Dates(firstDay) {
let dates, lastRenderedDate;
const updateFullMonth = function(now, newDate) {
const firstWeekday = new Date(newDate.getFullYear(), newDate.getMonth(), 1).getDay();
const previousMonthSpilloverDays = (firstWeekday - firstDay + 7) % 7 || 7;
const currentMonthDays = daysInMonth(newDate.getFullYear(), newDate.getMonth());
const nextMonthSpilloverDays = FULL_MONTH_SLOTS - (previousMonthSpilloverDays + currentMonthDays);
const previousMonthDays = daysInMonth(newDate.getFullYear(), newDate.getMonth() - 1)
const isCurrentMonth = datesWithinSameMonth(now, newDate);
const currentDate = now.getDate();
let children = dates.children;
let index = 0;
for (let i = 0; i < FULL_MONTH_SLOTS; i++) {
children[i].clearClasses("calendar-spillover-date", "calendar-current-date");
}
for (let i = 0; i < previousMonthSpilloverDays; i++, index++) {
children[index].classes("calendar-spillover-date").text(
previousMonthDays - previousMonthSpilloverDays + i + 1
)
}
for (let i = 1; i <= currentMonthDays; i++, index++) {
children[index]
.classesIf(isCurrentMonth && i === currentDate, "calendar-current-date")
.text(i);
}
for (let i = 0; i < nextMonthSpilloverDays; i++, index++) {
children[index].classes("calendar-spillover-date").text(i + 1);
}
lastRenderedDate = newDate;
};
const update = function(now, newDate) {
if (lastRenderedDate === undefined || datesWithinSameMonth(newDate, lastRenderedDate)) {
updateFullMonth(now, newDate);
return;
}
const next = newDate > lastRenderedDate;
dates.animateUpdate(
() => updateFullMonth(now, newDate),
next ? datesExitLeft : datesExitRight,
next ? datesEntranceRight : datesEntranceLeft,
);
}
return elem().append(
elem().classes("calendar-dates", "margin-top-15").append(
...repeat(7, (i) => elem().classes("size-h6", "color-subdue").text(
WEEKDAY_ABBRS[(firstDay + i) % 7]
))
),
dates = elem().classes("calendar-dates", "margin-top-3").append(
...elem().classes("calendar-date").duplicate(FULL_MONTH_SLOTS)
)
).component({ update });
}
function datesWithinSameMonth(d1, d2) {
return d1.getFullYear() === d2.getFullYear() && d1.getMonth() === d2.getMonth();
}
function daysInMonth(year, month) {
return new Date(year, month + 1, 0).getDate();
}
function msTillNextDay(now) {
now = now || new Date();
return 86_400_000 - (
now.getMilliseconds() +
now.getSeconds() * 1000 +
now.getMinutes() * 60_000 +
now.getHours() * 3_600_000
);
}

View file

@ -1,30 +1,11 @@
function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
let debounceTimeout;
let timesDebounced = 0;
import { setupPopovers } from './popover.js';
import { setupMasonries } from './masonry.js';
import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js';
return function () {
if (timesDebounced == maxDebounceTimes) {
clearTimeout(debounceTimeout);
timesDebounced = 0;
callback();
return;
}
clearTimeout(debounceTimeout);
timesDebounced++;
debounceTimeout = setTimeout(() => {
timesDebounced = 0;
callback();
}, debounceDelay);
};
};
async function fetchPageContent(pageSlug) {
async function fetchPageContent(pageData) {
// TODO: handle non 200 status codes/time outs
// TODO: add retries
const response = await fetch(`/api/pages/${pageSlug}/content/`);
const response = await fetch(`${pageData.baseURL}/api/pages/${pageData.slug}/content/`);
const content = await response.text();
return content;
@ -68,29 +49,35 @@ function setupCarousels() {
const minuteInSeconds = 60;
const hourInSeconds = minuteInSeconds * 60;
const dayInSeconds = hourInSeconds * 24;
const monthInSeconds = dayInSeconds * 30;
const yearInSeconds = monthInSeconds * 12;
const monthInSeconds = dayInSeconds * 30.4;
const yearInSeconds = dayInSeconds * 365;
function relativeTimeSince(timestamp) {
const delta = Math.round((Date.now() / 1000) - timestamp);
function timestampToRelativeTime(timestamp) {
let delta = Math.round((Date.now() / 1000) - timestamp);
let prefix = "";
if (delta < 0) {
delta = -delta;
prefix = "in ";
}
if (delta < minuteInSeconds) {
return "1m";
return prefix + "1m";
}
if (delta < hourInSeconds) {
return Math.floor(delta / minuteInSeconds) + "m";
return prefix + Math.floor(delta / minuteInSeconds) + "m";
}
if (delta < dayInSeconds) {
return Math.floor(delta / hourInSeconds) + "h";
return prefix + Math.floor(delta / hourInSeconds) + "h";
}
if (delta < monthInSeconds) {
return Math.floor(delta / dayInSeconds) + "d";
return prefix + Math.floor(delta / dayInSeconds) + "d";
}
if (delta < yearInSeconds) {
return Math.floor(delta / monthInSeconds) + "mo";
return prefix + Math.floor(delta / monthInSeconds) + "mo";
}
return Math.floor(delta / yearInSeconds) + "y";
return prefix + Math.floor(delta / yearInSeconds) + "y";
}
function updateRelativeTimeForElements(elements)
@ -103,7 +90,7 @@ function updateRelativeTimeForElements(elements)
if (timestamp === undefined)
continue
element.textContent = relativeTimeSince(timestamp);
element.textContent = timestampToRelativeTime(timestamp);
}
}
@ -124,6 +111,7 @@ function setupSearchBoxes() {
const bangsMap = {};
const kbdElement = widget.getElementsByTagName("kbd")[0];
let currentBang = null;
let lastQuery = "";
for (let j = 0; j < bangs.length; j++) {
const bang = bangs[j];
@ -160,6 +148,14 @@ function setupSearchBoxes() {
window.location.href = url;
}
lastQuery = query;
inputElement.value = "";
return;
}
if (event.key == "ArrowUp" && lastQuery.length > 0) {
inputElement.value = lastQuery;
return;
}
};
@ -250,6 +246,66 @@ function setupDynamicRelativeTime() {
});
}
function setupGroups() {
const groups = document.getElementsByClassName("widget-type-group");
if (groups.length == 0) {
return;
}
for (let g = 0; g < groups.length; g++) {
const group = groups[g];
const titles = group.getElementsByClassName("widget-header")[0].children;
const tabs = group.getElementsByClassName("widget-group-contents")[0].children;
let current = 0;
for (let t = 0; t < titles.length; t++) {
const title = titles[t];
if (title.dataset.titleUrl !== undefined) {
title.addEventListener("mousedown", (event) => {
if (event.button != 1) {
return;
}
openURLInNewTab(title.dataset.titleUrl, false);
event.preventDefault();
});
}
title.addEventListener("click", () => {
if (t == current) {
if (title.dataset.titleUrl !== undefined) {
openURLInNewTab(title.dataset.titleUrl);
}
return;
}
for (let i = 0; i < titles.length; i++) {
titles[i].classList.remove("widget-group-title-current");
titles[i].setAttribute("aria-selected", "false");
tabs[i].classList.remove("widget-group-content-current");
tabs[i].setAttribute("aria-hidden", "true");
}
if (current < t) {
tabs[t].dataset.direction = "right";
} else {
tabs[t].dataset.direction = "left";
}
current = t;
title.classList.add("widget-group-title-current");
title.setAttribute("aria-selected", "true");
tabs[t].classList.add("widget-group-content-current");
tabs[t].setAttribute("aria-hidden", "false");
});
}
}
}
function setupLazyImages() {
const images = document.querySelectorAll("img[loading=lazy]");
@ -385,9 +441,9 @@ function setupCollapsibleGrids() {
const button = attachExpandToggleButton(gridElement);
let cardsPerRow = 2;
let cardsPerRow;
const resolveCollapsibleItems = () => {
const resolveCollapsibleItems = () => requestAnimationFrame(() => {
const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
if (hideItemsAfterIndex >= gridElement.children.length) {
@ -413,14 +469,13 @@ function setupCollapsibleGrids() {
child.style.removeProperty("animation-delay");
}
}
};
afterContentReady(() => {
cardsPerRow = getCardsPerRow();
resolveCollapsibleItems();
});
window.addEventListener("resize", () => {
const observer = new ResizeObserver(() => {
if (!isElementVisible(gridElement)) {
return;
}
const newCardsPerRow = getCardsPerRow();
if (cardsPerRow == newCardsPerRow) {
@ -430,6 +485,8 @@ function setupCollapsibleGrids() {
cardsPerRow = newCardsPerRow;
resolveCollapsibleItems();
});
afterContentReady(() => observer.observe(gridElement));
}
}
@ -481,9 +538,34 @@ function timeInZone(now, zone) {
timeInZone = now
}
const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
const diffInMinutes = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60);
return { time: timeInZone, diffInHours: diffInHours };
return { time: timeInZone, diffInMinutes: diffInMinutes };
}
function zoneDiffText(diffInMinutes) {
if (diffInMinutes == 0) {
return "";
}
const sign = diffInMinutes < 0 ? "-" : "+";
const signText = diffInMinutes < 0 ? "behind" : "ahead";
diffInMinutes = Math.abs(diffInMinutes);
const hours = Math.floor(diffInMinutes / 60);
const minutes = diffInMinutes % 60;
const hourSuffix = hours == 1 ? "" : "s";
if (minutes == 0) {
return { text: `${sign}${hours}h`, title: `${hours} hour${hourSuffix} ${signText}` };
}
if (hours == 0) {
return { text: `${sign}${minutes}m`, title: `${minutes} minutes ${signText}` };
}
return { text: `${sign}${hours}h~`, title: `${hours} hour${hourSuffix} and ${minutes} minutes ${signText}` };
}
function setupClocks() {
@ -526,9 +608,11 @@ function setupClocks() {
);
updateCallbacks.push((now) => {
const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
const { time, diffInMinutes } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
setZoneTime(time);
diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
const { text, title } = zoneDiffText(diffInMinutes);
diffElement.textContent = text;
diffElement.title = title;
});
}
}
@ -545,28 +629,61 @@ function setupClocks() {
updateClocks();
}
async function setupCalendars() {
const elems = document.getElementsByClassName("calendar");
if (elems.length == 0) return;
// TODO: implement prefetching, currently loads as a nasty waterfall of requests
const calendar = await import ('./calendar.js');
for (let i = 0; i < elems.length; i++)
calendar.default(elems[i]);
}
function setupTruncatedElementTitles() {
const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines");
if (elements.length == 0) {
return;
}
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
if (element.title === "") element.title = element.textContent;
}
}
async function setupPage() {
const pageElement = document.getElementById("page");
const pageContentElement = document.getElementById("page-content");
const pageContent = await fetchPageContent(pageData.slug);
const pageContent = await fetchPageContent(pageData);
pageContentElement.innerHTML = pageContent;
try {
setupPopovers();
setupClocks()
await setupCalendars();
setupCarousels();
setupSearchBoxes();
setupCollapsibleLists();
setupCollapsibleGrids();
setupGroups();
setupMasonries();
setupDynamicRelativeTime();
setupLazyImages();
} finally {
pageElement.classList.add("content-ready");
pageElement.setAttribute("aria-busy", "false");
for (let i = 0; i < contentReadyCallbacks.length; i++) {
contentReadyCallbacks[i]();
}
setTimeout(() => {
setupTruncatedElementTitles();
}, 50);
setTimeout(() => {
document.body.classList.add("page-columns-transitioned");
}, 300);

View file

@ -0,0 +1,53 @@
import { clamp } from "./utils.js";
export function setupMasonries() {
const masonryContainers = document.getElementsByClassName("masonry");
for (let i = 0; i < masonryContainers.length; i++) {
const container = masonryContainers[i];
const options = {
minColumnWidth: container.dataset.minColumnWidth || 330,
maxColumns: container.dataset.maxColumns || 6,
};
const items = Array.from(container.children);
let previousColumnsCount = 0;
const render = function() {
const columnsCount = clamp(
Math.floor(container.offsetWidth / options.minColumnWidth),
1,
Math.min(options.maxColumns, items.length)
);
if (columnsCount === previousColumnsCount) {
return;
} else {
container.textContent = "";
previousColumnsCount = columnsCount;
}
const columnsFragment = document.createDocumentFragment();
for (let i = 0; i < columnsCount; i++) {
const column = document.createElement("div");
column.className = "masonry-column";
columnsFragment.append(column);
}
// poor man's masonry
// TODO: add an option that allows placing items in the
// shortest column instead of iterating the columns in order
for (let i = 0; i < items.length; i++) {
columnsFragment.children[i % columnsCount].appendChild(items[i]);
}
container.append(columnsFragment);
};
const observer = new ResizeObserver(() => requestAnimationFrame(render));
observer.observe(container);
}
}

View file

@ -0,0 +1,187 @@
const defaultShowDelayMs = 200;
const defaultHideDelayMs = 500;
const defaultMaxWidth = "300px";
const defaultDistanceFromTarget = "0px"
const htmlContentSelector = "[data-popover-html]";
let activeTarget = null;
let pendingTarget = null;
let cleanupOnHidePopover = null;
let togglePopoverTimeout = null;
const containerElement = document.createElement("div");
const containerComputedStyle = getComputedStyle(containerElement);
containerElement.addEventListener("mouseenter", clearTogglePopoverTimeout);
containerElement.addEventListener("mouseleave", handleMouseLeave);
containerElement.classList.add("popover-container");
const frameElement = document.createElement("div");
frameElement.classList.add("popover-frame");
const contentElement = document.createElement("div");
contentElement.classList.add("popover-content");
frameElement.append(contentElement);
containerElement.append(frameElement);
document.body.append(containerElement);
const queueRepositionContainer = () => requestAnimationFrame(repositionContainer);
const observer = new ResizeObserver(queueRepositionContainer);
function handleMouseEnter(event) {
clearTogglePopoverTimeout();
const target = event.target;
pendingTarget = target;
const showDelay = target.dataset.popoverShowDelay || defaultShowDelayMs;
if (activeTarget !== null) {
if (activeTarget !== target) {
hidePopover();
requestAnimationFrame(() => requestAnimationFrame(showPopover));
}
return;
}
togglePopoverTimeout = setTimeout(showPopover, showDelay);
}
function handleMouseLeave(event) {
clearTogglePopoverTimeout();
const target = activeTarget || event.target;
togglePopoverTimeout = setTimeout(hidePopover, target.dataset.popoverHideDelay || defaultHideDelayMs);
}
function clearTogglePopoverTimeout() {
clearTimeout(togglePopoverTimeout);
}
function showPopover() {
if (pendingTarget === null) return;
activeTarget = pendingTarget;
pendingTarget = null;
const popoverType = activeTarget.dataset.popoverType;
if (popoverType === "text") {
const text = activeTarget.dataset.popoverText;
if (text === undefined || text === "") return;
contentElement.textContent = text;
} else if (popoverType === "html") {
const htmlContent = activeTarget.querySelector(htmlContentSelector);
if (htmlContent === null) return;
/**
* The reason for all of the below shenanigans is that I want to preserve
* all attached event listeners of the original HTML content. This is so I don't have to
* re-setup events for things like lazy images, they'd just work as expected.
*/
const placeholder = document.createComment("");
htmlContent.replaceWith(placeholder);
contentElement.replaceChildren(htmlContent);
htmlContent.removeAttribute("data-popover-html");
cleanupOnHidePopover = () => {
htmlContent.setAttribute("data-popover-html", "");
placeholder.replaceWith(htmlContent);
placeholder.remove();
};
} else {
return;
}
const contentMaxWidth = activeTarget.dataset.popoverMaxWidth || defaultMaxWidth;
if (activeTarget.dataset.popoverTextAlign !== undefined) {
contentElement.style.textAlign = activeTarget.dataset.popoverTextAlign;
} else {
contentElement.style.removeProperty("text-align");
}
contentElement.style.maxWidth = contentMaxWidth;
activeTarget.classList.add("popover-active");
document.addEventListener("keydown", handleHidePopoverOnEscape);
window.addEventListener("resize", queueRepositionContainer);
observer.observe(containerElement);
}
function repositionContainer() {
containerElement.style.display = "block";
const targetBounds = activeTarget.dataset.popoverAnchor !== undefined
? activeTarget.querySelector(activeTarget.dataset.popoverAnchor).getBoundingClientRect()
: activeTarget.getBoundingClientRect();
const containerBounds = containerElement.getBoundingClientRect();
const containerInlinePadding = parseInt(containerComputedStyle.getPropertyValue("padding-inline"));
const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverTargetOffset || 0.5);
const position = activeTarget.dataset.popoverPosition || "below";
const popoverOffest = activeTarget.dataset.popoverOffset || 0.5;
const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width * popoverOffest));
if (left < 0) {
containerElement.style.left = 0;
containerElement.style.removeProperty("right");
containerElement.style.setProperty("--triangle-offset", targetBounds.left - containerInlinePadding + targetBoundsWidthOffset + "px");
} else if (left + containerBounds.width > window.innerWidth) {
containerElement.style.removeProperty("left");
containerElement.style.right = 0;
containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + -1 + "px");
} else {
containerElement.style.removeProperty("right");
containerElement.style.left = left + "px";
containerElement.style.setProperty("--triangle-offset", ((targetBounds.left + targetBoundsWidthOffset) - left - containerInlinePadding) + -1 + "px");
}
const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget;
const topWhenAbove = targetBounds.top + window.scrollY - containerBounds.height;
const topWhenBelow = targetBounds.top + window.scrollY + targetBounds.height;
if (
position === "above" && topWhenAbove > window.scrollY ||
(position === "below" && topWhenBelow + containerBounds.height > window.scrollY + window.innerHeight)
) {
containerElement.classList.add("position-above");
frameElement.style.removeProperty("margin-top");
frameElement.style.marginBottom = distanceFromTarget;
containerElement.style.top = topWhenAbove + "px";
} else {
containerElement.classList.remove("position-above");
frameElement.style.removeProperty("margin-bottom");
frameElement.style.marginTop = distanceFromTarget;
containerElement.style.top = topWhenBelow + "px";
}
}
function hidePopover() {
if (activeTarget === null) return;
activeTarget.classList.remove("popover-active");
containerElement.style.display = "none";
document.removeEventListener("keydown", handleHidePopoverOnEscape);
window.removeEventListener("resize", queueRepositionContainer);
observer.unobserve(containerElement);
if (cleanupOnHidePopover !== null) {
cleanupOnHidePopover();
cleanupOnHidePopover = null;
}
activeTarget = null;
}
function handleHidePopoverOnEscape(event) {
if (event.key === "Escape") {
hidePopover();
}
}
export function setupPopovers() {
const targets = document.querySelectorAll("[data-popover-type]");
for (let i = 0; i < targets.length; i++) {
const target = targets[i];
target.addEventListener("mouseenter", handleMouseEnter);
target.addEventListener("mouseleave", handleMouseLeave);
}
}

View file

@ -0,0 +1,190 @@
export function elem(tag = "div") {
return document.createElement(tag);
}
export function fragment(...children) {
const f = document.createDocumentFragment();
if (children) f.append(...children);
return f;
}
export function text(str = "") {
return document.createTextNode(str);
}
export function repeat(n, fn) {
const elems = Array(n);
for (let i = 0; i < n; i++)
elems[i] = fn(i);
return elems;
}
export function find(selector) {
return document.querySelector(selector);
}
export function findAll(selector) {
return document.querySelectorAll(selector);
}
const ep = HTMLElement.prototype;
const fp = DocumentFragment.prototype;
const tp = Text.prototype;
ep.classes = function(...classes) {
this.classList.add(...classes);
return this;
}
ep.find = function(selector) {
return this.querySelector(selector);
}
ep.findAll = function(selector) {
return this.querySelectorAll(selector);
}
ep.classesIf = function(cond, ...classes) {
cond ? this.classList.add(...classes) : this.classList.remove(...classes);
return this;
}
ep.hide = function() {
this.style.display = "none";
return this;
}
ep.show = function() {
this.style.removeProperty("display");
return this;
}
ep.showIf = function(cond) {
cond ? this.show() : this.hide();
return this;
}
ep.isHidden = function() {
return this.style.display === "none";
}
ep.clearClasses = function(...classes) {
classes.length ? this.classList.remove(...classes) : this.className = "";
return this;
}
ep.hasClass = function(className) {
return this.classList.contains(className);
}
ep.attr = function(name, value) {
this.setAttribute(name, value);
return this;
}
ep.attrs = function(attrs) {
for (const [name, value] of Object.entries(attrs))
this.setAttribute(name, value);
return this;
}
ep.tap = function(fn) {
fn(this);
return this;
}
ep.text = function(text) {
this.innerText = text;
return this;
}
ep.html = function(html) {
this.innerHTML = html;
return this;
}
ep.appendTo = function(parent) {
parent.appendChild(this);
return this;
}
ep.swap = function(element) {
this.replaceWith(element);
return element;
}
ep.on = function(event, callback, options) {
if (typeof event === "string") {
this.addEventListener(event, callback, options);
return this;
}
for (let i = 0; i < event.length; i++)
this.addEventListener(event[i], callback, options);
return this;
}
const epAppend = ep.append;
ep.append = function(...children) {
epAppend.apply(this, children);
return this;
}
ep.duplicate = function(n) {
const elems = Array(n);
for (let i = 0; i < n; i++)
elems[i] = this.cloneNode(true);
return elems;
}
ep.styles = function(s) {
Object.assign(this.style, s);
return this;
}
const epAnimate = ep.animate;
ep.animate = function(anim, callback) {
const a = epAnimate.call(this, anim.keyframes, anim.options);
if (callback) a.onfinish = () => callback(this, a);
return this;
}
ep.animateUpdate = function(update, exit, entrance) {
this.animate(exit, () => {
update(this);
this.animate(entrance);
});
return this;
}
ep.styleVar = function(name, value) {
this.style.setProperty(`--${name}`, value);
return this;
}
ep.component = function (methods) {
this.component = methods;
return this;
}
const fpAppend = fp.append;
fp.append = function(...children) {
fpAppend.apply(this, children);
return this;
}
fp.appendTo = function(parent) {
parent.appendChild(this);
return this;
}
tp.text = function(text) {
this.nodeValue = text;
return this;
}

View file

@ -0,0 +1,38 @@
export 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);
};
};
export function isElementVisible(element) {
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
}
export function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
// NOTE: inconsistent behavior between browsers when it comes to
// whether the newly opened tab gets focused or not, potentially
// depending on the event that this function is called from
export function openURLInNewTab(url, focus = true) {
const newWindow = window.open(url, '_blank', 'noopener,noreferrer');
if (focus && newWindow != null) newWindow.focus();
}

Some files were not shown because too many files have changed in this diff Show more