Compare commits

...
311 Commits
Author SHA1 Message Date
Svilen Markov 9a36187333 Add /api/healthz endpoint
Create release / release (push) Has been cancelled
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
Create release / release (push) Has been cancelled
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
Create release / release (push) Has been cancelled
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
Create release / release (push) Has been cancelled
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
Create release / release (push) Has been cancelled
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
Svilen Markov e09ea60616 Update dependencies 2024-07-15 21:59:51 +01:00
Svilen Markov 7cab07fa01 Bump alpine version 2024-07-15 21:59:51 +01:00
Svilen Markov 5a62a8afe9 Add autofocus property to search widget 2024-07-15 21:59:51 +01: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
Svilen Markov 9bda88edbd Merge pull request #145 from ssrtw/rss-feed-enhance
Rss feed enhance
2024-07-03 19:49:32 +01:00
Svilen Markov 16da25898f Prevent users from modifying internal property 2024-07-03 19:48:58 +01:00
Svilen Markov 88ae2b8e83 Merge pull request #164 from dracarys18/main
fix: allow custom bangs without an argument
2024-07-03 19:26:54 +01:00
Albin Parou 7d1ede8c91 releases: Add support for gitlab 2024-07-03 08:20:02 +02:00
dracarys18 3805f8b156 fix: allow custom bangs without an argument 2024-07-03 02:46:16 +05:30
Svilen Markov 39f40670d0 Fix missing RSS thumbnail images 2024-06-29 17:17:27 +01:00
Svilen Markov 514cf2b81c Allow setting widget title URL 2024-06-29 16:10:43 +01:00
Svilen Markov 4dbb5975c0 Allow setting custom CSS classes for individual widgets 2024-06-29 15:55:17 +01:00
Svilen Markov b327e59ab1 Allow specifying a custom lobsters URL or instance 2024-06-29 15:43:07 +01:00
Svilen Markov 917d51e54b Make script a module 2024-06-29 15:20:26 +01:00
Svilen Markov 122d05346b Change caching mechanism 2024-06-29 15:20:26 +01: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
ssrtw a2b7f59dc9 fix: correct title handling for RSS items without titles 2024-06-13 03:13:57 +00:00
ssrtw c23dc93c7a feat: use description as fallback for RSS items without titles 2024-06-12 15:36:29 +00:00
ssrtw d3e3667405 fix: add detailed mode flag to fix RSS logic conflict 2024-06-12 03:28:43 +00:00
Svilen Markov d4ed5afe93 Merge pull request #134 from wfg/github-latest-release-fix
Use GitHub's latest release API endpoint
2024-06-11 20:43:35 +01:00
Svilen Markov 790ecef1c1 Merge pull request #137 from ralphocdol/search-new-tab-property
feat: added property new-tab to type: search
2024-06-11 20:34:05 +01:00
Svilen Markov edb59ffdc6 Small change to implementation for new tab 2024-06-11 20:30:10 +01:00
Svilen Markov e137038586 Fix overflowing text 2024-06-11 01:16:39 +01:00
Svilen Markov 761eb343f9 Fix search input color 2024-06-11 00:35:26 +01:00
Svilen Markov 6c15c907bf Remove clock from preconfigured page 2024-06-11 00:17:25 +01:00
ralphocdol 06b21e3d27 feat: added property new-tab to type: search 2024-06-09 21:18:56 +08:00
Wyatt Gill 263d2e6f30 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-06-07 16:27:13 -05:00
Svilen Markov c97fec66f4 Update dependencies 2024-06-04 23:42:40 +01: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
Svilen Markov 757f68c872 Increase lobsters widget cache time 2024-06-03 00:46:26 +01:00
Svilen Markov b83bf9bc09 Merge pull request #121 from glanceapp/release/v0.5.0
Release v0.5.0
2024-06-02 20:17:50 +01:00
Svilen Markov 8d96e883d6 Update readme 2024-06-02 20:14:35 +01:00
Svilen Markov 75079a981b Add HTML widget 2024-06-02 19:01:20 +01:00
Svilen Markov e8043be107 Add lobsters widget preview 2024-06-02 18:44:27 +01:00
Svilen Markov c276f66017 Add tags to forum posts template 2024-06-02 18:44:17 +01:00
Svilen Markov db7df33b27 Bump versions 2024-06-02 18:24:54 +01:00
Svilen Markov 1b8e355944 Document extension widget 2024-06-02 18:24:54 +01:00
Svilen Markov 342ef90cbe Add extension widget 2024-06-02 18:21:03 +01:00
Svilen Markov b3be60acf6 Document collapse-after-rows property 2024-06-02 18:21:03 +01:00
Svilen Markov b58d3e2849 Document change detection widget 2024-06-02 18:21:03 +01:00
Svilen Markov 4206fb943d Document simple icons usage for monitor icons 2024-06-02 18:21:03 +01:00
Svilen Markov ee58ff40f1 Document allow-insecure property 2024-06-02 18:21:02 +01:00
Svilen Markov 21fd842bde Refactor monitor: allow insecure requests & use of simple icons 2024-06-02 18:21:02 +01:00
Svilen Markov b63b5eb262 Make converting string to simple icon url reusable 2024-06-02 18:21:02 +01:00
Svilen Markov 9c69509a19 Fix markets class 2024-06-02 18:21:02 +01:00
Svilen Markov 511147fb90 Document detailed-list RSS style 2024-06-02 18:21:02 +01:00
Svilen Markov 9ce0030482 Don't offset attachments 2024-06-02 18:21:02 +01:00
Svilen Markov 4164263639 Fix RSS item links without base domain 2024-06-02 18:21:02 +01:00
Svilen Markov 1bebb88d0e Rename Stocks to Markets
Also fix bug that would remove markets if a network request failed
and not show them again until Glance was restarted
2024-06-02 18:21:02 +01:00
Svilen Markov d2f5dbbc26 Fix weather widget making HTTP request as part of validation
Which would prevent app startup if there was no internet connection
2024-06-02 18:21:02 +01:00
Svilen Markov 2af6500028 Add utility class 2024-06-02 18:21:02 +01:00
Svilen Markov a7cf8be8c4 Lower gap between timezones 2024-06-02 18:21:02 +01:00
Svilen Markov 69b3e544bd Fix layout shift on image load 2024-06-02 18:21:02 +01:00
Svilen Markov 0c63c61db5 Position category details to top when tags missing 2024-06-02 18:21:02 +01:00
Svilen Markov 8dc34ddfa7 Merge pull request #56 from jonasknobloch/lobsters-widget
Add Lobsters widget
2024-06-02 18:20:36 +01:00
Svilen Markov 8efda0047c Merge branch 'release/v0.5.0' into lobsters-widget 2024-06-02 18:20:07 +01:00
Svilen Markov e3c17e0347 Merge branch 'main' into lobsters-widget 2024-06-02 18:19:06 +01:00
Svilen Markov 038c43bc09 Document lobsters widget 2024-06-02 18:17:20 +01:00
Svilen Markov 720549ec7e Allow specifying sort order and tags through config 2024-06-02 18:08:29 +01:00
Svilen Markov 3aa1f273b6 Add tags to dedicated field rather than to title 2024-06-02 17:51:49 +01:00
Svilen Markov 5105ebb870 Don't parse unused fields 2024-06-02 17:51:07 +01:00
Svilen Markov 0a55c12f4f Add tags to forum posts 2024-06-02 17:50:46 +01:00
Svilen Markov 67c20c3a5f Merge pull request #46 from knhash/change-detection
feat: add change detection module
2024-05-30 22:55:22 +01:00
Svilen Markov f4f7892123 Merge branch 'release/v0.5.0' into change-detection 2024-05-30 22:55:01 +01:00
Svilen Markov 00a93e466d Update change detection 2024-05-30 22:53:59 +01: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
c0smicandSvilen Markov 3c29d38599 fix: Fix typo
Co-authored-by: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com>
2024-05-27 18:11:35 +00:00
Svilen Markov f8816ff227 Merge pull request #89 from NoxesP/patch-1
docs: update configuration.md
2024-05-25 04:23:06 +01:00
Svilen Markov a5be460c2e Merge pull request #69 from chand1012/search
Add Search Bar Widget
2024-05-25 04:00:20 +01:00
Svilen Markov e1e7853c34 Update search widget 2024-05-25 03:58:11 +01:00
Svilen Markov 3f862f67ab Merge branch 'release/v0.5.0' into search 2024-05-25 02:51:32 +01:00
Svilen Markov 4bb5b2641b Further attempts at making the rain look less scuffed 2024-05-25 02:38:49 +01:00
Svilen Markov f044fdf5dc Add utility classes 2024-05-25 02:38:49 +01:00
Svilen Markov d1be4637ad Fix registering resize event on wrong object 2024-05-25 02:38:49 +01:00
Svilen Markov d18f645c18 Add detailed-list style for RSS 2024-05-25 02:38:49 +01:00
Svilen Markov 6663360caa Don't add classes that are no longer needed 2024-05-25 02:38:48 +01:00
Svilen Markov c2d24ee4d9 Set background color in manifest 2024-05-25 02:38:48 +01:00
Svilen Markov 37471fcdc1 Remove extra new line 2024-05-25 02:38:48 +01:00
Svilen Markov 0f42567d98 Merge pull request #82 from yardenshoham/add-clock-widget
Add a clock widget
2024-05-25 02:32:18 +01:00
Svilen Markov 8148f09b9c Add more features to clock 2024-05-25 02:28:45 +01:00
NoxesP c270afb5e5 docs: update configuration.md
Won't admit how long it took for me to notice this in my glance.yml file after wondering why it wasn't working ahahah.

Small fix :)
2024-05-20 04:47:57 +03:00
Svilen Markov 6de0f73ec1 Fix build breaking because of missing build dir 2024-05-19 21:33:51 +01:00
Svilen Markov a80a34afb1 Merge pull request #83 from wfg/image-build-rework
Image build rework
2024-05-19 18:06:03 +01:00
Svilen Markov 584f813fc4 Merge pull request #87 from hex-developer/main
Fix column overflow
2024-05-19 18:01:21 +01:00
Svilen Markov a44056dc43 Remove other unnecessary shrinks 2024-05-19 18:00:08 +01:00
Svilen Markov c941c78ebf Truncate RSS channel name 2024-05-19 17:52:17 +01:00
Svilen Markov 9e9f001fdf Add newline at end of file 2024-05-19 17:51:59 +01:00
Svilen Markov 9060887694 Remove unnecessary shrink 2024-05-19 17:51:40 +01:00
Svilen Markov 6d9ea27e9f Merge branch 'release/v0.5.0' into main 2024-05-19 17:48:14 +01:00
Svilen Markov 4f4973157c Merge branch 'main' into release/v0.5.0 2024-05-19 17:46:21 +01:00
hex-developer 859a6d2f27 Fix column overflow 2024-05-19 15:52:13 +00:00
Wyatt Gill e3ad646bae Add .dockerignore to keep build context small 2024-05-18 17:42:02 -05:00
Wyatt Gill 53a661861a Build app in Dockerfile
This change makes it simpler and more convenient to build the app.
2024-05-18 17:41:21 -05:00
Yarden Shoham 4a27ec3271 Add a clock widget
It's a simple clock that shows the current time

Signed-off-by: Yarden Shoham <git@yardenshoham.com>
2024-05-18 22:40:33 +03:00
Svilen Markov 926de71274 Add PR template 2024-05-18 19:49:26 +01:00
Svilen Markov b0e57bae9f Merge pull request #81 from yardenshoham/correct-method-in-docs
Specify the correct method in the Monitor widget documentation
2024-05-18 19:19:14 +01:00
Svilen Markov 0bbb4573b2 Revert setTimeout for images
Reduces instances of cached images fading in by giving
the browser a bit more time to load them
2024-05-18 18:52:54 +01:00
Svilen Markov eeda28a287 Reduce reliance on setTimeout shenanigans 2024-05-18 18:43:11 +01:00
Yarden Shoham 59c55b96be Specify the correct method in the Monitor widget documentation
After 189b8895b0 we use `GET` instead of `HEAD`

Signed-off-by: Yarden Shoham <git@yardenshoham.com>
2024-05-18 18:02:12 +03: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
Svilen Markov 21909e0bda Merge pull request #76 from fawni/feat/twitch-channels-sort
Add custom sorting to the twitch channels widget
2024-05-18 11:05:55 +01:00
Svilen Markov 5e9635a76f Change custom order to live 2024-05-18 11:01:34 +01:00
Anthony Harivel 90d718c186 Add last Github commits
Signed-off-by: Anthony Harivel <aharivel@redhat.com>
2024-05-18 11:51:44 +02:00
Svilen Markov 6627a635d5 Update build script 🤦‍♂️ 2024-05-18 10:44:52 +01:00
Svilen Markov 20740f33af Update build script 2024-05-18 10:37:14 +01:00
fawn 0681a04607 Add custom sorting to the twitch channels widget
this allows the channels to be sorted as defined in the config while
still keeping the live channels on the top. the wording could probably
be improved, "custom" is too broad but i'm not sure what else to call
it.
2024-05-18 03:07:34 +03:00
David Leonard 3cfbe0c89b Allow some branding customization. 2024-05-17 14:37:05 -07:00
Svilen Markov 21d491843f Create SECURITY.md 2024-05-17 19:56:18 +01:00
Svilen Markov 566ac99a53 Move code of conduct 2024-05-17 19:55:34 +01:00
Svilen Markov fca504901f Create CODE_OF_CONDUCT.md 2024-05-17 19:31:03 +01:00
Svilen Markov e03231cbd7 Update time label 2024-05-17 19:28:15 +01:00
Svilen Markov 693051f4e9 Merge pull request #59 from tarikcoskun/main
Add 24-hour time format support for weather
2024-05-17 18:41:04 +01:00
Svilen Markov 06e891f9a9 Update doc 2024-05-17 18:37:35 +01:00
Svilen Markov a2bb1b88ea Remove unrelated units check 2024-05-17 18:37:11 +01:00
Svilen Markov 9602269f27 Merge pull request #49 from medinnna/main
Add web app support
2024-05-17 17:47:18 +01:00
Svilen Markov 27b9af6067 Don't transition all
Otherwise animates image dimensions when resizing from mobile to desktop
2024-05-17 17:45:50 +01:00
Svilen Markov d43c0749c1 Only apply box shadow on collapsible grids 2024-05-17 17:45:50 +01:00
Svilen Markov f4fca7600a Merge pull request #74 from brian-petersen/serve-error
Adds reporting serve errors
2024-05-17 17:31:10 +01:00
Brian Petersen 6134f1c812 Adds reporting serve errors 2024-05-17 10:11:00 -06:00
chand1012 8392b7a606 Fix some styling issues and query bug 2024-05-17 10:18:58 -04:00
Svilen Markov 94cf82c3d9 Remove link to discussions 2024-05-17 12:21:44 +01:00
Svilen Markov b0018c3f06 Refactor, again
Make collapsible components reusable
Add ability to collapse video rows in videos-grid style
Lose sanity while dealing with all the intricacies
2024-05-17 12:15:44 +01:00
Svilen Markov 8977114806 Fix viewport offset after collapsing 2024-05-16 21:17:10 +01:00
Svilen Markov 36f8eac3e4 Refactor
Delay showing page content until JS has finished setting up page elements
That then allows the following:
Leave relative time to be rendered on the client
Leave collapsible lists to be rendered on the client
Which massively simplfies the backend templates which were error prone
2024-05-16 20:41:50 +01:00
chand1012 0f01a8eda5 Add search box 2024-05-15 20:04:08 -04:00
Svilen Markov de965dace8 Increase delay before adding class 2024-05-14 16:38:54 +01:00
Svilen Markov f0ca2edb25 Reduce max width of weather columns 2024-05-14 16:36:04 +01:00
Svilen Markov 514d5f92d1 Add link to release notes 2024-05-14 14:33:24 +01:00
Svilen Markov 7e05f8a5eb Add carousel right cutoff by default
Prevents it from fading in when the page loads
2024-05-14 14:32:51 +01:00
Svilen Markov 48d4d4e966 Revert back to vh
dvh causes micro jumps when scrolling near the top of the page which looks glitchy
2024-05-14 06:28:42 +01:00
Tarık Coşkun 6a7b50b35f Add 24-hour time format support for weather 2024-05-13 15:08:58 +03:00
Shashank S 6cad5a8efb Merge remote-tracking branch 'origin' into change-detection 2024-05-13 03:32:52 +02:00
Svilen Markov 7743664527 Merge pull request #57 from glanceapp/release/v0.4.0
Release v0.4.0
2024-05-12 22:12:00 +01:00
Svilen Markov 714e1b6d29 Add dynamic columns style for bookmarks, stocks & monitor sites 2024-05-12 22:07:58 +01:00
Svilen Markov a0585e097f Update codeblocks highlighting 2024-05-12 20:45:57 +01:00
Svilen Markov 6363b76d33 Update codeblocks highlighting 2024-05-12 20:44:35 +01:00
Svilen Markov 503562f2e5 Update readme 2024-05-12 20:42:36 +01:00
Svilen Markov 01a7331354 Update readme with docker image build guide 2024-05-12 20:33:36 +01:00
Jonas Knobloch c07a1ab636 Update default feed 2024-05-12 17:37:04 +02:00
Jonas Knobloch eb9f92f04a Update tag delimiter 2024-05-12 17:14:40 +02:00
Jonas Knobloch c05607934b Fetch posts via JSON endpoint 2024-05-12 17:14:04 +02:00
Svilen Markov f9ca93e0b0 Account for safe area in label offset 2024-05-12 14:45:13 +01:00
Svilen Markov 09b7d71103 Revert to using the small icon for the favicon
Bit too much whitespace in the app icon to be able to use it as a favicon, makes the G look quite small
2024-05-12 14:27:25 +01:00
Svilen Markov bc06578e53 Change implementation for the safe area 2024-05-12 14:20:10 +01:00
Jonas Knobloch 344f518991 Add Lobsters widget 2024-05-12 13:20:34 +02:00
Svilen Markov 6bb44bef20 Merge pull request #54 from eltociear/patch-1
docs: update configuration.md
2024-05-12 11:03:18 +01:00
Svilen Markov aef0c84286 Move engagement calculation inside widget 2024-05-12 11:00:45 +01:00
Svilen Markov 0fbc8dcb41 Allow specifying links for stock symbol & chart 2024-05-12 04:13:38 +01:00
Svilen Markov 818138340c Rename widget to just repository 2024-05-12 03:35:25 +01:00
Svilen Markov 4beab6bcec Make images fade in as they load 2024-05-12 03:18:55 +01:00
Ikko Eltociear Ashimine b9b7ae1cac docs: update configuration.md
minor fix
2024-05-12 03:09:12 +09:00
Carlos Medina 91c75518da Change mobile nav styles 2024-05-10 19:54:43 -06:00
Svilen Markov 82f51293df Add more refinement options for HN widget 2024-05-10 14:08:01 +01:00
Svilen Markov 840ba0f240 Add more refinement options for Reddit widget 2024-05-10 13:23:57 +01:00
Svilen Markov c5e3eed64b Add Repository Overview widget 2024-05-10 11:05:42 +01:00
Svilen Markov 7dd1cca65d Add horizontal-cards-2 style for RSS widget 2024-05-10 10:10:13 +01:00
Svilen Markov 57414bdb67 So. Many. Units. 2024-05-10 09:52:05 +01:00
Svilen Markov 646be63895 Fix 1px black border in video thumbnails 2024-05-10 08:26:55 +01:00
Svilen Markov 3c1fb97c12 Add grid-cards style for videos widget 2024-05-10 07:41:11 +01:00
Svilen Markov e78619f5e1 Update build to include openbsd and armv7 2024-05-09 13:00:42 +01:00
Shashank S aa04904a72 feat: add diff, fix: configurable url 2024-05-09 08:32:27 +02:00
Svilen Markov 54102ae35e Make RSS thumbnail height configurable 2024-05-09 06:01:16 +01:00
Svilen Markov 7c4c1798b6 Lowercase domains 2024-05-09 04:49:42 +01:00
Svilen Markov f30006093d Tweak breakpoints of horizontal cards 2024-05-09 04:46:22 +01:00
Svilen Markov c8ef47064a Merge branch 'main' into release/v0.4.0 2024-05-09 04:22:51 +01:00
Svilen Markov 461c984317 Check if background color is nil before setting 2024-05-09 04:05:25 +01:00
Carlos Medina ecbad7ce6c Add mobile web app support 2024-05-08 16:51:04 -06:00
Shashank S 7adf624e95 feat: add change detection module 2024-05-08 11:47:25 +02:00
Svilen Markov 56cb97706b Document show-mobile-header property 2024-05-08 01:38:49 +01:00
Svilen Markov dd5bec799f Allow specifying Url from env 2024-05-08 01:30:02 +01:00
Svilen Markov 2389415071 Add mobile reachability header 2024-05-08 01:21:55 +01:00
Svilen Markov 976fb02bd3 Lower gap between releases 2024-05-06 16:53:24 +01:00
Svilen Markov 007fe18d27 Add utility classes 2024-05-06 16:53:13 +01:00
Svilen Markov 11f2999b52 Update readme 2024-05-06 16:52:56 +01:00
Svilen Markov e204029d3c Merge pull request #41 from chev2/catppuccin-themes
Add the other Catppuccin themes
2024-05-03 22:09:39 +01:00
Chev d3eaae2fb0 Update docs/themes.md with new Catppuccin themes 2024-05-03 12:52:02 -07:00
Chev 251c22a14f Rename catppuccin.png to catppuccin-mocha.png
For consistency
2024-05-03 12:50:53 -07:00
Chev 7d0a7186e8 Add new Catppuccin theme screenshots 2024-05-03 12:49:45 -07:00
134 changed files with 6232 additions and 1128 deletions
+10
View File
@@ -0,0 +1,10 @@
# https://docs.docker.com/build/building/context/#dockerignore-files
# Ignore all files by default
*
# Only add necessary files to the Docker build context (Dockerfiles are always included implicitly)
!/build/
!/internal/
!/go.mod
!/go.sum
!main.go
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
glanceapp@duck.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+1
View File
@@ -0,0 +1 @@
github: [glanceapp]
+7
View File
@@ -0,0 +1,7 @@
<!--
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.
-->
+9
View File
@@ -0,0 +1,9 @@
# Security Policy
## Supported Versions
Security updates will be applied to the latest as well as previous minor version release depending on severity and if applicable.
## Reporting a Vulnerability
Please report any suspected security vulnerabilities to [glanceapp@duck.com](mailto:glanceapp@duck.com) and do not disclose them publicly. You should receive a response within a few days and if confirmed the issue will be resolved as soon as possible.
+39
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
+1 -1
View File
@@ -1,4 +1,4 @@
/assets /assets
/build /build
/playground /playground
glance.yml glance*.yml
+69
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
+8 -5
View File
@@ -1,10 +1,13 @@
FROM alpine:3.19 FROM golang:1.22.5-alpine3.20 AS builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /app WORKDIR /app
COPY build/glance-$TARGETOS-$TARGETARCH /app/glance COPY . /app
RUN CGO_ENABLED=0 go build .
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /app/glance .
EXPOSE 8080/tcp EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance"] ENTRYPOINT ["/app/glance"]
+8
View File
@@ -0,0 +1,8 @@
FROM alpine:3.20
WORKDIR /app
COPY glance .
EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance"]
+28 -7
View File
@@ -1,6 +1,6 @@
<p align="center"><em>What if you could see everything at a...</em></p> <p align="center"><em>What if you could see everything at a...</em></p>
<h1 align="center">Glance</h1> <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) ![example homepage](docs/images/readme-main-image.png)
@@ -10,13 +10,18 @@
* Subreddit posts * Subreddit posts
* Weather * Weather
* Bookmarks * Bookmarks
* Hacker News
* Lobsters
* Latest YouTube videos from specific channels * Latest YouTube videos from specific channels
* Clock
* Calendar * Calendar
* Stocks * Stocks
* iframe * iframe
* Twitch channels & top games * Twitch channels & top games
* GitHub releases * GitHub releases
* Repository overview
* Site monitor * Site monitor
* Search box
#### Themeable #### Themeable
![multiple color schemes example](docs/images/themes-example.png) ![multiple color schemes example](docs/images/themes-example.png)
@@ -41,16 +46,16 @@ Checkout the [configuration docs](docs/configuration.md) to learn more. A [preco
#### Manual #### 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: 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:
``` ```bash
/opt/glance/glance --config /etc/glance.yml /opt/glance/glance --config /etc/glance.yml
``` ```
#### Docker #### Docker
> [!IMPORTANT] > [!IMPORTANT]
> >
> Make sure you have a valid `glance.yml` file before running the container. > Make sure you have a valid `glance.yml` file in the same directory before running the container.
```console ```bash
docker run -d -p 8080:8080 \ docker run -d -p 8080:8080 \
-v ./glance.yml:/app/glance.yml \ -v ./glance.yml:/app/glance.yml \
-v /etc/timezone:/etc/timezone:ro \ -v /etc/timezone:/etc/timezone:ro \
@@ -79,12 +84,28 @@ Requirements: [Go](https://go.dev/dl/) >= v1.22
To build: To build:
``` ```bash
go build . go build -o build/glance .
``` ```
To run: To run:
``` ```bash
go run . go run .
``` ```
### Building Docker image
Build the image:
**Make sure to replace "owner" with your name or organization.**
```bash
docker build -t owner/glance:latest .
```
Push the image to your registry:
```bash
docker push owner/glance:latest
```
+695 -43
View File
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
# Extensions
> [!IMPORTANT]
>
> **This document as well as the extensions feature are a work in progress. The API may change in the future. You are responsible for maintaining your own extensions.**
## Overview
With the intention of requiring minimal knowledge in order to develop extensions, rather than being a convoluted protocol they are nothing more than an HTTP request to a server that returns a few special headers. The exchange between Glance and extensions can be seen in the following diagram:
![](images/extension-overview.png)
If you know how to setup an HTTP server and a bit of HTML and CSS you're ready to start building your own extensions.
> [!TIP]
>
> By default, the extension widget has a cache time of 30 minutes. To avoid having to restart Glance after every extension change you can set the cache time of the widget to 1 second:
> ```yaml
> - type: extension
> url: http://localhost:8081
> cache: 1s
> ```
## Headers
### `Widget-Title`
Used to specify the title of the widget. If not provided, the widget's title will be "Extension".
### `Widget-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.
## Content Types
> [!NOTE]
>
> Currently, `html` is the only supported content type. The long-term goal is to have generic content types such as `videos`, `forum-posts`, `markets`, `streams`, etc. which will be returned in JSON format and displayed by Glance using existing styles and functionality, allowing extension developers to achieve a native look while only focusing on providing data from their preferred source.
### `html`
Displays the content as HTML. This requires the user to have the `allow-potentially-dangerous-html` property set to `true`, otherwise the content will be shown as plain text.
#### Using existing classes and functionality
Most of the features seen throughout Glance can easily be used in your custom HTML extensions. Below is an example of some of these features:
```html
<p class="color-subdue">Text with subdued color</p>
<p>Text with base color</p>
<p class="color-highlight">Text with highlighted color</p>
<p class="color-primary">Text with primary color</p>
<p class="color-positive">Text with positive color</p>
<p class="color-negative">Text with negative color</p>
<hr class="margin-block-15">
<p class="size-h1">Font size 1</p>
<p class="size-h2">Font size 2</p>
<p class="size-h3">Font size 3</p>
<p class="size-h4">Font size 4</p>
<p class="size-base">Font size base</p>
<p class="size-h5">Font size 5</p>
<p class="size-h6">Font size 6</p>
<hr class="margin-block-15">
<a class="visited-indicator" href="#notvisitedprobably">Link with visited indicator</a>
<hr class="margin-block-15">
<a class="color-primary-if-not-visited" href="#notvisitedprobably">Link with primary color if not visited</a>
<hr class="margin-block-15">
<p>Event happened <span data-dynamic-relative-time="<unix timestamp>"></span> ago</p>
<hr class="margin-block-15">
<ul class="list-horizontal-text">
<li>horizontal</li>
<li>list</li>
<li>with</li>
<li>multiple</li>
<li>text</li>
<li>items</li>
</ul>
<hr class="margin-block-15">
<ul class="list list-gap-10 list-with-separator">
<li>list</li>
<li>with</li>
<li>gap</li>
<li>and</li>
<li>horizontal</li>
<li>lines</li>
</ul>
<hr class="margin-block-15">
<ul class="list collapsible-container" data-collapse-after="3">
<li>collapsible</li>
<li>list</li>
<li>with</li>
<li>many</li>
<li>items</li>
<li>that</li>
<li>will</li>
<li>appear</li>
<li>when</li>
<li>you</li>
<li>click</li>
<li>the</li>
<li>button</li>
<li>below</li>
</ul>
<hr class="margin-bottom-15">
<p class="margin-bottom-10">Lazily loaded image:</p>
<img src="https://picsum.photos/200" alt="" loading="lazy">
<hr class="margin-block-15">
<p class="margin-bottom-10">List of posts:</p>
<ul class="list list-gap-14 collapsible-container" data-collapse-after="5">
<li>
<a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
<ul class="list-horizontal-text">
<li data-dynamic-relative-time="<unix timestamp>"></li>
<li>3,321 points</li>
<li>139 comments</li>
</ul>
</li>
<li>
<a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
<ul class="list-horizontal-text">
<li data-dynamic-relative-time="<unix timestamp>"></li>
<li>3,321 points</li>
<li>139 comments</li>
</ul>
</li>
<li>
<a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
<ul class="list-horizontal-text">
<li data-dynamic-relative-time="<unix timestamp>"></li>
<li>3,321 points</li>
<li>139 comments</li>
</ul>
</li>
</ul>
```
All of that will result in the following:
![](images/extension-html-reusing-existing-features-preview.png)
**Class names or features may change, once again, you are responsible for maintaining your own extensions.**
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 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: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 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: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Before

Width:  |  Height:  |  Size: 341 KiB

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

+222
View File
@@ -0,0 +1,222 @@
# 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!
## 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>
+46 -2
View File
@@ -11,8 +11,30 @@ theme:
contrast-multiplier: 1.1 contrast-multiplier: 1.1
``` ```
### Catppuccin ### Catppuccin Frappe
![screenshot](images/themes/catppuccin.png) ![screenshot](images/themes/catppuccin-frappe.png)
```yaml
theme:
background-color: 229 19 23
contrast-multiplier: 1.2
primary-color: 222 74 74
positive-color: 96 44 68
negative-color: 359 68 71
```
### Catppuccin Macchiato
![screenshot](images/themes/catppuccin-macchiato.png)
```yaml
theme:
background-color: 232 23 18
contrast-multiplier: 1.2
primary-color: 220 83 75
positive-color: 105 48 72
negative-color: 351 74 73
```
### Catppuccin Mocha
![screenshot](images/themes/catppuccin-mocha.png)
```yaml ```yaml
theme: theme:
background-color: 240 21 15 background-color: 240 21 15
@@ -31,6 +53,16 @@ theme:
primary-color: 97 13 80 primary-color: 97 13 80
``` ```
### 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 ### Tucan
![screenshot](images/themes/tucan.png) ![screenshot](images/themes/tucan.png)
```yaml ```yaml
@@ -42,6 +74,18 @@ theme:
## Light ## Light
### Catppuccin Latte
![screenshot](images/themes/catppuccin-latte.png)
```yaml
theme:
light: true
background-color: 220 23 95
contrast-multiplier: 1.0
primary-color: 220 91 54
positive-color: 109 58 40
negative-color: 347 87 44
```
### Peachy ### Peachy
![screenshot](images/themes/peachy.png) ![screenshot](images/themes/peachy.png)
```yaml ```yaml
+4 -4
View File
@@ -1,19 +1,19 @@
module github.com/glanceapp/glance module github.com/glanceapp/glance
go 1.22.0 go 1.22.5
require ( require (
github.com/mmcdole/gofeed v1.3.0 github.com/mmcdole/gofeed v1.3.0
golang.org/x/text v0.14.0 golang.org/x/text v0.16.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/PuerkitoBio/goquery v1.9.1 // indirect github.com/PuerkitoBio/goquery v1.9.2 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/mmcdole/goxpp v1.1.1 // indirect github.com/mmcdole/goxpp v1.1.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
golang.org/x/net v0.24.0 // indirect golang.org/x/net v0.27.0 // indirect
) )
+6 -6
View File
@@ -1,5 +1,5 @@
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI= github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= 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 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -33,8 +33,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 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.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.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -54,8 +54,8 @@ 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.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.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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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.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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+41
View File
@@ -1,8 +1,14 @@
package assets package assets
import ( import (
"crypto/md5"
"embed" "embed"
"encoding/hex"
"io"
"io/fs" "io/fs"
"log/slog"
"strconv"
"time"
) )
//go:embed static //go:embed static
@@ -13,3 +19,38 @@ var _templateFS embed.FS
var PublicFS, _ = fs.Sub(_publicFS, "static") var PublicFS, _ = fs.Sub(_publicFS, "static")
var TemplateFS, _ = fs.Sub(_templateFS, "templates") 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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

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

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

+1
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

+1
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

+599
View File
@@ -0,0 +1,599 @@
import { setupPopovers } from './popover.js';
import { throttledDebounce, isElementVisible } from './utils.js';
async function fetchPageContent(pageData) {
// TODO: handle non 200 status codes/time outs
// TODO: add retries
const response = await fetch(`${pageData.baseURL}/api/pages/${pageData.slug}/content/`);
const content = await response.text();
return content;
}
function setupCarousels() {
const carouselElements = document.getElementsByClassName("carousel-container");
if (carouselElements.length == 0) {
return;
}
for (let i = 0; i < carouselElements.length; i++) {
const carousel = carouselElements[i];
carousel.classList.add("show-right-cutoff");
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
const determineSideCutoffs = () => {
if (itemsContainer.scrollLeft != 0) {
carousel.classList.add("show-left-cutoff");
} else {
carousel.classList.remove("show-left-cutoff");
}
if (Math.ceil(itemsContainer.scrollLeft) + itemsContainer.clientWidth < itemsContainer.scrollWidth) {
carousel.classList.add("show-right-cutoff");
} else {
carousel.classList.remove("show-right-cutoff");
}
}
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
window.addEventListener("resize", determineSideCutoffsRateLimited);
afterContentReady(determineSideCutoffs);
}
}
const minuteInSeconds = 60;
const hourInSeconds = minuteInSeconds * 60;
const dayInSeconds = hourInSeconds * 24;
const monthInSeconds = dayInSeconds * 30;
const yearInSeconds = monthInSeconds * 12;
function relativeTimeSince(timestamp) {
const delta = Math.round((Date.now() / 1000) - timestamp);
if (delta < minuteInSeconds) {
return "1m";
}
if (delta < hourInSeconds) {
return Math.floor(delta / minuteInSeconds) + "m";
}
if (delta < dayInSeconds) {
return Math.floor(delta / hourInSeconds) + "h";
}
if (delta < monthInSeconds) {
return Math.floor(delta / dayInSeconds) + "d";
}
if (delta < yearInSeconds) {
return Math.floor(delta / monthInSeconds) + "mo";
}
return Math.floor(delta / yearInSeconds) + "y";
}
function updateRelativeTimeForElements(elements)
{
for (let i = 0; i < elements.length; i++)
{
const element = elements[i];
const timestamp = element.dataset.dynamicRelativeTime;
if (timestamp === undefined)
continue
element.textContent = relativeTimeSince(timestamp);
}
}
function setupSearchBoxes() {
const searchWidgets = document.getElementsByClassName("search");
if (searchWidgets.length == 0) {
return;
}
for (let i = 0; i < searchWidgets.length; i++) {
const widget = searchWidgets[i];
const defaultSearchUrl = widget.dataset.defaultSearchUrl;
const newTab = widget.dataset.newTab === "true";
const inputElement = widget.getElementsByClassName("search-input")[0];
const bangElement = widget.getElementsByClassName("search-bang")[0];
const bangs = widget.querySelectorAll(".search-bangs > input");
const bangsMap = {};
const kbdElement = widget.getElementsByTagName("kbd")[0];
let currentBang = null;
for (let j = 0; j < bangs.length; j++) {
const bang = bangs[j];
bangsMap[bang.dataset.shortcut] = bang;
}
const handleKeyDown = (event) => {
if (event.key == "Escape") {
inputElement.blur();
return;
}
if (event.key == "Enter") {
const input = inputElement.value.trim();
let query;
let searchUrlTemplate;
if (currentBang != null) {
query = input.slice(currentBang.dataset.shortcut.length + 1);
searchUrlTemplate = currentBang.dataset.url;
} else {
query = input;
searchUrlTemplate = defaultSearchUrl;
}
if (query.length == 0 && currentBang == null) {
return;
}
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
window.open(url, '_blank').focus();
} else {
window.location.href = url;
}
return;
}
};
const changeCurrentBang = (bang) => {
currentBang = bang;
bangElement.textContent = bang != null ? bang.dataset.title : "";
}
const handleInput = (event) => {
const value = event.target.value.trim();
if (value in bangsMap) {
changeCurrentBang(bangsMap[value]);
return;
}
const words = value.split(" ");
if (words.length >= 2 && words[0] in bangsMap) {
changeCurrentBang(bangsMap[words[0]]);
return;
}
changeCurrentBang(null);
};
inputElement.addEventListener("focus", () => {
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("input", handleInput);
});
inputElement.addEventListener("blur", () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("input", handleInput);
});
document.addEventListener("keydown", (event) => {
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
if (event.key != "s") return;
inputElement.focus();
event.preventDefault();
});
kbdElement.addEventListener("mousedown", () => {
requestAnimationFrame(() => inputElement.focus());
});
}
}
function setupDynamicRelativeTime() {
const elements = document.querySelectorAll("[data-dynamic-relative-time]");
const updateInterval = 60 * 1000;
let lastUpdateTime = Date.now();
updateRelativeTimeForElements(elements);
const updateElementsAndTimestamp = () => {
updateRelativeTimeForElements(elements);
lastUpdateTime = Date.now();
};
const scheduleRepeatingUpdate = () => setInterval(updateElementsAndTimestamp, updateInterval);
if (document.hidden === undefined) {
scheduleRepeatingUpdate();
return;
}
let timeout = scheduleRepeatingUpdate();
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
clearTimeout(timeout);
return;
}
const delta = Date.now() - lastUpdateTime;
if (delta >= updateInterval) {
updateElementsAndTimestamp();
timeout = scheduleRepeatingUpdate();
return;
}
timeout = setTimeout(() => {
updateElementsAndTimestamp();
timeout = scheduleRepeatingUpdate();
}, updateInterval - delta);
});
}
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];
title.addEventListener("click", () => {
if (t == current) {
return;
}
for (let i = 0; i < titles.length; i++) {
titles[i].classList.remove("widget-group-title-current");
tabs[i].classList.remove("widget-group-content-current");
}
if (current < t) {
tabs[t].dataset.direction = "right";
} else {
tabs[t].dataset.direction = "left";
}
current = t;
title.classList.add("widget-group-title-current");
tabs[t].classList.add("widget-group-content-current");
});
}
}
}
function setupLazyImages() {
const images = document.querySelectorAll("img[loading=lazy]");
if (images.length == 0) {
return;
}
function imageFinishedTransition(image) {
image.classList.add("finished-transition");
}
afterContentReady(() => {
setTimeout(() => {
for (let i = 0; i < images.length; i++) {
const image = images[i];
if (image.complete) {
image.classList.add("cached");
setTimeout(() => imageFinishedTransition(image), 1);
} else {
// TODO: also handle error event
image.addEventListener("load", () => {
image.classList.add("loaded");
setTimeout(() => imageFinishedTransition(image), 400);
});
}
}
}, 1);
});
}
function attachExpandToggleButton(collapsibleContainer) {
const showMoreText = "Show more";
const showLessText = "Show less";
let expanded = false;
const button = document.createElement("button");
const icon = document.createElement("span");
icon.classList.add("expand-toggle-button-icon");
const textNode = document.createTextNode(showMoreText);
button.classList.add("expand-toggle-button");
button.append(textNode, icon);
button.addEventListener("click", () => {
expanded = !expanded;
if (expanded) {
collapsibleContainer.classList.add("container-expanded");
button.classList.add("container-expanded");
textNode.nodeValue = showLessText;
return;
}
const topBefore = button.getClientRects()[0].top;
collapsibleContainer.classList.remove("container-expanded");
button.classList.remove("container-expanded");
textNode.nodeValue = showMoreText;
const topAfter = button.getClientRects()[0].top;
if (topAfter > 0)
return;
window.scrollBy({
top: topAfter - topBefore,
behavior: "instant"
});
});
collapsibleContainer.after(button);
return button;
};
function setupCollapsibleLists() {
const collapsibleLists = document.querySelectorAll(".list.collapsible-container");
if (collapsibleLists.length == 0) {
return;
}
for (let i = 0; i < collapsibleLists.length; i++) {
const list = collapsibleLists[i];
if (list.dataset.collapseAfter === undefined) {
continue;
}
const collapseAfter = parseInt(list.dataset.collapseAfter);
if (collapseAfter == -1) {
continue;
}
if (list.children.length <= collapseAfter) {
continue;
}
attachExpandToggleButton(list);
for (let c = collapseAfter; c < list.children.length; c++) {
const child = list.children[c];
child.classList.add("collapsible-item");
child.style.animationDelay = ((c - collapseAfter) * 20).toString() + "ms";
}
}
}
function setupCollapsibleGrids() {
const collapsibleGridElements = document.querySelectorAll(".cards-grid.collapsible-container");
if (collapsibleGridElements.length == 0) {
return;
}
for (let i = 0; i < collapsibleGridElements.length; i++) {
const gridElement = collapsibleGridElements[i];
if (gridElement.dataset.collapseAfterRows === undefined) {
continue;
}
const collapseAfterRows = parseInt(gridElement.dataset.collapseAfterRows);
if (collapseAfterRows == -1) {
continue;
}
const getCardsPerRow = () => {
return parseInt(getComputedStyle(gridElement).getPropertyValue('--cards-per-row'));
};
const button = attachExpandToggleButton(gridElement);
let cardsPerRow;
const resolveCollapsibleItems = () => {
const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
if (hideItemsAfterIndex >= gridElement.children.length) {
button.style.display = "none";
} else {
button.style.removeProperty("display");
}
let row = 0;
for (let i = 0; i < gridElement.children.length; i++) {
const child = gridElement.children[i];
if (i >= hideItemsAfterIndex) {
child.classList.add("collapsible-item");
child.style.animationDelay = (row * 40).toString() + "ms";
if (i % cardsPerRow + 1 == cardsPerRow) {
row++;
}
} else {
child.classList.remove("collapsible-item");
child.style.removeProperty("animation-delay");
}
}
};
const observer = new ResizeObserver(() => {
if (!isElementVisible(gridElement)) {
return;
}
const newCardsPerRow = getCardsPerRow();
if (cardsPerRow == newCardsPerRow) {
return;
}
cardsPerRow = newCardsPerRow;
resolveCollapsibleItems();
});
afterContentReady(() => observer.observe(gridElement));
}
}
const contentReadyCallbacks = [];
function afterContentReady(callback) {
contentReadyCallbacks.push(callback);
}
const weekDayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
function makeSettableTimeElement(element, hourFormat) {
const fragment = document.createDocumentFragment();
const hour = document.createElement('span');
const minute = document.createElement('span');
const amPm = document.createElement('span');
fragment.append(hour, document.createTextNode(':'), minute);
if (hourFormat == '12h') {
fragment.append(document.createTextNode(' '), amPm);
}
element.append(fragment);
return (date) => {
const hours = date.getHours();
if (hourFormat == '12h') {
amPm.textContent = hours < 12 ? 'AM' : 'PM';
hour.textContent = hours % 12 || 12;
} else {
hour.textContent = hours < 10 ? '0' + hours : hours;
}
const minutes = date.getMinutes();
minute.textContent = minutes < 10 ? '0' + minutes : minutes;
};
};
function timeInZone(now, zone) {
let timeInZone;
try {
timeInZone = new Date(now.toLocaleString('en-US', { timeZone: zone }));
} catch (e) {
// TODO: indicate to the user that this is an invalid timezone
console.error(e);
timeInZone = now
}
const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
return { time: timeInZone, diffInHours: diffInHours };
}
function setupClocks() {
const clocks = document.getElementsByClassName('clock');
if (clocks.length == 0) {
return;
}
const updateCallbacks = [];
for (var i = 0; i < clocks.length; i++) {
const clock = clocks[i];
const hourFormat = clock.dataset.hourFormat;
const localTimeContainer = clock.querySelector('[data-local-time]');
const localDateElement = localTimeContainer.querySelector('[data-date]');
const localWeekdayElement = localTimeContainer.querySelector('[data-weekday]');
const localYearElement = localTimeContainer.querySelector('[data-year]');
const timeZoneContainers = clock.querySelectorAll('[data-time-in-zone]');
const setLocalTime = makeSettableTimeElement(
localTimeContainer.querySelector('[data-time]'),
hourFormat
);
updateCallbacks.push((now) => {
setLocalTime(now);
localDateElement.textContent = now.getDate() + ' ' + monthNames[now.getMonth()];
localWeekdayElement.textContent = weekDayNames[now.getDay()];
localYearElement.textContent = now.getFullYear();
});
for (var z = 0; z < timeZoneContainers.length; z++) {
const timeZoneContainer = timeZoneContainers[z];
const diffElement = timeZoneContainer.querySelector('[data-time-diff]');
const setZoneTime = makeSettableTimeElement(
timeZoneContainer.querySelector('[data-time]'),
hourFormat
);
updateCallbacks.push((now) => {
const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
setZoneTime(time);
diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
});
}
}
const updateClocks = () => {
const now = new Date();
for (var i = 0; i < updateCallbacks.length; i++)
updateCallbacks[i](now);
setTimeout(updateClocks, (60 - now.getSeconds()) * 1000);
};
updateClocks();
}
async function setupPage() {
const pageElement = document.getElementById("page");
const pageContentElement = document.getElementById("page-content");
const pageContent = await fetchPageContent(pageData);
pageContentElement.innerHTML = pageContent;
try {
setupPopovers();
setupClocks()
setupCarousels();
setupSearchBoxes();
setupCollapsibleLists();
setupCollapsibleGrids();
setupGroups();
setupDynamicRelativeTime();
setupLazyImages();
} finally {
pageElement.classList.add("content-ready");
for (let i = 0; i < contentReadyCallbacks.length; i++) {
contentReadyCallbacks[i]();
}
setTimeout(() => {
document.body.classList.add("page-columns-transitioned");
}, 300);
}
}
setupPage();
+182
View File
@@ -0,0 +1,182 @@
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 observer = new ResizeObserver(repositionContainer);
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() {
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;
containerElement.style.display = "block";
activeTarget.classList.add("popover-active");
document.addEventListener("keydown", handleHidePopoverOnEscape);
window.addEventListener("resize", repositionContainer);
observer.observe(containerElement);
}
function repositionContainer() {
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.popoverOffset || 0.5);
const position = activeTarget.dataset.popoverPosition || "below";
const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width / 2));
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) + "px");
} else {
containerElement.style.removeProperty("right");
containerElement.style.left = left + "px";
containerElement.style.removeProperty("--triangle-offset");
}
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", repositionContainer);
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);
}
}
+25
View File
@@ -0,0 +1,25 @@
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);
}
File diff suppressed because it is too large Load Diff
-163
View File
@@ -1,163 +0,0 @@
function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
let debounceTimeout;
let timesDebounced = 0;
return function () {
if (timesDebounced == maxDebounceTimes) {
clearTimeout(debounceTimeout);
timesDebounced = 0;
callback();
return;
}
clearTimeout(debounceTimeout);
timesDebounced++;
debounceTimeout = setTimeout(() => {
timesDebounced = 0;
callback();
}, debounceDelay);
};
};
async function fetchPageContents (pageSlug) {
// TODO: handle non 200 status codes/time outs
// TODO: add retries
const response = await fetch(`/api/pages/${pageSlug}/content/`);
const content = await response.text();
return content;
}
function setupCarousels() {
const carouselElements = document.getElementsByClassName("carousel-container");
for (let i = 0; i < carouselElements.length; i++) {
const carousel = carouselElements[i];
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
const determineSideCutoffs = () => {
if (itemsContainer.scrollLeft != 0) {
carousel.classList.add("show-left-cutoff");
} else {
carousel.classList.remove("show-left-cutoff");
}
if (Math.ceil(itemsContainer.scrollLeft) + itemsContainer.clientWidth < itemsContainer.scrollWidth) {
carousel.classList.add("show-right-cutoff");
} else {
carousel.classList.remove("show-right-cutoff");
}
}
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
document.addEventListener("resize", determineSideCutoffsRateLimited);
determineSideCutoffs();
}
}
const minuteInSeconds = 60;
const hourInSeconds = minuteInSeconds * 60;
const dayInSeconds = hourInSeconds * 24;
const monthInSeconds = dayInSeconds * 30;
const yearInSeconds = monthInSeconds * 12;
function relativeTimeSince(timestamp) {
const delta = Math.round((Date.now() / 1000) - timestamp);
if (delta < minuteInSeconds) {
return "1m";
}
if (delta < hourInSeconds) {
return Math.floor(delta / minuteInSeconds) + "m";
}
if (delta < dayInSeconds) {
return Math.floor(delta / hourInSeconds) + "h";
}
if (delta < monthInSeconds) {
return Math.floor(delta / dayInSeconds) + "d";
}
if (delta < yearInSeconds) {
return Math.floor(delta / monthInSeconds) + "mo";
}
return Math.floor(delta / yearInSeconds) + "y";
}
function updateRelativeTimeForElements(elements)
{
for (let i = 0; i < elements.length; i++)
{
const element = elements[i];
const timestamp = element.dataset.dynamicRelativeTime;
if (timestamp === undefined)
continue
element.innerText = relativeTimeSince(timestamp);
}
}
function setupDynamicRelativeTime() {
const elements = document.querySelectorAll("[data-dynamic-relative-time]");
const updateInterval = 60 * 1000;
let lastUpdateTime = Date.now();
const updateElementsAndTimestamp = () => {
updateRelativeTimeForElements(elements);
lastUpdateTime = Date.now();
};
const scheduleRepeatingUpdate = () => setInterval(updateElementsAndTimestamp, updateInterval);
if (document.hidden === undefined) {
scheduleRepeatingUpdate();
return;
}
let timeout = scheduleRepeatingUpdate();
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
clearTimeout(timeout);
return;
}
const delta = Date.now() - lastUpdateTime;
if (delta >= updateInterval) {
updateElementsAndTimestamp();
timeout = scheduleRepeatingUpdate();
return;
}
timeout = setTimeout(() => {
updateElementsAndTimestamp();
timeout = scheduleRepeatingUpdate();
}, updateInterval - delta);
});
}
async function setupPage() {
const pageElement = document.getElementById("page");
const pageContents = await fetchPageContents(pageData.slug);
pageElement.innerHTML = pageContents;
setTimeout(() => {
document.body.classList.add("animate-element-transition");
}, 150);
setupCarousels();
setupDynamicRelativeTime();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", setupPage);
} else {
setupPage();
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "Glance",
"display": "standalone",
"background_color": "#151519",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "app-icon.png",
"type": "image/png",
"sizes": "512x512"
}
]
}
+13 -16
View File
@@ -15,6 +15,7 @@ var (
PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl") PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
PageContentTemplate = compileTemplate("content.html") PageContentTemplate = compileTemplate("content.html")
CalendarTemplate = compileTemplate("calendar.html", "widget-base.html") CalendarTemplate = compileTemplate("calendar.html", "widget-base.html")
ClockTemplate = compileTemplate("clock.html", "widget-base.html")
BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html") BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html")
IFrameTemplate = compileTemplate("iframe.html", "widget-base.html") IFrameTemplate = compileTemplate("iframe.html", "widget-base.html")
WeatherTemplate = compileTemplate("weather.html", "widget-base.html") WeatherTemplate = compileTemplate("weather.html", "widget-base.html")
@@ -22,13 +23,22 @@ var (
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html") RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html") RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html") ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
VideosTemplate = compileTemplate("videos.html", "widget-base.html") ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html")
StocksTemplate = compileTemplate("stocks.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") RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
RSSCardsTemplate = compileTemplate("rss-cards.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") MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html") TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
TwitchChannelsTemplate = compileTemplate("twitch-channels.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")
GroupTemplate = compileTemplate("group.html", "widget-base.html")
DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html")
) )
var globalTemplateFunctions = template.FuncMap{ var globalTemplateFunctions = template.FuncMap{
@@ -41,19 +51,6 @@ var globalTemplateFunctions = template.FuncMap{
"formatPrice": func(price float64) string { "formatPrice": func(price float64) string {
return intl.Sprintf("%.2f", price) 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 { "dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix())) return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix()))
}, },
+4 -4
View File
@@ -1,9 +1,9 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<ul class="list list-gap-24 list-with-separator"> <div class="dynamic-columns list-gap-24 list-with-separator">
{{ range .Groups }} {{ range .Groups }}
<li class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}> <div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }} {{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
<ul class="list list-gap-2"> <ul class="list list-gap-2">
{{ range .Links }} {{ range .Links }}
@@ -17,7 +17,7 @@
</li> </li>
{{ end }} {{ end }}
</ul> </ul>
</li> </div>
{{ end }} {{ end }}
</ul> </div>
{{ end }} {{ end }}
+2
View File
@@ -1,6 +1,7 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<div class="widget-small-content-bounds">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div> <div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div>
<ul class="list-horizontal-text color-highlight size-h4"> <ul class="list-horizontal-text color-highlight size-h4">
@@ -24,4 +25,5 @@
<div class="calendar-day{{ if eq . $.Calendar.CurrentDay }} calendar-day-today{{ end }}">{{ . }}</div> <div class="calendar-day{{ if eq . $.Calendar.CurrentDay }} calendar-day-today{{ end }}">{{ . }}</div>
{{ end }} {{ end }}
</div> </div>
</div>
{{ end }} {{ end }}
@@ -0,0 +1,17 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .ChangeDetections }}
<li>
<a class="size-h4 block text-truncate color-highlight" href="{{ .URL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text">
<li {{ dynamicRelativeTimeAttrs .LastChanged }}></li>
<li class="shrink min-width-0"><a class="visited-indicator" href="{{ .DiffURL }}" target="_blank" rel="noreferrer">diff:{{ .PreviousHash }}</a></li>
</ul>
</li>
{{ else }}
<li>No watches configured</li>
{{ end}}
</ul>
{{ end }}
+30
View File
@@ -0,0 +1,30 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="clock" data-hour-format="{{ .HourFormat }}">
<div class="flex justify-between items-center" data-local-time>
<div>
<div class="color-highlight size-h1" data-date></div>
<div data-year></div>
</div>
<div class="text-right">
<div class="clock-time size-h1" data-time></div>
<div data-weekday></div>
</div>
</div>
{{ if gt (len .Timezones) 0 }}
<hr class="margin-block-10">
<ul class="list list-gap-4">
{{ range .Timezones }}
<li class="flex items-center gap-15" data-time-in-zone="{{ .Timezone }}">
<div class="grow min-width-0">
<div class="text-truncate">{{ if ne .Label "" }}{{ .Label }}{{ else }}{{ .Timezone }}{{ end }}</div>
</div>
<div class="color-subdue" data-time-diff></div>
<div class="size-h4 clock-time shrink-0 text-right" data-time></div>
</li>
{{ end }}
</ul>
{{ end }}
</div>
{{ end }}
+4
View File
@@ -1,3 +1,7 @@
{{ if .Page.ShowMobileHeader }}
<div class="mobile-reachability-header">{{ .Page.Title }}</div>
{{ end }}
<div class="page-columns"> <div class="page-columns">
{{ range .Page.Columns }} {{ range .Page.Columns }}
<div class="page-column page-column-{{ .Size }}"> <div class="page-column page-column-{{ .Size }}">
+85
View File
@@ -0,0 +1,85 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="widget-small-content-bounds dns-stats">
<div class="flex text-center justify-between dns-stats-totals">
<div>
<div class="color-highlight size-h3">{{ .Stats.TotalQueries | formatNumber }}</div>
<div class="size-h6">QUERIES</div>
</div>
<div>
<div class="color-highlight size-h3">{{ .Stats.BlockedPercent }}%</div>
<div class="size-h6">BLOCKED</div>
</div>
{{ if gt .Stats.ResponseTime 0 }}
<div>
<div class="color-highlight size-h3">{{ .Stats.ResponseTime | formatNumber }}ms</div>
<div class="size-h6">LATENCY</div>
</div>
{{ else }}
<div class="cursor-help" data-popover-type="text" data-popover-text="Total number of blocked domains from all adlists" data-popover-max-width="200px" data-popover-text-align="center">
<div class="color-highlight size-h3">{{ .Stats.DomainsBlocked | formatViewerCount }}</div>
<div class="size-h6">DOMAINS</div>
</div>
{{ end }}
</div>
<div class="dns-stats-graph margin-top-15">
<div class="dns-stats-graph-gridlines-container">
<svg class="dns-stats-graph-gridlines" shape-rendering="crispEdges" viewBox="0 0 1 100" preserveAspectRatio="none">
<g stroke="var(--color-graph-gridlines)" stroke-width="1">
<line x1="0" y1="1" x2="1" y2="1" vector-effect="non-scaling-stroke" />
<line x1="0" y1="25" x2="1" y2="25" vector-effect="non-scaling-stroke" />
<line x1="0" y1="50" x2="1" y2="50" vector-effect="non-scaling-stroke" />
<line x1="0" y1="75" x2="1" y2="75" vector-effect="non-scaling-stroke" />
<line x1="0" y1="99" x2="1" y2="99" vector-effect="non-scaling-stroke" stroke="var(--color-progress-bar-border)"/>
</g>
</svg>
</div>
<div class="dns-stats-graph-columns">
{{ range $i, $column := .Stats.Series }}
<div class="dns-stats-graph-column" data-popover-type="html" data-popover-position="above" data-popover-show-delay="500">
<div data-popover-html>
<div class="flex text-center justify-between gap-25">
<div>
<div class="color-highlight size-h3">{{ $column.Queries | formatNumber }}</div>
<div class="size-h6">QUERIES</div>
</div>
<div>
<div class="color-highlight size-h3">{{ $column.PercentBlocked }}%</div>
<div class="size-h6">BLOCKED</div>
</div>
</div>
</div>
{{ if gt $column.PercentTotal 0}}
<div class="dns-stats-graph-bar" style="--bar-height: {{ $column.PercentTotal }}">
{{ if ne $column.Queries $column.Blocked }}
<div class="queries"></div>
{{ end }}
{{ if or (gt $column.Blocked 0) (and (lt $column.PercentTotal 15) (lt $column.PercentBlocked 10)) }}
<div class="blocked" style="flex-basis: {{ $column.PercentBlocked }}%"></div>
{{ end }}
</div>
{{ end }}
<div class="dns-stats-graph-time">{{ index $.TimeLabels $i }}</div>
</div>
{{ end }}
</div>
</div>
{{ if .Stats.TopBlockedDomains }}
<details class="details margin-top-40">
<summary class="summary">Top blocked domains</summary>
<ul class="list list-gap-4 list-with-transition size-h5">
{{ range .Stats.TopBlockedDomains }}
<li class="flex justify-between align-center">
<div class="text-truncate rtl">{{ .Domain }}</div>
<div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
</li>
{{ end }}
</ul>
</details>
{{ end }}
</div>
{{ end }}
+11 -4
View File
@@ -5,10 +5,17 @@
<title>{{ block "document-title" . }}{{ end }}</title> <title>{{ block "document-title" . }}{{ end }}</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<link rel="icon" type="image/png" href="/static/favicon.png" /> <meta name="apple-mobile-web-app-capable" content="yes">
<link rel="stylesheet" href="/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}"> <meta name="mobile-web-app-capable" content="yes">
<script async src="/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Glance">
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
<link rel="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}">
<link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
<link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
<script type="module" src="{{ .App.AssetPath "js/main.js" }}"></script>
{{ block "document-head-after" . }}{{ end }} {{ block "document-head-after" . }}{{ end }}
</head> </head>
<body> <body>
+5
View File
@@ -0,0 +1,5 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
{{ .Extension.Content }}
{{ end }}
+27 -17
View File
@@ -1,14 +1,18 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<ul class="list list-gap-14 list-collapsible"> <ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range $i, $post := .Posts }} {{ range .Posts }}
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}> <li>
<div class="forum-post-list-item thumbnail-container"> <div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
{{ if $.ShowThumbnails }} {{ if $.ShowThumbnails }}
{{ if ne $post.ThumbnailUrl "" }} {{ if .IsCrosspost }}
<img class="forum-post-list-thumbnail thumbnail" src="{{ $post.ThumbnailUrl }}" alt="" loading="lazy"> <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)">
{{ else if $post.HasTargetUrl }} <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
{{ else 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)"> <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" /> <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> </svg>
@@ -18,14 +22,23 @@
</svg> </svg>
{{ end }} {{ end }}
{{ end }} {{ end }}
<div class="grow"> <div class="grow min-width-0">
<a href="{{ $post.DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a> <a href="{{ .DiscussionUrl }}" class="size-title-dynamic 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"> <ul class="list-horizontal-text">
<li title="{{ $post.TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs $post.TimePosted }}>{{ $post.TimePosted | relativeTime }}</li> <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ $post.Score | formatNumber }} points</li> <li>{{ .Score | formatNumber }} points</li>
<li>{{ $post.CommentCount | formatNumber }} comments</li> <li>{{ .CommentCount | formatNumber }} comments</li>
{{ if $post.HasTargetUrl }} {{ if .HasTargetUrl }}
<li class="shrink min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ $post.TargetUrlDomain }}</a></li> <li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
{{ end }} {{ end }}
</ul> </ul>
</div> </div>
@@ -33,7 +46,4 @@
</li> </li>
{{ end }} {{ end }}
</ul> </ul>
{{ if gt (len .Posts) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }} {{ end }}
+20
View File
@@ -0,0 +1,20 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="widget-group-header">
<div class="widget-header gap-20">
{{ range $i, $widget := .Widgets }}
<button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}">{{ $widget.Title }}</button>
{{ end }}
</div>
</div>
<div class="widget-group-contents">
{{ range $i, $widget := .Widgets }}
<div class="widget-group-content{{ if eq $i 0 }} widget-group-content-current{{ end }}">{{ .Render }}</div>
{{ end }}
</div>
{{ end }}
+25
View File
@@ -0,0 +1,25 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="dynamic-columns list-gap-20 list-with-separator">
{{ range .Markets }}
<div class="flex items-center gap-15">
<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>
</div>
{{ end }}
</div>
{{ end }}
+25 -11
View File
@@ -1,17 +1,34 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<ul class="list list-gap-20 list-with-separator"> {{ if not (and .ShowFailingOnly (not .HasFailing)) }}
<ul class="dynamic-columns list-gap-20 list-with-separator">
{{ range .Sites }} {{ range .Sites }}
<li class="monitor-site flex items-center gap-15"> {{ if and $.ShowFailingOnly (eq .StatusStyle "ok" ) }} {{ continue }} {{ end }}
{{ if .IconUrl }} <div class="monitor-site flex items-center gap-15">
<img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy"> {{ template "site" . }}
</div>
{{ end }} {{ end }}
<div> </ul>
<a class="size-h3 color-highlight" href="{{ .Url }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a> {{ else }}
<div class="flex items-center justify-center gap-10 padding-block-5">
<p>All sites are online</p>
<svg class="shrink-0" style="width: 1.7rem;" 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>
{{ end }}
{{ end }}
{{ define "site" }}
{{ if .IconUrl }}
<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy">
{{ end }}
<div class="min-width-0">
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text"> <ul class="list-horizontal-text">
{{ if not .Status.Error }} {{ if not .Status.Error }}
<li>{{ .StatusText }}</li> <li title="{{ .Status.Code }}">{{ .StatusText }}</li>
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li> <li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
{{ else if .Status.TimedOut }} {{ else if .Status.TimedOut }}
<li class="color-negative">Timed Out</li> <li class="color-negative">Timed Out</li>
@@ -20,7 +37,7 @@
{{ end }} {{ end }}
</ul> </ul>
</div> </div>
{{ if eq .StatusStyle "good" }} {{ if eq .StatusStyle "ok" }}
<div class="monitor-site-status-icon"> <div class="monitor-site-status-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)"> <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" /> <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" />
@@ -33,7 +50,4 @@
</svg> </svg>
</div> </div>
{{ end }} {{ end }}
</li>
{{ end }}
</ul>
{{ end }} {{ end }}
+21 -10
View File
@@ -1,16 +1,18 @@
{{ template "document.html" . }} {{ template "document.html" . }}
{{ define "document-title" }}{{ .Page.Title }} - Glance{{ end }} {{ define "document-title" }}{{ .Page.Title }}{{ end }}
{{ define "document-head-before" }} {{ define "document-head-before" }}
<script> <script>
const pageData = { const pageData = {
slug: "{{ .Page.Slug }}", slug: "{{ .Page.Slug }}",
baseURL: "{{ .App.Config.Server.BaseURL }}",
}; };
</script> </script>
{{ end }} {{ end }}
{{ define "document-root-attrs" }}{{ if .App.Config.Theme.Light }}class="light-scheme"{{ end }}{{ end }} {{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }} {{ end }}{{ if .Page.CenterVertically }}page-center-vertically{{ end }}"{{ end }}
{{ define "document-head-after" }} {{ define "document-head-after" }}
{{ template "page-style-overrides.gotmpl" . }} {{ template "page-style-overrides.gotmpl" . }}
{{ if ne "" .App.Config.Theme.CustomCSSFile }} {{ if ne "" .App.Config.Theme.CustomCSSFile }}
@@ -20,20 +22,23 @@
{{ define "navigation-links" }} {{ define "navigation-links" }}
{{ range .App.Config.Pages }} {{ range .App.Config.Pages }}
<a href="/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a> <a href="{{ $.App.Config.Server.BaseURL }}/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a>
{{ end }} {{ end }}
{{ end }} {{ end }}
{{ define "document-body" }} {{ define "document-body" }}
<div class="flex flex-column height-100">
{{ if not .Page.HideDesktopNavigation }}
<div class="header-container content-bounds"> <div class="header-container content-bounds">
<div class="header flex padding-inline-widget widget-content-frame"> <div class="header flex padding-inline-widget widget-content-frame">
<!-- TODO: Replace G with actual logo, first need an actual logo --> <!-- TODO: Replace G with actual logo, first need an actual logo -->
<div class="logo">G</div> <div class="logo">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
<div class="nav flex grow"> <div class="nav flex grow">
{{ template "navigation-links" . }} {{ template "navigation-links" . }}
</div> </div>
</div> </div>
</div> </div>
{{ end }}
<div class="mobile-navigation"> <div class="mobile-navigation">
<div class="mobile-navigation-icons"> <div class="mobile-navigation-icons">
@@ -48,8 +53,9 @@
</div> </div>
</div> </div>
<div class="content-bounds"> <div class="content-bounds grow">
<div class="page" id="page"> <div class="page" id="page">
<div class="page-content" id="page-content"></div>
<div class="page-loading-container"> <div class="page-loading-container">
<!-- TODO: add a bigger/better loading indicator --> <!-- TODO: add a bigger/better loading indicator -->
<div class="loading-icon"></div> <div class="loading-icon"></div>
@@ -57,13 +63,18 @@
</div> </div>
</div> </div>
{{ if not .App.Config.Branding.HideFooter }}
<div class="footer flex items-center flex-column"> <div class="footer flex items-center flex-column">
{{ if eq "" .App.Config.Branding.CustomFooter }}
<div> <div>
<span class="size-h3">Glance</span> ({{ .App.Version }}) <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> </div>
<ul class="list-horizontal-text margin-top-5 size-h5 color-primary"> {{ else }}
<li><a href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a></li> {{ .App.Config.Branding.CustomFooter }}
<li><a href="https://github.com/glanceapp/glance/discussions" target="_blank" rel="noreferrer">Submit feedback</a></li> {{ end }}
</ul> </div>
{{ end }}
<div class="mobile-navigation-offset"></div>
</div> </div>
{{ end }} {{ end }}
@@ -20,7 +20,7 @@
{{ end }} {{ end }}
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a> <a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text margin-top-7"> <ul class="list-horizontal-text margin-top-7">
<li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li> <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ .Score | formatNumber }} points</li> <li>{{ .Score | formatNumber }} points</li>
</ul> </ul>
</div> </div>
@@ -19,7 +19,7 @@
{{ end }} {{ end }}
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a> <a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text margin-top-7"> <ul class="list-horizontal-text margin-top-7">
<li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li> <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ .Score | formatNumber }} points</li> <li>{{ .Score | formatNumber }} points</li>
</ul> </ul>
</div> </div>
+13 -11
View File
@@ -1,21 +1,23 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<ul class="list list-gap-14 list-collapsible"> <ul class="list list-gap-10 collapsible-container single-line-titles" data-collapse-after="{{ .CollapseAfter }}">
{{ range $i, $release := .Releases }} {{ range .Releases }}
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}> <li>
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a> <div class="flex items-center gap-10">
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ .NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
{{ if $.ShowSourceIcon }}
<img class="simple-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
{{ end }}
</div>
<ul class="list-horizontal-text"> <ul class="list-horizontal-text">
<li title="{{ $release.TimeReleased | formatTime }}" {{ dynamicRelativeTimeAttrs $release.TimeReleased }}>{{ $release.TimeReleased | relativeTime }}</li> <li {{ dynamicRelativeTimeAttrs .TimeReleased }}></li>
<li>{{ $release.Version }}</li> <li>{{ .Version }}</li>
{{ if gt $release.Downvotes 3 }} {{ if gt .Downvotes 3 }}
<li>{{ $release.Downvotes | formatNumber }} ⚠</li> <li>{{ .Downvotes | formatNumber }} ⚠</li>
{{ end }} {{ end }}
</ul> </ul>
</li> </li>
{{ end }} {{ end }}
</ul> </ul>
{{ if gt (len .Releases) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }} {{ end }}
+61
View File
@@ -0,0 +1,61 @@
{{ 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.Commits) 0 }}
<hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .RepositoryDetails.Commits }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .RepositoryDetails.Commits }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Author }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/commit/{{ .Sha }}">{{ .Message }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
{{ 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 }}
@@ -0,0 +1,40 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-24 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Items }}
<li class="flex gap-15 items-start row-reverse-on-mobile thumbnail-parent">
<div class="thumbnail-container rss-detailed-thumbnail">
{{ if ne "" .ImageURL }}
<img class="thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
{{ else }}
<svg class="scale-half hide-on-mobile" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
{{ end }}
</div>
<div class="grow min-width-0">
<a class="size-h3 color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap">
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="min-width-0">
<a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
</li>
</ul>
{{ if ne "" .Description }}
<p class="rss-detailed-description text-truncate-2-lines margin-top-10">{{ .Description }}</p>
{{ end }}
{{ if gt (len .Categories) 0 }}
<ul class="attachments margin-top-10">
{{ range .Categories }}
<li>{{ . }}</li>
{{ end }}
</ul>
{{ end }}
</div>
</li>
{{ else }}
<li>{{ .NoItemsMessage }}</li>
{{ end }}
</ul>
{{ end }}
@@ -0,0 +1,32 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
{{ if gt (len .Items) 0 }}
<div class="carousel-container">
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
{{ range .Items }}
<div class="card rss-card-2 widget-content-frame thumbnail-parent">
{{ if ne "" .ImageURL }}
<img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
{{ else }}
<svg class="rss-card-2-image" style="transform: scale(0.35) translateY(-25%)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="var(--color-text-subdue)">
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
{{ end }}
<div class="rss-card-2-content padding-inline-widget">
<a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-5">
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
</ul>
</div>
</div>
{{ end }}
</div>
</div>
{{ else }}
<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
{{ end }}
{{ end }}
@@ -3,10 +3,11 @@
{{ define "widget-content-classes" }}widget-content-frameless{{ end }} {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }} {{ define "widget-content" }}
{{ if gt (len .Items) 0 }}
<div class="carousel-container"> <div class="carousel-container">
<div class="cards-horizontal carousel-items-container"> <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
{{ range .Items }} {{ range .Items }}
<div class="card widget-content-frame thumbnail-container"> <div class="card widget-content-frame thumbnail-parent">
{{ if ne "" .ImageURL }} {{ if ne "" .ImageURL }}
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt=""> <img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
{{ else }} {{ else }}
@@ -17,12 +18,15 @@
<div class="margin-bottom-widget padding-inline-widget flex flex-column grow"> <div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a> <a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-7"> <ul class="list-horizontal-text flex-nowrap margin-top-7">
<li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li> <li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li> <li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
</ul> </ul>
</div> </div>
</div> </div>
{{ end }} {{ end }}
</div> </div>
</div> </div>
{{ else }}
<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
{{ end }}
{{ end }} {{ end }}
+11 -12
View File
@@ -1,20 +1,19 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<ul class="list list-gap-14 list-collapsible"> <ul class="list list-gap-14 collapsible-container{{ if .SingleLineTitles }} single-line-titles{{ end }}" data-collapse-after="{{ .CollapseAfter }}">
{{ range $i, $item := .Items }} {{ range .Items }}
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}> <li>
<a class="size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a> <a class="title size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
<ul class="list-horizontal-text"> <ul class="list-horizontal-text flex-nowrap">
<li title="{{ $item.PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs $item.PublishedAt }}>{{ .PublishedAt | relativeTime }}</li> <li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
{{ if gt (len $.FeedRequests) 1 }} <li class="min-width-0">
<li><a href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a></li> <a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
{{ end }} </li>
</ul> </ul>
</li> </li>
{{ else }}
<li>{{ .NoItemsMessage }}</li>
{{ end }} {{ end }}
</ul> </ul>
{{ if gt (len .Items) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }} {{ end }}
+24
View File
@@ -0,0 +1,24 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}" data-new-tab="{{ .NewTab }}">
<div class="search-bangs">
{{ range .Bangs }}
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
{{ end }}
</div>
<div class="search-icon-container">
<svg class="search-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</div>
<input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off"{{ if .Autofocus }} autofocus{{ end }}>
<div class="search-bang"></div>
<kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
</div>
{{ end }}
-23
View File
@@ -1,23 +0,0 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-20 list-with-separator">
{{ range .Stocks }}
<li class="flex items-center gap-15">
<div class="shrink min-width-0">
<div class="color-highlight size-h3 text-truncate">{{ .Symbol }}</div>
<div class="text-truncate">{{ .Name }}</div>
</div>
<svg class="stock-chart shrink-0" viewBox="0 0 100 50">
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
</svg>
<div class="stock-values shrink-0">
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
</div>
</li>
{{ end }}
</ul>
{{ end }}
+17 -16
View File
@@ -1,27 +1,31 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<ul class="list list-gap-14 list-collapsible"> <ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range $i, $channel := .Channels }} {{ range .Channels }}
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}> <li>
<div class="{{ if $channel.IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-container"> <div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
<div class="twitch-channel-avatar-container"> <div class="twitch-channel-avatar-container">
{{ if $channel.Exists }} {{ if .Exists }}
<img class="twitch-channel-avatar thumbnail" src="{{ $channel.AvatarUrl }}" alt="" loading="lazy"> <a href="https://twitch.tv/{{ .Login }}" class="twitch-channel-avatar-link" target="_blank" rel="noreferrer">
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
</a>
{{ else }} {{ else }}
<svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg> </svg>
{{ end }} {{ end }}
</div> </div>
<div class="shrink min-width-0"> <div class="min-width-0">
<a href="https://twitch.tv/{{ $channel.Login }}" class="size-h3{{ if $channel.IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ $channel.Name }}</a> <a href="https://twitch.tv/{{ .Login }}" class="size-h3{{ if .IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ .Name }}</a>
{{ if $channel.Exists }} {{ if .Exists }}
{{ if $channel.IsLive }} {{ if .IsLive }}
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ $channel.CategorySlug }}" target="_blank" rel="noreferrer">{{ $channel.Category }}</a> {{ if .Category }}
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a>
{{ end }}
<ul class="list-horizontal-text"> <ul class="list-horizontal-text">
<li title="{{ $channel.LiveSince | formatTime }}" {{ dynamicRelativeTimeAttrs $channel.LiveSince }}>{{ $channel.LiveSince | relativeTime }}</li> <li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
<li>{{ $channel.ViewersCount | formatViewerCount }} viewers</li> <li>{{ .ViewersCount | formatViewerCount }} viewers</li>
</ul> </ul>
{{ else }} {{ else }}
<div>Offline</div> <div>Offline</div>
@@ -34,7 +38,4 @@
</li> </li>
{{ end }} {{ end }}
</ul> </ul>
{{ if gt (len .Channels) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }} {{ end }}
@@ -1,26 +1,25 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<ul class="list list-gap-14 list-collapsible"> <ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range $i, $category := .Categories }} {{ range .Categories }}
{{ $shouldCollapseItem := shouldCollapse $i $.CollapseAfter }} <li class="twitch-category thumbnail-parent">
<li class="twitch-category thumbnail-container{{ if $shouldCollapseItem }} list-collapsible-item{{ end }}" {{ if $shouldCollapseItem }}style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}> <div class="flex gap-10 items-start">
<div class="flex gap-10 items-center"> <img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ .AvatarUrl }}" alt="">
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ $category.AvatarUrl }}" alt=""> <div class="min-width-0">
<div class="shrink min-width-0"> <a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ $category.Slug }}" target="_blank" rel="noreferrer">{{ $category.Name }}</a>
<ul class="list-horizontal-text"> <ul class="list-horizontal-text">
<li>{{ $category.ViewersCount | formatViewerCount }} viewers</li> <li>{{ .ViewersCount | formatViewerCount }} viewers</li>
{{ if $category.IsNew }} {{ if .IsNew }}
<li class="color-primary">NEW</li> <li class="color-primary">NEW</li>
{{ end }} {{ end }}
</ul> </ul>
<ul class="list-horizontal-text flex-nowrap"> <ul class="list-horizontal-text flex-nowrap">
{{ range $i, $tag := $category.Tags }} {{ range $i, $tag := .Tags }}
{{ if eq $i 0 }} {{ if eq $i 0 }}
<li class="shrink-0">{{ $tag.Name }}</li> <li class="shrink-0">{{ $tag.Name }}</li>
{{ else }} {{ else }}
<li class="text-truncate shrink min-width-0">{{ $tag.Name }}</li> <li class="text-truncate min-width-0">{{ $tag.Name }}</li>
{{ end }} {{ end }}
{{ end }} {{ end }}
</ul> </ul>
@@ -29,7 +28,4 @@
</li> </li>
{{ end }} {{ end }}
</ul> </ul>
{{ if gt (len .Categories) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }} {{ end }}
@@ -0,0 +1,12 @@
{{ define "video-card-contents" }}
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-7">
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li class="min-width-0">
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
</li>
</ul>
</div>
{{ end }}
@@ -0,0 +1,13 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="cards-grid collapsible-container" data-collapse-after-rows="{{ .CollapseAfterRows }}">
{{ range .Videos }}
<div class="card widget-content-frame thumbnail-parent">
{{ template "video-card-contents" . }}
</div>
{{ end }}
</div>
{{ end }}
+3 -12
View File
@@ -4,19 +4,10 @@
{{ define "widget-content" }} {{ define "widget-content" }}
<div class="carousel-container"> <div class="carousel-container">
<div class="videos cards-horizontal carousel-items-container"> <div class="cards-horizontal carousel-items-container">
{{ range .Videos }} {{ range .Videos }}
<div class="card widget-content-frame thumbnail-container"> <div class="card widget-content-frame thumbnail-parent">
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt=""> {{ template "video-card-contents" . }}
<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-7">
<li class="shrink-0" title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
<li class="shrink min-width-0">
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
</li>
</ul>
</div>
</div> </div>
{{ end }} {{ end }}
</div> </div>
+2
View File
@@ -1,6 +1,7 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<div class="widget-small-content-bounds">
<div class="size-h2 color-highlight text-center">{{ .Weather.WeatherCodeAsString }}</div> <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="size-h4 text-center">Feels like {{ .Weather.ApparentTemperature }}°{{ if eq .Units "metric" }}C{{ else }}F{{ end }}</div>
@@ -26,4 +27,5 @@
<div class="text-truncate">{{ .Place.Name }},{{ if .ShowAreaName }} {{ .Place.Area }},{{ end }} {{ .Place.Country }}</div> <div class="text-truncate">{{ .Place.Name }},{{ if .ShowAreaName }} {{ .Place.Area }},{{ end }} {{ .Place.Country }}</div>
</div> </div>
{{ end }} {{ end }}
</div>
{{ end }} {{ end }}
+4 -2
View File
@@ -1,12 +1,14 @@
<div class="widget widget-type-{{ .GetType }}"> <div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
{{ if not .HideHeader}}
<div class="widget-header"> <div class="widget-header">
<div class="uppercase">{{ .Title }}</div> {{ 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 }} {{ if and .Error .ContentAvailable }}
<div class="notice-icon notice-icon-major" title="{{ .Error }}"></div> <div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
{{ else if .Notice }} {{ else if .Notice }}
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div> <div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
{{ end }} {{ end }}
</div> </div>
{{ end }}
<div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}"> <div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}">
{{ if .ContentAvailable }} {{ if .ContentAvailable }}
{{ block "widget-content" . }}{{ end }} {{ block "widget-content" . }}{{ end }}
+120
View File
@@ -0,0 +1,120 @@
package feed
import (
"net/http"
"strings"
)
type adguardStatsResponse struct {
TotalQueries int `json:"num_dns_queries"`
QueriesSeries []int `json:"dns_queries"`
BlockedQueries int `json:"num_blocked_filtering"`
BlockedSeries []int `json:"blocked_filtering"`
ResponseTime float64 `json:"avg_processing_time"`
TopBlockedDomains []map[string]int `json:"top_blocked_domains"`
}
func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error) {
requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats"
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
request.SetBasicAuth(username, password)
responseJson, err := decodeJsonFromRequest[adguardStatsResponse](defaultClient, request)
if err != nil {
return nil, err
}
var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
stats := &DNSStats{
TotalQueries: responseJson.TotalQueries,
BlockedQueries: responseJson.BlockedQueries,
ResponseTime: int(responseJson.ResponseTime * 1000),
TopBlockedDomains: make([]DNSStatsBlockedDomain, 0, topBlockedDomainsCount),
}
if stats.TotalQueries <= 0 {
return stats, nil
}
stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
for i := 0; i < topBlockedDomainsCount; i++ {
domain := responseJson.TopBlockedDomains[i]
var firstDomain string
for k := range domain {
firstDomain = k
break
}
if firstDomain == "" {
continue
}
stats.TopBlockedDomains = append(stats.TopBlockedDomains, DNSStatsBlockedDomain{
Domain: firstDomain,
})
if stats.BlockedQueries > 0 {
stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100)
}
}
queriesSeries := responseJson.QueriesSeries
blockedSeries := responseJson.BlockedSeries
const bars = 8
const hoursSpan = 24
const hoursPerBar int = hoursSpan / bars
if len(queriesSeries) > hoursSpan {
queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:]
} else if len(queriesSeries) < hoursSpan {
queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...)
}
if len(blockedSeries) > hoursSpan {
blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:]
} else if len(blockedSeries) < hoursSpan {
blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...)
}
maxQueriesInSeries := 0
for i := 0; i < bars; i++ {
queries := 0
blocked := 0
for j := 0; j < hoursPerBar; j++ {
queries += queriesSeries[i*hoursPerBar+j]
blocked += blockedSeries[i*hoursPerBar+j]
}
stats.Series[i] = DNSStatsSeries{
Queries: queries,
Blocked: blocked,
}
if queries > 0 {
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
}
if queries > maxQueriesInSeries {
maxQueriesInSeries = queries
}
}
for i := 0; i < bars; i++ {
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
}
return stats, nil
}
+139
View File
@@ -0,0 +1,139 @@
package feed
import (
"fmt"
"log/slog"
"net/http"
"sort"
"strings"
"time"
)
type ChangeDetectionWatch struct {
Title string
URL string
LastChanged time.Time
DiffURL string
PreviousHash string
}
type ChangeDetectionWatches []ChangeDetectionWatch
func (r ChangeDetectionWatches) SortByNewest() ChangeDetectionWatches {
sort.Slice(r, func(i, j int) bool {
return r[i].LastChanged.After(r[j].LastChanged)
})
return r
}
type changeDetectionResponseJson struct {
Title string `json:"title"`
URL string `json:"url"`
LastChanged int64 `json:"last_changed"`
DateCreated int64 `json:"date_created"`
PreviousHash string `json:"previous_md5"`
}
func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) {
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil)
if token != "" {
request.Header.Add("x-api-key", token)
}
uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultClient, request)
if err != nil {
return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err)
}
uuids := make([]string, 0, len(uuidsMap))
for uuid := range uuidsMap {
uuids = append(uuids, uuid)
}
return uuids, nil
}
func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) {
watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs))
if len(requestedWatchIDs) == 0 {
return watches, nil
}
requests := make([]*http.Request, len(requestedWatchIDs))
for i, repository := range requestedWatchIDs {
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch/%s", instanceURL, repository), nil)
if token != "" {
request.Header.Add("x-api-key", token)
}
requests[i] = request
}
task := decodeJsonFromRequestTask[changeDetectionResponseJson](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 change detection watch", "error", errs[i], "url", requests[i].URL)
continue
}
watchJson := responses[i]
watch := ChangeDetectionWatch{
URL: watchJson.URL,
DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1),
}
if watchJson.LastChanged == 0 {
watch.LastChanged = time.Unix(watchJson.DateCreated, 0)
} else {
watch.LastChanged = time.Unix(watchJson.LastChanged, 0)
}
if watchJson.Title != "" {
watch.Title = watchJson.Title
} else {
watch.Title = strings.TrimPrefix(strings.Trim(stripURLScheme(watchJson.URL), "/"), "www.")
}
if watchJson.PreviousHash != "" {
var hashLength = 8
if len(watchJson.PreviousHash) < hashLength {
hashLength = len(watchJson.PreviousHash)
}
watch.PreviousHash = watchJson.PreviousHash[0:hashLength]
}
watches = append(watches, watch)
}
if len(watches) == 0 {
return nil, ErrNoContent
}
watches.SortByNewest()
if failed > 0 {
return watches, fmt.Errorf("%w: could not get %d watches", ErrPartialContent, failed)
}
return watches, nil
}
+39
View File
@@ -0,0 +1,39 @@
package feed
import (
"fmt"
"net/http"
)
type codebergReleaseResponseJson struct {
TagName string `json:"tag_name"`
PublishedAt string `json:"published_at"`
HtmlUrl string `json:"html_url"`
}
func fetchLatestCodebergRelease(request *ReleaseRequest) (*AppRelease, error) {
httpRequest, err := http.NewRequest(
"GET",
fmt.Sprintf(
"https://codeberg.org/api/v1/repos/%s/releases/latest",
request.Repository,
),
nil,
)
if err != nil {
return nil, err
}
response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultClient, httpRequest)
if err != nil {
return nil, err
}
return &AppRelease{
Source: ReleaseSourceCodeberg,
Name: request.Repository,
Version: normalizeVersionFormat(response.TagName),
NotesUrl: response.HtmlUrl,
TimeReleased: parseRFC3339Time(response.PublishedAt),
}, nil
}
+102
View File
@@ -0,0 +1,102 @@
package feed
import (
"fmt"
"net/http"
"strings"
)
type dockerHubRepositoryTagsResponse struct {
Results []dockerHubRepositoryTagResponse `json:"results"`
}
type dockerHubRepositoryTagResponse struct {
Name string `json:"name"`
LastPushed string `json:"tag_last_pushed"`
}
const dockerHubOfficialRepoTagURLFormat = "https://hub.docker.com/_/%s/tags?name=%s"
const dockerHubRepoTagURLFormat = "https://hub.docker.com/r/%s/tags?name=%s"
const dockerHubTagsURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags"
const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags/%s"
func fetchLatestDockerHubRelease(request *ReleaseRequest) (*AppRelease, error) {
nameParts := strings.Split(request.Repository, "/")
if len(nameParts) > 2 {
return nil, fmt.Errorf("invalid repository name: %s", request.Repository)
} else if len(nameParts) == 1 {
nameParts = []string{"library", nameParts[0]}
}
tagParts := strings.SplitN(nameParts[1], ":", 2)
var requestURL string
if len(tagParts) == 2 {
requestURL = fmt.Sprintf(dockerHubSpecificTagURLFormat, nameParts[0], tagParts[0], tagParts[1])
} else {
requestURL = fmt.Sprintf(dockerHubTagsURLFormat, nameParts[0], nameParts[1])
}
httpRequest, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
if request.Token != nil {
httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
}
var tag *dockerHubRepositoryTagResponse
if len(tagParts) == 1 {
response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultClient, httpRequest)
if err != nil {
return nil, err
}
if len(response.Results) == 0 {
return nil, fmt.Errorf("no tags found for repository: %s", request.Repository)
}
tag = &response.Results[0]
} else {
response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultClient, httpRequest)
if err != nil {
return nil, err
}
tag = &response
}
var repo string
var displayName string
var notesURL string
if len(tagParts) == 1 {
repo = nameParts[1]
} else {
repo = tagParts[0]
}
if nameParts[0] == "library" {
displayName = repo
notesURL = fmt.Sprintf(dockerHubOfficialRepoTagURLFormat, repo, tag.Name)
} else {
displayName = nameParts[0] + "/" + repo
notesURL = fmt.Sprintf(dockerHubRepoTagURLFormat, displayName, tag.Name)
}
return &AppRelease{
Source: ReleaseSourceDockerHub,
NotesUrl: notesURL,
Name: displayName,
Version: tag.Name,
TimeReleased: parseRFC3339Time(tag.LastPushed),
}, nil
}
+97
View File
@@ -0,0 +1,97 @@
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
}
+186 -77
View File
@@ -2,116 +2,225 @@ package feed
import ( import (
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"strings"
"sync"
"time" "time"
) )
type githubReleaseResponseJson struct { type githubReleaseLatestResponseJson struct {
TagName string `json:"tag_name"` TagName string `json:"tag_name"`
PublishedAt string `json:"published_at"` PublishedAt string `json:"published_at"`
HtmlUrl string `json:"html_url"` HtmlUrl string `json:"html_url"`
Draft bool `json:"draft"`
PreRelease bool `json:"prerelease"`
Reactions struct { Reactions struct {
Downvotes int `json:"-1"` Downvotes int `json:"-1"`
} `json:"reactions"` } `json:"reactions"`
} }
func parseGithubTime(t string) time.Time { func fetchLatestGithubRelease(request *ReleaseRequest) (*AppRelease, error) {
parsedTime, err := time.Parse("2006-01-02T15:04:05Z", t) httpRequest, err := http.NewRequest(
"GET",
if err != nil { fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository),
return time.Now() nil,
} )
return parsedTime
}
func FetchLatestReleasesFromGithub(repositories []string, token string) (AppReleases, error) {
appReleases := make(AppReleases, 0, len(repositories))
if len(repositories) == 0 {
return appReleases, nil
}
requests := make([]*http.Request, len(repositories))
for i, repository := range repositories {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=10", repository), nil)
if token != "" {
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
}
requests[i] = request
}
task := decodeJsonFromRequestTask[[]githubReleaseResponseJson](defaultClient)
job := newJob(task, requests).withWorkers(15)
responses, errs, err := workerPoolDo(job)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var failed int if request.Token != nil {
httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
for i := range responses {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch or parse github release", "error", errs[i], "url", requests[i].URL)
continue
} }
releases := responses[i] response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest)
if len(releases) < 1 { if err != nil {
failed++ return nil, err
slog.Error("No releases found", "repository", repositories[i], "url", requests[i].URL)
continue
} }
var liveRelease *githubReleaseResponseJson return &AppRelease{
Source: ReleaseSourceGithub,
for i := range releases { Name: request.Repository,
release := &releases[i] Version: normalizeVersionFormat(response.TagName),
NotesUrl: response.HtmlUrl,
if !release.Draft && !release.PreRelease { TimeReleased: parseRFC3339Time(response.PublishedAt),
liveRelease = release Downvotes: response.Reactions.Downvotes,
break }, nil
}
} }
if liveRelease == nil { type GithubTicket struct {
slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL) Number int
continue CreatedAt time.Time
Title string
} }
version := liveRelease.TagName type RepositoryDetails struct {
Name string
if version[0] != 'v' { Stars int
version = "v" + version Forks int
OpenPullRequests int
PullRequests []GithubTicket
OpenIssues int
Issues []GithubTicket
LastCommits int
Commits []CommitDetails
} }
appReleases = append(appReleases, AppRelease{ type githubRepositoryDetailsResponseJson struct {
Name: repositories[i], Name string `json:"full_name"`
Version: version, Stars int `json:"stargazers_count"`
NotesUrl: liveRelease.HtmlUrl, Forks int `json:"forks_count"`
TimeReleased: parseGithubTime(liveRelease.PublishedAt), }
Downvotes: liveRelease.Reactions.Downvotes,
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"`
}
type CommitDetails struct {
Sha string
Author string
CreatedAt time.Time
Message string
}
type gitHubCommitResponseJson struct {
Sha string `json:"sha"`
Commit struct {
Author struct {
Name string `json:"name"`
Date string `json:"date"`
} `json:"author"`
Message string `json:"message"`
} `json:"commit"`
}
func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int, maxCommits 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)
CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repository, maxCommits), nil)
if token != "" {
token = fmt.Sprintf("Bearer %s", token)
repositoryRequest.Header.Add("Authorization", token)
PRsRequest.Header.Add("Authorization", token)
issuesRequest.Header.Add("Authorization", token)
CommitsRequest.Header.Add("Authorization", token)
}
var detailsResponse githubRepositoryDetailsResponseJson
var detailsErr error
var PRsResponse githubTicketResponseJson
var PRsErr error
var issuesResponse githubTicketResponseJson
var issuesErr error
var commitsResponse []gitHubCommitResponseJson
var CommitsErr 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)
})()
}
if maxCommits > 0 {
wg.Add(1)
go (func() {
defer wg.Done()
commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultClient, CommitsRequest)
})()
}
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)),
Commits: make([]CommitDetails, 0, len(commitsResponse)),
}
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: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
Title: PRsResponse.Tickets[i].Title,
}) })
} }
}
if len(appReleases) == 0 {
return nil, ErrNoContent
} }
appReleases.SortByNewest() 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
if failed > 0 { for i := range issuesResponse.Tickets {
return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed) details.Issues = append(details.Issues, GithubTicket{
Number: issuesResponse.Tickets[i].Number,
CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
Title: issuesResponse.Tickets[i].Title,
})
}
}
} }
return appReleases, nil if maxCommits > 0 {
if CommitsErr != nil {
err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, CommitsErr)
} else {
for i := range commitsResponse {
details.Commits = append(details.Commits, CommitDetails{
Sha: commitsResponse[i].Sha,
Author: commitsResponse[i].Commit.Author.Name,
CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date),
Message: strings.SplitN(commitsResponse[i].Commit.Message, "\n\n", 2)[0],
})
}
}
}
return details, err
} }
+48
View File
@@ -0,0 +1,48 @@
package feed
import (
"fmt"
"net/http"
"net/url"
)
type gitlabReleaseResponseJson struct {
TagName string `json:"tag_name"`
ReleasedAt string `json:"released_at"`
Links struct {
Self string `json:"self"`
} `json:"_links"`
}
func fetchLatestGitLabRelease(request *ReleaseRequest) (*AppRelease, error) {
httpRequest, err := http.NewRequest(
"GET",
fmt.Sprintf(
"https://gitlab.com/api/v4/projects/%s/releases/permalink/latest",
url.QueryEscape(request.Repository),
),
nil,
)
if err != nil {
return nil, err
}
if request.Token != nil {
httpRequest.Header.Add("PRIVATE-TOKEN", *request.Token)
}
response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultClient, httpRequest)
if err != nil {
return nil, err
}
return &AppRelease{
Source: ReleaseSourceGitlab,
Name: request.Repository,
Version: normalizeVersionFormat(response.TagName),
NotesUrl: response.Links.Self,
TimeReleased: parseRFC3339Time(response.ReleasedAt),
}, nil
}
+4 -4
View File
@@ -18,8 +18,8 @@ type hackerNewsPostResponseJson struct {
TimePosted int64 `json:"time"` TimePosted int64 `json:"time"`
} }
func getHackerNewsTopPostIds() ([]int, error) { func getHackerNewsPostIds(sort string) ([]int, error) {
request, _ := http.NewRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", nil) request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
response, err := decodeJsonFromRequest[[]int](defaultClient, request) response, err := decodeJsonFromRequest[[]int](defaultClient, request)
if err != nil { if err != nil {
@@ -83,8 +83,8 @@ func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (Forum
return posts, nil return posts, nil
} }
func FetchHackerNewsTopPosts(limit int, commentsUrlTemplate string) (ForumPosts, error) { func FetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (ForumPosts, error) {
postIds, err := getHackerNewsTopPostIds() postIds, err := getHackerNewsPostIds(sort)
if err != nil { if err != nil {
return nil, err return nil, err
+91
View File
@@ -0,0 +1,91 @@
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
}
+33 -7
View File
@@ -7,6 +7,12 @@ import (
"time" "time"
) )
type SiteStatusRequest struct {
URL string `yaml:"url"`
CheckURL string `yaml:"check-url"`
AllowInsecure bool `yaml:"allow-insecure"`
}
type SiteStatus struct { type SiteStatus struct {
Code int Code int
TimedOut bool TimedOut bool
@@ -14,14 +20,34 @@ type SiteStatus struct {
Error error Error error
} }
func getSiteStatusTask(request *http.Request) (SiteStatus, error) { func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
var url string
if statusRequest.CheckURL != "" {
url = statusRequest.CheckURL
} else {
url = statusRequest.URL
}
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return SiteStatus{
Error: err,
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel() defer cancel()
request = request.WithContext(ctx) request = request.WithContext(ctx)
start := time.Now() requestSentAt := time.Now()
response, err := http.DefaultClient.Do(request) var response *http.Response
took := time.Since(start)
status := SiteStatus{ResponseTime: took} if !statusRequest.AllowInsecure {
response, err = defaultClient.Do(request)
} else {
response, err = defaultInsecureClient.Do(request)
}
status := SiteStatus{ResponseTime: time.Since(requestSentAt)}
if err != nil { if err != nil {
if errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.DeadlineExceeded) {
@@ -29,7 +55,7 @@ func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
} }
status.Error = err status.Error = err
return status, err return status, nil
} }
defer response.Body.Close() defer response.Body.Close()
@@ -39,7 +65,7 @@ func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
return status, nil return status, nil
} }
func FetchStatusesForRequests(requests []*http.Request) ([]SiteStatus, error) { func FetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) {
job := newJob(getSiteStatusTask, requests).withWorkers(20) job := newJob(getSiteStatusTask, requests).withWorkers(20)
results, _, err := workerPoolDo(job) results, _, err := workerPoolDo(job)
+136
View File
@@ -0,0 +1,136 @@
package feed
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"sort"
"strings"
)
type piholeStatsResponse struct {
TotalQueries int `json:"dns_queries_today"`
QueriesSeries map[int64]int `json:"domains_over_time"`
BlockedQueries int `json:"ads_blocked_today"`
BlockedSeries map[int64]int `json:"ads_over_time"`
BlockedPercentage float64 `json:"ads_percentage_today"`
TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"`
DomainsBlocked int `json:"domains_being_blocked"`
}
// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array
// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling
type piholeTopBlockedDomains map[string]int
func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
// NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow
// because of the UnmarshalJSON method getting called recursively
temp := make(map[string]int)
err := json.Unmarshal(data, &temp)
if err != nil {
*p = make(piholeTopBlockedDomains)
} else {
*p = temp
}
return nil
}
func FetchPiholeStats(instanceURL, token string) (*DNSStats, error) {
if token == "" {
return nil, errors.New("missing API token")
}
requestURL := strings.TrimRight(instanceURL, "/") +
"/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
responseJson, err := decodeJsonFromRequest[piholeStatsResponse](defaultClient, request)
if err != nil {
return nil, err
}
stats := &DNSStats{
TotalQueries: responseJson.TotalQueries,
BlockedQueries: responseJson.BlockedQueries,
BlockedPercent: int(responseJson.BlockedPercentage),
DomainsBlocked: responseJson.DomainsBlocked,
}
if len(responseJson.TopBlockedDomains) > 0 {
domains := make([]DNSStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains))
for domain, count := range responseJson.TopBlockedDomains {
domains = append(domains, DNSStatsBlockedDomain{
Domain: domain,
PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100),
})
}
sort.Slice(domains, func(a, b int) bool {
return domains[a].PercentBlocked > domains[b].PercentBlocked
})
stats.TopBlockedDomains = domains[:min(len(domains), 5)]
}
// Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144
if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 {
slog.Warn(
"DNS stats for pihole: did not get expected 144 data points",
"len(queries)", len(responseJson.QueriesSeries),
"len(blocked)", len(responseJson.BlockedSeries),
)
return stats, nil
}
var lowestTimestamp int64 = 0
for timestamp := range responseJson.QueriesSeries {
if lowestTimestamp == 0 || timestamp < lowestTimestamp {
lowestTimestamp = timestamp
}
}
maxQueriesInSeries := 0
for i := 0; i < 8; i++ {
queries := 0
blocked := 0
for j := 0; j < 18; j++ {
index := lowestTimestamp + int64(i*10800+j*600)
queries += responseJson.QueriesSeries[index]
blocked += responseJson.BlockedSeries[index]
}
if queries > maxQueriesInSeries {
maxQueriesInSeries = queries
}
stats.Series[i] = DNSStatsSeries{
Queries: queries,
Blocked: blocked,
}
if queries > 0 {
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
}
}
for i := 0; i < 8; i++ {
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
}
return stats, nil
}
+41 -9
View File
@@ -16,6 +16,8 @@ type ForumPost struct {
Score int Score int
Engagement float64 Engagement float64
TimePosted time.Time TimePosted time.Time
Tags []string
IsCrosspost bool
} }
type ForumPosts []ForumPost type ForumPosts []ForumPost
@@ -39,6 +41,8 @@ type Weather struct {
} }
type AppRelease struct { type AppRelease struct {
Source ReleaseSource
SourceIconURL string
Name string Name string
Version string Version string
NotesUrl string NotesUrl string
@@ -84,18 +88,46 @@ var currencyToSymbol = map[string]string{
"PHP": "₱", "PHP": "₱",
} }
type Stock struct { type DNSStats struct {
Name string TotalQueries int
Symbol string BlockedQueries int
Currency string BlockedPercent int
Price float64 ResponseTime int
PercentChange float64 DomainsBlocked int
SvgChartPoints string Series [8]DNSStatsSeries
TopBlockedDomains []DNSStatsBlockedDomain
} }
type Stocks []Stock type DNSStatsSeries struct {
Queries int
Blocked int
PercentTotal int
PercentBlocked int
}
func (t Stocks) SortByAbsChange() { type DNSStatsBlockedDomain struct {
Domain string
PercentBlocked int
}
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 { sort.Slice(t, func(i, j int) bool {
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange) return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
}) })
+55 -8
View File
@@ -25,17 +25,48 @@ type subredditResponseJson struct {
Pinned bool `json:"pinned"` Pinned bool `json:"pinned"`
IsSelf bool `json:"is_self"` IsSelf bool `json:"is_self"`
Thumbnail string `json:"thumbnail"` Thumbnail string `json:"thumbnail"`
Flair string `json:"link_flair_text"`
ParentList []struct {
Id string `json:"id"`
Subreddit string `json:"subreddit"`
Permalink string `json:"permalink"`
} `json:"crosspost_parent_list"`
} `json:"data"` } `json:"data"`
} `json:"children"` } `json:"children"`
} `json:"data"` } `json:"data"`
} }
func FetchSubredditPosts(subreddit string, commentsUrlTemplate string, requestUrlTemplate string) (ForumPosts, error) { func templateRedditCommentsURL(template, subreddit, postId, postPath string) string {
subreddit = url.QueryEscape(subreddit) template = strings.ReplaceAll(template, "{SUBREDDIT}", subreddit)
requestUrl := fmt.Sprintf("https://www.reddit.com/r/%s/hot.json", subreddit) template = strings.ReplaceAll(template, "{POST-ID}", postId)
template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/"))
return template
}
func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (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 != "" { if requestUrlTemplate != "" {
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl) requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
} }
request, err := http.NewRequest("GET", requestUrl, nil) request, err := http.NewRequest("GET", requestUrl, nil)
if err != nil { if err != nil {
@@ -68,9 +99,7 @@ func FetchSubredditPosts(subreddit string, commentsUrlTemplate string, requestUr
if commentsUrlTemplate == "" { if commentsUrlTemplate == "" {
commentsUrl = "https://www.reddit.com" + post.Permalink commentsUrl = "https://www.reddit.com" + post.Permalink
} else { } else {
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{SUBREDDIT}", subreddit) commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-ID}", post.Id)
commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-PATH}", strings.TrimLeft(post.Permalink, "/"))
} }
forumPost := ForumPost{ forumPost := ForumPost{
@@ -90,10 +119,28 @@ func FetchSubredditPosts(subreddit string, commentsUrlTemplate string, requestUr
forumPost.TargetUrl = post.Url forumPost.TargetUrl = post.Url
} }
if showFlairs && post.Flair != "" {
forumPost.Tags = append(forumPost.Tags, post.Flair)
}
if len(post.ParentList) > 0 {
forumPost.IsCrosspost = true
forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit
if commentsUrlTemplate == "" {
forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink
} else {
forumPost.TargetUrl = templateRedditCommentsURL(
commentsUrlTemplate,
post.ParentList[0].Subreddit,
post.ParentList[0].Id,
post.ParentList[0].Permalink,
)
}
}
posts = append(posts, forumPost) posts = append(posts, forumPost)
} }
posts.CalculateEngagement()
return posts, nil return posts, nil
} }

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