Compare commits

...
168 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
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
c0smic 060609e2cb Add Kanagawa Dark to themes 2024-05-18 14:25:08 +00:00
c0smic a1c48bf012 Use new Kanagawa Dark Image 2024-05-18 14:24:15 +00:00
c0smic a67fb66d2e Delete old Kanagawa Dark image 2024-05-18 14:23:32 +00:00
c0smic 3001f4845a Add new Kanagawa Dark image 2024-05-18 14:20:17 +00:00
c0smic 192c1ad669 Rename kanagawa dark preview image 2024-05-18 14:15:35 +00:00
c0smic 89ae8a3607 Add Kanagawa Dark Theme preview 2024-05-18 14:13:56 +00:00
Anthony Harivel 90d718c186 Add last Github commits
Signed-off-by: Anthony Harivel <aharivel@redhat.com>
2024-05-18 11:51:44 +02:00
David Leonard 3cfbe0c89b Allow some branding customization. 2024-05-17 14:37:05 -07:00
87 changed files with 2995 additions and 867 deletions
+1
View File
@@ -0,0 +1 @@
github: [glanceapp]
+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 -6
View File
@@ -1,11 +1,13 @@
FROM alpine:3.20 FROM golang:1.22.5-alpine3.20 AS builder
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
WORKDIR /app WORKDIR /app
COPY build/glance-$TARGETOS-$TARGETARCH${TARGETVARIANT} /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"]
-14
View File
@@ -1,14 +0,0 @@
FROM golang:1.22.3-alpine3.19 AS builder
WORKDIR /app
COPY . /app
RUN CGO_ENABLED=0 go build .
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /app/glance .
EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance"]
+2 -2
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)
@@ -101,7 +101,7 @@ Build the image:
**Make sure to replace "owner" with your name or organization.** **Make sure to replace "owner" with your name or organization.**
```bash ```bash
docker build -t owner/glance:latest -f Dockerfile.single-platform . docker build -t owner/glance:latest .
``` ```
Push the image to your registry: Push the image to your registry:
+269 -21
View File
@@ -3,6 +3,7 @@
- [Intro](#intro) - [Intro](#intro)
- [Preconfigured page](#preconfigured-page) - [Preconfigured page](#preconfigured-page)
- [Server](#server) - [Server](#server)
- [Branding](#branding)
- [Theme](#theme) - [Theme](#theme)
- [Themes](#themes) - [Themes](#themes)
- [Pages & Columns](#pages--columns) - [Pages & Columns](#pages--columns)
@@ -13,10 +14,12 @@
- [Lobsters](#lobsters) - [Lobsters](#lobsters)
- [Reddit](#reddit) - [Reddit](#reddit)
- [Search](#search-widget) - [Search](#search-widget)
- [Group](#group)
- [Extension](#extension) - [Extension](#extension)
- [Weather](#weather) - [Weather](#weather)
- [Monitor](#monitor) - [Monitor](#monitor)
- [Releases](#releases) - [Releases](#releases)
- [DNS Stats](#dns-stats)
- [Repository](#repository) - [Repository](#repository)
- [Bookmarks](#bookmarks) - [Bookmarks](#bookmarks)
- [Calendar](#calendar) - [Calendar](#calendar)
@@ -40,7 +43,6 @@ pages:
columns: columns:
- size: small - size: small
widgets: widgets:
- type: clock
- type: calendar - type: calendar
- type: rss - type: rss
@@ -124,6 +126,7 @@ server:
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| host | string | no | | | host | string | no | |
| port | number | no | 8080 | | port | number | no | 8080 |
| base-url | string | no | |
| assets-path | string | no | | | assets-path | string | no | |
#### `host` #### `host`
@@ -132,6 +135,13 @@ The address which the server will listen on. Setting it to `localhost` means tha
#### `port` #### `port`
A number between 1 and 65,535, so long as that port isn't already used by anything else. A number between 1 and 65,535, so long as that port isn't already used by anything else.
#### `base-url`
The base URL that Glance is hosted under. No need to specify this unless you're using a reverse proxy and are hosting Glance under a directory. If that's the case then you can set this value to `/glance` or whatever the directory is called. Note that the forward slash (`/`) in the beginning is required unless you specify the full domain and path.
> [!IMPORTANT]
> You need to strip the `base-url` prefix before forwarding the request to the Glance server.
> In Caddy you can do this using [`handle_path`](https://caddyserver.com/docs/caddyfile/directives/handle_path) or [`uri strip_prefix`](https://caddyserver.com/docs/caddyfile/directives/uri).
#### `assets-path` #### `assets-path`
The path to a directory that will be served by the server under the `/assets/` path. This is handy for widgets like the Monitor where you have to specify an icon URL and you want to self host all the icons rather than pointing to an external source. The path to a directory that will be served by the server under the `/assets/` path. This is handy for widgets like the Monitor where you have to specify an icon URL and you want to self host all the icons rather than pointing to an external source.
@@ -169,6 +179,42 @@ To be able to point to an asset from your assets path, use the `/assets/` path l
icon: /assets/gitea-icon.png icon: /assets/gitea-icon.png
``` ```
## Branding
You can adjust the various parts of the branding through a top level `branding` property. Example:
```yaml
branding:
custom-footer: |
<p>Powered by <a href="https://github.com/glanceapp/glance">Glance</a></p>
logo-url: /assets/logo.png
favicon-url: /assets/logo.png
```
### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| hide-footer | bool | no | false |
| custom-footer | string | no | |
| logo-text | string | no | G |
| logo-url | string | no | |
| favicon-url | string | no | |
#### `hide-footer`
Hides the footer when set to `true`.
#### `custom-footer`
Specify custom HTML to use for the footer.
#### `logo-text`
Specify custom text to use instead of the "G" found in the navigation.
#### `logo-url`
Specify a URL to a custom image to use instead of the "G" found in the navigation. If both `logo-text` and `logo-url` are set, only `logo-url` will be used.
#### `favicon-url`
Specify a URL to a custom image to use for the favicon.
## Theme ## Theme
Theming is done through a top level `theme` property. Values for the colors are in [HSL](https://giggster.com/guide/basics/hue-saturation-lightness/) (hue, saturation, lightness) format. You can use a color picker [like this one](https://hslpicker.com/) to convert colors from other formats to HSL. The values are separated by a space and `%` is not required for any of the numbers. Theming is done through a top level `theme` property. Values for the colors are in [HSL](https://giggster.com/guide/basics/hue-saturation-lightness/) (hue, saturation, lightness) format. You can use a color picker [like this one](https://hslpicker.com/) to convert colors from other formats to HSL. The values are separated by a space and `%` is not required for any of the numbers.
@@ -235,6 +281,8 @@ theme:
> .widget-type-rss a { > .widget-type-rss a {
> font-size: 1.5rem; > font-size: 1.5rem;
> } > }
>
> In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets.
## Pages & Columns ## Pages & Columns
@@ -262,6 +310,9 @@ pages:
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| title | string | yes | | | title | string | yes | |
| slug | string | no | | | slug | string | no | |
| width | string | no | |
| center-vertically | boolean | no | false |
| hide-desktop-navigation | boolean | no | false |
| show-mobile-header | boolean | no | false | | show-mobile-header | boolean | no | false |
| columns | array | yes | | | columns | array | yes | |
@@ -271,6 +322,23 @@ The name of the page which gets shown in the navigation bar.
#### `slug` #### `slug`
The URL friendly version of the title which is used to access the page. For example if the title of the page is "RSS Feeds" you can make the page accessible via `localhost:8080/feeds` by setting the slug to `feeds`. If not defined, it will automatically be generated from the title. The URL friendly version of the title which is used to access the page. For example if the title of the page is "RSS Feeds" you can make the page accessible via `localhost:8080/feeds` by setting the slug to `feeds`. If not defined, it will automatically be generated from the title.
#### `width`
The maximum width of the page on desktop. Possible values are `slim` and `wide`.
* default: `1600px`
* slim: `1100px`
* wide: `1920px`
> [!NOTE]
>
> When using `slim`, the maximum number of columns allowed for that page is `2`.
#### `center-vertically`
When set to `true`, vertically centers the content on the page. Has no effect if the content is taller than the height of the viewport.
#### `hide-desktop-navigation`
Whether to show the navigation links at the top of the page on desktop.
#### `show-mobile-header` #### `show-mobile-header`
Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices. Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices.
@@ -355,7 +423,9 @@ pages:
| ---- | ---- | -------- | | ---- | ---- | -------- |
| type | string | yes | | type | string | yes |
| title | string | no | | title | string | no |
| title-url | string | no |
| cache | string | no | | cache | string | no |
| css-class | string | no |
#### `type` #### `type`
Used to specify the widget. Used to specify the widget.
@@ -363,6 +433,9 @@ Used to specify the widget.
#### `title` #### `title`
The title of the widget. If left blank it will be defined by the widget. The title of the widget. If left blank it will be defined by the widget.
#### `title-url`
The URL to go to when clicking on the widget's title. If left blank it will be defined by the widget (if available).
#### `cache` #### `cache`
How long to keep the fetched data in memory. The value is a string and must be a number followed by one of s, m, h, d. Examples: How long to keep the fetched data in memory. The value is a string and must be a number followed by one of s, m, h, d. Examples:
@@ -377,6 +450,9 @@ cache: 1d # 1 day
> >
> Not all widgets can have their cache duration modified. The calendar and weather widgets update on the hour and this cannot be changed. > Not all widgets can have their cache duration modified. The calendar and weather widgets update on the hour and this cannot be changed.
#### `css-class`
Set custom CSS classes for the specific widget instance.
### RSS ### RSS
Display a list of articles from multiple RSS feeds. Display a list of articles from multiple RSS feeds.
@@ -403,10 +479,18 @@ Example:
| thumbnail-height | float | no | 10 | | thumbnail-height | float | no | 10 |
| card-height | float | no | 27 | | card-height | float | no | 27 |
| limit | integer | no | 25 | | limit | integer | no | 25 |
| single-line-titles | boolean | no | false |
| collapse-after | integer | no | 5 | | collapse-after | integer | no | 5 |
##### `style` ##### `style`
Used to change the appearance of the widget. Possible values are `vertical-list` and `horizontal-cards` where the former is intended to be used within a small column and the latter a full column. Below are previews of each style. Used to change the appearance of the widget. Possible values are:
* `vertical-list` - suitable for `full` and `small` columns
* `detailed-list` - suitable for `full` columns
* `horizontal-cards` - suitable for `full` columns
* `horizontal-cards-2` - suitable for `full` columns
Below is a preview of each style:
`vertical-list` `vertical-list`
@@ -448,6 +532,9 @@ If an RSS feed isn't returning item links with a base domain and Glance has fail
##### `limit` ##### `limit`
The maximum number of articles to show. The maximum number of articles to show.
##### `single-line-titles`
When set to `true`, truncates the title of each post if it exceeds one line. Only applies when the style is set to `vertical-list`.
##### `collapse-after` ##### `collapse-after`
How many articles are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse. How many articles are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
@@ -474,6 +561,7 @@ Preview:
| limit | integer | no | 25 | | limit | integer | no | 25 |
| style | string | no | horizontal-cards | | style | string | no | horizontal-cards |
| collapse-after-rows | integer | no | 4 | | collapse-after-rows | integer | no | 4 |
| include-shorts | boolean | no | false |
| video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} | | video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} |
##### `channels` ##### `channels`
@@ -573,11 +661,23 @@ Preview:
#### Properties #### Properties
| Name | Type | Required | Default | | Name | Type | Required | Default |
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| instance-url | string | no | https://lobste.rs/ |
| custom-url | string | no | |
| limit | integer | no | 15 | | limit | integer | no | 15 |
| collapse-after | integer | no | 5 | | collapse-after | integer | no | 5 |
| sort-by | string | no | hot | | sort-by | string | no | hot |
| tags | array | no | | | tags | array | no | |
##### `instance-url`
The base URL for a lobsters instance hosted somewhere other than on lobste.rs. Example:
```yaml
instance-url: https://www.journalduhacker.net/
```
##### `custom-url`
A custom URL to retrieve lobsters posts from. If this is specified, the `instance-url`, `sort-by` and `tags` properties are ignored.
##### `limit` ##### `limit`
The maximum number of posts to show. The maximum number of posts to show.
@@ -610,6 +710,7 @@ Example:
| subreddit | string | yes | | | subreddit | string | yes | |
| style | string | no | vertical-list | | style | string | no | vertical-list |
| show-thumbnails | boolean | no | false | | show-thumbnails | boolean | no | false |
| show-flairs | boolean | no | false |
| limit | integer | no | 15 | | limit | integer | no | 15 |
| collapse-after | integer | no | 5 | | collapse-after | integer | no | 5 |
| comments-url-template | string | no | https://www.reddit.com/{POST-PATH} | | comments-url-template | string | no | https://www.reddit.com/{POST-PATH} |
@@ -646,6 +747,9 @@ Shows or hides thumbnails next to the post. This only works if the `style` is `v
> >
> Thumbnails don't work for some subreddits due to Reddit's API not returning the thumbnail URL. No workaround for this yet. > Thumbnails don't work for some subreddits due to Reddit's API not returning the thumbnail URL. No workaround for this yet.
##### `show-flairs`
Shows post flairs when set to `true`.
##### `limit` ##### `limit`
The maximum number of posts to show. The maximum number of posts to show.
@@ -725,10 +829,16 @@ Preview:
| <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Perform search in a new tab | Search input is focused and not empty | | <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Perform search in a new tab | Search input is focused and not empty |
| <kbd>Escape</kbd> | Leave focus | Search input is focused | | <kbd>Escape</kbd> | Leave focus | Search input is focused |
> [!TIP]
>
> You can use the property `new-tab` with a value of `true` if you want to show search results in a new tab by default. <kbd>Ctrl</kbd> + <kbd>Enter</kbd> will then show results in the same tab.
#### Properties #### Properties
| Name | Type | Required | Default | | Name | Type | Required | Default |
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| search-engine | string | no | duckduckgo | | search-engine | string | no | duckduckgo |
| new-tab | boolean | no | false |
| autofocus | boolean | no | false |
| bangs | array | no | | | bangs | array | no | |
##### `search-engine` ##### `search-engine`
@@ -739,6 +849,12 @@ Either a value from the table below or a URL to a custom search engine. Use `{QU
| duckduckgo | `https://duckduckgo.com/?q={QUERY}` | | duckduckgo | `https://duckduckgo.com/?q={QUERY}` |
| google | `https://www.google.com/search?q={QUERY}` | | google | `https://www.google.com/search?q={QUERY}` |
##### `new-tab`
When set to `true`, swaps the shortcuts for showing results in the same or new tab, defaulting to showing results in a new tab.
##### `new-tab`
When set to `true`, automatically focuses the search input on page load.
##### `bangs` ##### `bangs`
What now? [Bangs](https://duckduckgo.com/bangs). They're shortcuts that allow you to use the same search box for many different sites. Assuming you have it configured, if for example you start your search input with `!yt` you'd be able to perform a search on YouTube: What now? [Bangs](https://duckduckgo.com/bangs). They're shortcuts that allow you to use the same search box for many different sites. Assuming you have it configured, if for example you start your search input with `!yt` you'd be able to perform a search on YouTube:
@@ -773,6 +889,50 @@ url: https://store.steampowered.com/search/?term={QUERY}
url: https://www.amazon.com/s?k={QUERY} url: https://www.amazon.com/s?k={QUERY}
``` ```
### Group
Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget within a group widget.
Example:
```yaml
- type: group
widgets:
- type: reddit
subreddit: gamingnews
show-thumbnails: true
collapse-after: 6
- type: reddit
subreddit: games
- type: reddit
subreddit: pcgaming
show-thumbnails: true
```
Preview:
![](images/group-widget-preview.png)
#### Sharing properties
To avoid repetition you can use [YAML anchors](https://support.atlassian.com/bitbucket-cloud/docs/yaml-anchors/) and share properties between widgets.
Example:
```yaml
- type: group
define: &shared-properties
type: reddit
show-thumbnails: true
collapse-after: 6
widgets:
- subreddit: gamingnews
<<: *shared-properties
- subreddit: games
<<: *shared-properties
- subreddit: pcgaming
<<: *shared-properties
```
### Extension ### Extension
Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP). Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP).
@@ -902,13 +1062,13 @@ You can hover over the "ERROR" text to view more information.
#### Properties #### Properties
| Name | Type | Required | | Name | Type | Required | Default |
| ---- | ---- | -------- | | ---- | ---- | -------- | ------- |
| sites | array | yes | | sites | array | yes | |
| style | string | no | | show-failing-only | boolean | no | false |
##### `style` ##### `show-failing-only`
To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option. Shows only a list of failing sites when set to `true`.
##### `sites` ##### `sites`
@@ -918,6 +1078,7 @@ Properties for each site:
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| title | string | yes | | | title | string | yes | |
| url | string | yes | | | url | string | yes | |
| check-url | string | no | |
| icon | string | no | | | icon | string | no | |
| allow-insecure | boolean | no | false | | allow-insecure | boolean | no | false |
| same-tab | boolean | no | false | | same-tab | boolean | no | false |
@@ -928,7 +1089,11 @@ The title used to indicate the site.
`url` `url`
The URL which will be requested and its response will determine the status of the site. Optionally, you can specify this using an environment variable with the syntax `${VARIABLE_NAME}`. The public facing URL of a monitored service, the user will be redirected here. If `check-url` is not specified, this is used as the status check.
`check-url`
The URL which will be requested and its response will determine the status of the site. If not specified, the `url` property is used.
`icon` `icon`
@@ -953,17 +1118,20 @@ Whether to ignore invalid/self-signed certificates.
Whether to open the link in the same or a new tab. Whether to open the link in the same or a new tab.
### Releases ### Releases
Display a list of releases for specific repositories on Github. Draft releases and prereleases will not be shown. Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub.
Example: Example:
```yaml ```yaml
- type: releases - type: releases
show-source-icon: true
repositories: repositories:
- immich-app/immich
- go-gitea/gitea - go-gitea/gitea
- dani-garcia/vaultwarden
- jellyfin/jellyfin - jellyfin/jellyfin
- glanceapp/glance
- codeberg:redict/redict
- gitlab:fdroid/fdroidclient
- dockerhub:gotify/server
``` ```
Preview: Preview:
@@ -975,12 +1143,42 @@ Preview:
| Name | Type | Required | Default | | Name | Type | Required | Default |
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| repositories | array | yes | | | repositories | array | yes | |
| show-source-icon | boolean | no | false | |
| token | string | no | | | token | string | no | |
| gitlab-token | string | no | |
| limit | integer | no | 10 | | limit | integer | no | 10 |
| collapse-after | integer | no | 5 | | collapse-after | integer | no | 5 |
##### `repositories` ##### `repositories`
A list of repositores for which to fetch the latest release for. Only the name/repo is required, not the full URL. A list of repositores to fetch the latest release for. Only the name/repo is required, not the full URL. A prefix can be specified for repositories hosted elsewhere such as GitLab, Codeberg and Docker Hub. Example:
```yaml
repositories:
- gitlab:inkscape/inkscape
- dockerhub:glanceapp/glance
- codeberg:redict/redict
```
Official images on Docker Hub can be specified by ommiting the owner:
```yaml
repositories:
- dockerhub:nginx
- dockerhub:node
- dockerhub:alpine
```
You can also specify specific tags for Docker Hub images:
```yaml
repositories:
- dockerhub:nginx:latest
- dockerhub:nginx:stable-alpine
```
##### `show-source-icon`
Shows an icon of the source (GitHub/GitLab/Codeberg/Docker Hub) next to the repository name when set to `true`.
##### `token` ##### `token`
Without authentication Github allows for up to 60 requests per hour. You can easily exceed this limit and start seeing errors if you're tracking lots of repositories or your cache time is low. To circumvent this you can [create a read only token from your Github account](https://github.com/settings/personal-access-tokens/new) and provide it here. Without authentication Github allows for up to 60 requests per hour. You can easily exceed this limit and start seeing errors if you're tracking lots of repositories or your cache time is low. To circumvent this you can [create a read only token from your Github account](https://github.com/settings/personal-access-tokens/new) and provide it here.
@@ -1005,12 +1203,65 @@ and then use it in your `glance.yml` like this:
This way you can safely check your `glance.yml` in version control without exposing the token. This way you can safely check your `glance.yml` in version control without exposing the token.
##### `gitlab-token`
Same as the above but used when fetching GitLab releases.
##### `limit` ##### `limit`
The maximum number of releases to show. The maximum number of releases to show.
#### `collapse-after` #### `collapse-after`
How many releases are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse. How many releases are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
### DNS Stats
Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home or Pi-hole.
Example:
```yaml
- type: dns-stats
service: adguard
url: https://adguard.domain.com/
username: admin
password: ${ADGUARD_PASSWORD}
```
Preview:
![](images/dns-stats-widget-preview.png)
> [!NOTE]
>
> When using AdGuard Home the 3rd statistic on top will be the average latency and when using Pi-hole it will be the total number of blocked domains from all adlists.
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| service | string | no | pihole |
| url | string | yes | |
| username | string | when service is `adguard` | |
| password | string | when service is `adguard` | |
| token | string | when service is `pihole` | |
| hour-format | string | no | 12h |
##### `service`
Either `adguard` or `pihole`.
##### `url`
The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
##### `username`
Only required when using AdGuard Home. The username used to log into the admin dashboard. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
##### `password`
Only required when using AdGuard Home. The password used to log into the admin dashboard. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
##### `token`
Only required when using Pi-hole. The API token which can be found in `Settings -> API -> Show API token`. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
##### `hour-format`
Whether to display the relative time in the graph in `12h` or `24h` format.
### Repository ### Repository
Display general information about a repository as well as a list of the latest open pull requests and issues. Display general information about a repository as well as a list of the latest open pull requests and issues.
@@ -1021,6 +1272,7 @@ Example:
repository: glanceapp/glance repository: glanceapp/glance
pull-requests-limit: 5 pull-requests-limit: 5
issues-limit: 3 issues-limit: 3
commits-limit: 3
``` ```
Preview: Preview:
@@ -1035,6 +1287,7 @@ Preview:
| token | string | no | | | token | string | no | |
| pull-requests-limit | integer | no | 3 | | pull-requests-limit | integer | no | 3 |
| issues-limit | integer | no | 3 | | issues-limit | integer | no | 3 |
| commits-limit | integer | no | -1 |
##### `repository` ##### `repository`
The owner and repository name that will have their information displayed. The owner and repository name that will have their information displayed.
@@ -1048,6 +1301,9 @@ The maximum number of latest open pull requests to show. Set to `-1` to not show
##### `issues-limit` ##### `issues-limit`
The maximum number of latest open issues to show. Set to `-1` to not show any. The maximum number of latest open issues to show. Set to `-1` to not show any.
##### `commits-limit`
The maximum number of lastest commits to show from the default branch. Set to `-1` to not show any.
### Bookmarks ### Bookmarks
Display a list of links which can be grouped. Display a list of links which can be grouped.
@@ -1097,14 +1353,10 @@ Preview:
| Name | Type | Required | | Name | Type | Required |
| ---- | ---- | -------- | | ---- | ---- | -------- |
| groups | array | yes | | groups | array | yes |
| style | string | no |
##### `groups` ##### `groups`
An array of groups which can optionally have a title and a custom color. An array of groups which can optionally have a title and a custom color.
##### `style`
To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option.
###### Properties for each group ###### Properties for each group
| Name | Type | Required | Default | | Name | Type | Required | Default |
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
@@ -1282,7 +1534,6 @@ Preview:
| ---- | ---- | -------- | | ---- | ---- | -------- |
| markets | array | yes | | markets | array | yes |
| sort-by | string | no | | sort-by | string | no |
| style | string | no |
##### `markets` ##### `markets`
An array of markets for which to display information about. An array of markets for which to display information about.
@@ -1290,9 +1541,6 @@ An array of markets for which to display information about.
##### `sort-by` ##### `sort-by`
By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change. By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change.
##### `style`
To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option.
###### Properties for each stock ###### Properties for each stock
| Name | Type | Required | | Name | Type | Required |
| ---- | ---- | -------- | | ---- | ---- | -------- |
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 946 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 482 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: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 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>
+10
View File
@@ -53,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
+4 -4
View File
@@ -1,19 +1,19 @@
module github.com/glanceapp/glance module github.com/glanceapp/glance
go 1.22.3 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)
@@ -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

@@ -1,30 +1,10 @@
function throttledDebounce(callback, maxDebounceTimes, debounceDelay) { import { setupPopovers } from './popover.js';
let debounceTimeout; import { throttledDebounce, isElementVisible } from './utils.js';
let timesDebounced = 0;
return function () { async function fetchPageContent(pageData) {
if (timesDebounced == maxDebounceTimes) {
clearTimeout(debounceTimeout);
timesDebounced = 0;
callback();
return;
}
clearTimeout(debounceTimeout);
timesDebounced++;
debounceTimeout = setTimeout(() => {
timesDebounced = 0;
callback();
}, debounceDelay);
};
};
async function fetchPageContent(pageSlug) {
// TODO: handle non 200 status codes/time outs // TODO: handle non 200 status codes/time outs
// TODO: add retries // TODO: add retries
const response = await fetch(`/api/pages/${pageSlug}/content/`); const response = await fetch(`${pageData.baseURL}/api/pages/${pageData.slug}/content/`);
const content = await response.text(); const content = await response.text();
return content; return content;
@@ -107,7 +87,7 @@ function updateRelativeTimeForElements(elements)
} }
} }
function setupSearchboxes() { function setupSearchBoxes() {
const searchWidgets = document.getElementsByClassName("search"); const searchWidgets = document.getElementsByClassName("search");
if (searchWidgets.length == 0) { if (searchWidgets.length == 0) {
@@ -117,6 +97,7 @@ function setupSearchboxes() {
for (let i = 0; i < searchWidgets.length; i++) { for (let i = 0; i < searchWidgets.length; i++) {
const widget = searchWidgets[i]; const widget = searchWidgets[i];
const defaultSearchUrl = widget.dataset.defaultSearchUrl; const defaultSearchUrl = widget.dataset.defaultSearchUrl;
const newTab = widget.dataset.newTab === "true";
const inputElement = widget.getElementsByClassName("search-input")[0]; const inputElement = widget.getElementsByClassName("search-input")[0];
const bangElement = widget.getElementsByClassName("search-bang")[0]; const bangElement = widget.getElementsByClassName("search-bang")[0];
const bangs = widget.querySelectorAll(".search-bangs > input"); const bangs = widget.querySelectorAll(".search-bangs > input");
@@ -147,14 +128,13 @@ function setupSearchboxes() {
query = input; query = input;
searchUrlTemplate = defaultSearchUrl; searchUrlTemplate = defaultSearchUrl;
} }
if (query.length == 0 && currentBang == null) {
if (query.length == 0) {
return; return;
} }
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query)); const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
if (event.ctrlKey) { if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
window.open(url, '_blank').focus(); window.open(url, '_blank').focus();
} else { } else {
window.location.href = url; window.location.href = url;
@@ -170,9 +150,13 @@ function setupSearchboxes() {
} }
const handleInput = (event) => { const handleInput = (event) => {
const value = event.target.value.trimStart(); const value = event.target.value.trim();
const words = value.split(" "); if (value in bangsMap) {
changeCurrentBang(bangsMap[value]);
return;
}
const words = value.split(" ");
if (words.length >= 2 && words[0] in bangsMap) { if (words.length >= 2 && words[0] in bangsMap) {
changeCurrentBang(bangsMap[words[0]]); changeCurrentBang(bangsMap[words[0]]);
return; return;
@@ -246,6 +230,46 @@ function setupDynamicRelativeTime() {
}); });
} }
function setupGroups() {
const groups = document.getElementsByClassName("widget-type-group");
if (groups.length == 0) {
return;
}
for (let g = 0; g < groups.length; g++) {
const group = groups[g];
const titles = group.getElementsByClassName("widget-header")[0].children;
const tabs = group.getElementsByClassName("widget-group-contents")[0].children;
let current = 0;
for (let t = 0; t < titles.length; t++) {
const title = titles[t];
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() { function setupLazyImages() {
const images = document.querySelectorAll("img[loading=lazy]"); const images = document.querySelectorAll("img[loading=lazy]");
@@ -381,7 +405,7 @@ function setupCollapsibleGrids() {
const button = attachExpandToggleButton(gridElement); const button = attachExpandToggleButton(gridElement);
let cardsPerRow = 2; let cardsPerRow;
const resolveCollapsibleItems = () => { const resolveCollapsibleItems = () => {
const hideItemsAfterIndex = cardsPerRow * collapseAfterRows; const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
@@ -411,12 +435,11 @@ function setupCollapsibleGrids() {
} }
}; };
afterContentReady(() => { const observer = new ResizeObserver(() => {
cardsPerRow = getCardsPerRow(); if (!isElementVisible(gridElement)) {
resolveCollapsibleItems(); return;
}); }
window.addEventListener("resize", () => {
const newCardsPerRow = getCardsPerRow(); const newCardsPerRow = getCardsPerRow();
if (cardsPerRow == newCardsPerRow) { if (cardsPerRow == newCardsPerRow) {
@@ -426,6 +449,8 @@ function setupCollapsibleGrids() {
cardsPerRow = newCardsPerRow; cardsPerRow = newCardsPerRow;
resolveCollapsibleItems(); resolveCollapsibleItems();
}); });
afterContentReady(() => observer.observe(gridElement));
} }
} }
@@ -544,16 +569,18 @@ function setupClocks() {
async function setupPage() { async function setupPage() {
const pageElement = document.getElementById("page"); const pageElement = document.getElementById("page");
const pageContentElement = document.getElementById("page-content"); const pageContentElement = document.getElementById("page-content");
const pageContent = await fetchPageContent(pageData.slug); const pageContent = await fetchPageContent(pageData);
pageContentElement.innerHTML = pageContent; pageContentElement.innerHTML = pageContent;
try { try {
setupPopovers();
setupClocks() setupClocks()
setupCarousels(); setupCarousels();
setupSearchboxes(); setupSearchBoxes();
setupCollapsibleLists(); setupCollapsibleLists();
setupCollapsibleGrids(); setupCollapsibleGrids();
setupGroups();
setupDynamicRelativeTime(); setupDynamicRelativeTime();
setupLazyImages(); setupLazyImages();
} finally { } finally {
@@ -569,8 +596,4 @@ async function setupPage() {
} }
} }
if (document.readyState === "loading") { setupPage();
document.addEventListener("DOMContentLoaded", setupPage);
} else {
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);
}
+453 -53
View File
@@ -34,6 +34,11 @@
--color-separator: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 4% * var(--cm)))); --color-separator: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 4% * var(--cm))));
--color-widget-content-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%))); --color-widget-content-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
--color-widget-background-highlight: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%))); --color-widget-background-highlight: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
--color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 3%));
--color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 12%)));
--color-progress-border: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 10% * var(--cm))));
--color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 27% * var(--cm))));
--color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm))));
--ths: var(--bgh), calc(var(--bgs) * var(--tsm)); --ths: var(--bgh), calc(var(--bgs) * var(--tsm));
--color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%)); --color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
@@ -54,8 +59,9 @@
--scheme: 100% -; --scheme: 100% -;
} }
.size-title-dynamic { .page {
font-size: var(--font-size-h4); height: 100%;
padding-block: var(--widget-gap);
} }
.page-content, .page.content-ready .page-loading-container { .page-content, .page.content-ready .page-loading-container {
@@ -66,6 +72,10 @@
display: block; display: block;
} }
.page-column-small .size-title-dynamic {
font-size: var(--font-size-h4);
}
.page-column-full .size-title-dynamic { .page-column-full .size-title-dynamic {
font-size: var(--font-size-h3); font-size: var(--font-size-h3);
} }
@@ -74,12 +84,18 @@
color: var(--color-primary); color: var(--color-primary);
} }
.text-truncate { .text-truncate,
.single-line-titles .title
{
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.single-line-titles .title {
display: block;
}
.text-truncate-2-lines, .text-truncate-3-lines { .text-truncate-2-lines, .text-truncate-3-lines {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -87,8 +103,8 @@
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
.text-truncate-3-lines { -webkit-line-clamp: 3; } .text-truncate-3-lines { line-clamp: 3; -webkit-line-clamp: 3; }
.text-truncate-2-lines { -webkit-line-clamp: 2; } .text-truncate-2-lines { line-clamp: 2; -webkit-line-clamp: 2; }
.visited-indicator:not(.text-truncate)::after, .visited-indicator:not(.text-truncate)::after,
.visited-indicator.text-truncate::before, .visited-indicator.text-truncate::before,
@@ -110,20 +126,20 @@
color: var(--color-primary); color: var(--color-primary);
} }
.list { --list-half-gap: 0rem; } .page-columns-transitioned .list-with-transition > * { animation: collapsibleItemReveal .25s backwards; }
.list-gap-2 { --list-half-gap: 0.1rem; } .list-with-transition > *:nth-child(2) { animation-delay: 30ms; }
.list-gap-4 { --list-half-gap: 0.2rem; } .list-with-transition > *:nth-child(3) { animation-delay: 60ms; }
.list-gap-10 { --list-half-gap: 0.5rem; } .list-with-transition > *:nth-child(4) { animation-delay: 90ms; }
.list-gap-14 { --list-half-gap: 0.7rem; } .list-with-transition > *:nth-child(5) { animation-delay: 120ms; }
.list-gap-20 { --list-half-gap: 1rem; } .list-with-transition > *:nth-child(6) { animation-delay: 150ms; }
.list-gap-24 { --list-half-gap: 1.2rem; } .list-with-transition > *:nth-child(7) { animation-delay: 180ms; }
.list-gap-34 { --list-half-gap: 1.7rem; } .list-with-transition > *:nth-child(8) { animation-delay: 210ms; }
.list > *:not(:first-child) { .list > *:not(:first-child) {
margin-top: calc(var(--list-half-gap) * 2); margin-top: calc(var(--list-half-gap) * 2);
} }
.list-with-separator > *:not(:first-child) { .list.list-with-separator > *:not(:first-child) {
margin-top: var(--list-half-gap); margin-top: var(--list-half-gap);
border-top: 1px solid var(--color-separator); border-top: 1px solid var(--color-separator);
padding-top: var(--list-half-gap); padding-top: var(--list-half-gap);
@@ -184,6 +200,56 @@
transform: rotate(-90deg); transform: rotate(-90deg);
} }
.widget-group-header {
overflow-x: auto;
scrollbar-width: thin;
}
.widget-group-title {
background: none;
font: inherit;
border: none;
text-transform: uppercase;
border-bottom: 1px dotted transparent;
cursor: pointer;
flex-shrink: 0;
transition: color .3s, border-color .3s;
color: var(--color-text-subdue);
line-height: calc(1.6em - 1px);
}
.widget-group-title:hover:not(.widget-group-title-current) {
color: var(--color-text-base);
}
.widget-group-title-current {
border-bottom-color: var(--color-text-base-muted);
color: var(--color-text-base);
}
.widget-group-content {
animation: widgetGroupContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
.widget-group-content[data-direction="right"] {
--direction: 5px;
}
.widget-group-content[data-direction="left"] {
--direction: -5px;
}
@keyframes widgetGroupContentEntrance {
from {
opacity: 0;
transform: translateX(var(--direction));
}
}
.widget-group-content:not(.widget-group-content-current) {
display: none;
}
.widget-content:has(.expand-toggle-button:last-child) { .widget-content:has(.expand-toggle-button:last-child) {
padding-bottom: 0; padding-bottom: 0;
} }
@@ -260,9 +326,14 @@ html {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
html, body {
height: 100%;
}
a { a {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
overflow-wrap: break-word;
} }
ul { ul {
@@ -292,7 +363,6 @@ body {
.page-columns { .page-columns {
display: flex; display: flex;
gap: var(--widget-gap); gap: var(--widget-gap);
margin: var(--widget-gap) 0;
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards; animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
} }
@@ -304,13 +374,19 @@ body {
} }
.page-loading-container { .page-loading-container {
margin: 50px auto; height: 100%;
width: fit-content; display: flex;
align-items: center;
justify-content: center;
animation: loadingContainerEntrance 200ms backwards; animation: loadingContainerEntrance 200ms backwards;
animation-delay: 150ms; animation-delay: 150ms;
font-size: 2rem; font-size: 2rem;
} }
.page-loading-container > .loading-icon {
translate: 0 -250%;
}
@keyframes loadingContainerEntrance { @keyframes loadingContainerEntrance {
from { from {
opacity: 0; opacity: 0;
@@ -333,12 +409,6 @@ body {
} }
} }
@keyframes loadingIconSpin {
to {
transform: rotate(360deg);
}
}
.notice-icon { .notice-icon {
width: 0.7rem; width: 0.7rem;
height: 0.7rem; height: 0.7rem;
@@ -370,23 +440,155 @@ kbd:active {
box-shadow: 0 0 0 0 var(--color-widget-background-highlight); box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
} }
.popover-container, [data-popover-html] {
display: none;
}
.popover-container {
--triangle-size: 10px;
--triangle-offset: 50%;
--triangle-margin: calc(var(--triangle-size) + 3px);
--entrance-y-offset: 8px;
--entrance-direction: calc(var(--entrance-y-offset) * -1);
z-index: 20;
position: absolute;
padding-top: var(--triangle-margin);
padding-inline: var(--content-bounds-padding);
}
.popover-container.position-above {
--entrance-direction: var(--entrance-y-offset);
padding-top: 0;
padding-bottom: var(--triangle-margin);
}
.popover-frame {
--shadow-properties: 0 15px 20px -10px;
--shadow-color: hsla(var(--bghs), calc(var(--bgl) * 0.2), 0.5);
position: relative;
padding: 10px;
background: var(--color-popover-background);
border: 1px solid var(--color-popover-border);
border-radius: 5px;
animation: popoverFrameEntrance 0.3s backwards cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: var(--shadow-properties) var(--shadow-color);
}
.popover-frame::before {
content: '';
position: absolute;
width: var(--triangle-size);
height: var(--triangle-size);
transform: rotate(45deg);
background-color: var(--color-popover-background);
border-top-left-radius: 2px;
border-left: 1px solid var(--color-popover-border);
border-top: 1px solid var(--color-popover-border);
left: calc(var(--triangle-offset) - (var(--triangle-size) / 2));
top: calc(var(--triangle-size) / 2 * -1 - 1px);
}
.popover-container.position-above .popover-frame::before {
transform: rotate(-135deg);
top: auto;
bottom: calc(var(--triangle-size) / 2 * -1 - 1px);
}
.popover-container.position-above .popover-frame {
--shadow-properties: 0 10px 20px -10px;
}
@keyframes popoverFrameEntrance {
from {
opacity: 0;
transform: translateY(var(--entrance-direction));
}
}
.summary {
width: 100%;
cursor: pointer;
word-spacing: -0.18em;
user-select: none;
list-style: none;
position: relative;
display: flex;
}
.details[open] .summary {
margin-bottom: .8rem;
}
.summary::before {
content: "";
position: absolute;
inset: -.3rem -.8rem;
border-radius: var(--border-radius);
background-color: var(--color-widget-background-highlight);
opacity: 0;
transition: opacity 0.2s;
z-index: -1;
}
.details[open] .summary::before, .summary:hover::before {
opacity: 1;
}
.summary::after {
content: "◀";
font-size: 1.2em;
position: absolute;
top: 0;
bottom: 0;
line-height: 1.3em;
right: 0;
transition: rotate .5s cubic-bezier(0.22, 1, 0.36, 1);
}
details[open] .summary::after {
rotate: -90deg;
}
.content-bounds { .content-bounds {
max-width: 1600px; max-width: 1600px;
width: 100%;
margin-inline: auto; margin-inline: auto;
padding: 0 var(--content-bounds-padding); padding: 0 var(--content-bounds-padding);
} }
.page-width-wide .content-bounds {
max-width: 1920px;
}
.page-width-slim .content-bounds {
max-width: 1100px;
}
.page-center-vertically .page {
display: flex;
justify-content: center;
flex-direction: column;
}
/* TODO: refactor, otherwise I hope I never have to change dynamic columns again */
.dynamic-columns { .dynamic-columns {
gap: calc(var(--widget-content-vertical-padding) / 2); --list-half-gap: 0.5rem;
gap: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
display: grid; display: grid;
grid-template-columns: repeat(var(--columns-per-row), 1fr); grid-template-columns: repeat(var(--columns-per-row), 1fr);
margin: calc(0px - var(--widget-content-vertical-padding) / 2) calc(0px - var(--widget-content-horizontal-padding) / 2);
} }
.dynamic-columns > * { .dynamic-columns > * {
padding: calc(var(--widget-content-vertical-padding) / 2) calc(var(--widget-content-horizontal-padding) / 1.5); padding-left: var(--widget-content-horizontal-padding);
background-color: var(--color-background); border-left: 1px solid var(--color-separator);
border-radius: var(--border-radius); min-width: 0;
}
.dynamic-columns > *:first-child {
padding-top: 0;
border-top: none;
border-left: none;
} }
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; } .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
@@ -395,23 +597,49 @@ kbd:active {
.dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; } .dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
.dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; } .dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; }
@container widget (max-width: 1500px) { @container widget (max-width: 599px) {
.dynamic-columns { gap: 0; }
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; } .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
.dynamic-columns > * {
border-left: none;
padding-left: 0;
}
.dynamic-columns > *:not(:first-child) {
margin-top: calc(var(--list-half-gap) * 2);
}
.dynamic-columns.list-with-separator > *:not(:first-child) {
margin-top: var(--list-half-gap);
border-top: 1px solid var(--color-separator);
padding-top: var(--list-half-gap);
}
}
@container widget (min-width: 600px) and (max-width: 849px) {
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; } .dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
.dynamic-columns > :nth-child(2n-1) {
border-left: none;
padding-left: 0;
}
}
@container widget (min-width: 850px) and (max-width: 1249px) {
.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; } .dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
.dynamic-columns > :nth-child(3n+1) {
border-left: none;
padding-left: 0;
}
}
@container widget (min-width: 1250px) and (max-width: 1499px) {
.dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; } .dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
.dynamic-columns > :nth-child(4n+1) {
border-left: none;
padding-left: 0;
}
} }
@container widget (max-width: 1250px) { @container widget (min-width: 1500px) {
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; } .dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; }
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; } .dynamic-columns > :nth-child(5n+1) {
.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; } border-left: none;
} padding-left: 0;
@container widget (max-width: 850px) { }
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
}
@container widget (max-width: 550px) {
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
} }
.cards-vertical { .cards-vertical {
@@ -444,6 +672,7 @@ kbd:active {
.cards-horizontal { .cards-horizontal {
overflow-x: auto; overflow-x: auto;
scrollbar-width: thin;
padding-bottom: 1rem; padding-bottom: 1rem;
} }
@@ -467,7 +696,10 @@ kbd:active {
@container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } } @container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } }
@container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } } @container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } }
.widget-small-content-bounds {
max-width: 350px;
margin: 0 auto;
}
.widget-error-header { .widget-error-header {
display: flex; display: flex;
@@ -538,7 +770,7 @@ kbd:active {
.widget-header { .widget-header {
padding: 0 calc(var(--widget-content-horizontal-padding) + 1px); padding: 0 calc(var(--widget-content-horizontal-padding) + 1px);
font-size: var(--font-size-h4); font-size: var(--font-size-h4);
margin-bottom: 1rem; margin-bottom: 0.9rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
@@ -584,6 +816,15 @@ kbd:active {
padding-right: var(--widget-content-horizontal-padding); padding-right: var(--widget-content-horizontal-padding);
} }
.logo:has(img) {
display: flex;
align-items: center;
}
.logo img {
max-height: 2.7rem;
}
.nav { .nav {
height: 100%; height: 100%;
gap: var(--header-items-gap); gap: var(--header-items-gap);
@@ -594,7 +835,8 @@ kbd:active {
} }
.footer { .footer {
margin-block: calc(var(--widget-gap) * 1.5); padding-bottom: calc(var(--widget-gap) * 1.5);
padding-top: calc(var(--widget-gap) / 2);
animation: loadingContainerEntrance 200ms backwards; animation: loadingContainerEntrance 200ms backwards;
animation-delay: 150ms; animation-delay: 150ms;
} }
@@ -609,6 +851,7 @@ kbd:active {
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
transition: color .3s, border-color .3s; transition: color .3s, border-color .3s;
font-size: var(--font-size-h3); font-size: var(--font-size-h3);
flex-shrink: 0;
} }
.nav-item:not(.nav-item-current):hover { .nav-item:not(.nav-item-current):hover {
@@ -621,9 +864,17 @@ kbd:active {
color: var(--color-text-highlight); color: var(--color-text-highlight);
} }
.release-source-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
opacity: 0.4;
}
.market-chart { .market-chart {
margin-left: auto; margin-left: auto;
width: 6.5rem; width: 6.5rem;
flex-shrink: 0;
} }
.market-chart svg { .market-chart svg {
@@ -676,6 +927,7 @@ kbd:active {
overflow: hidden; overflow: hidden;
display: block; display: block;
text-overflow: ellipsis; text-overflow: ellipsis;
line-clamp: 2;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
@@ -731,6 +983,7 @@ kbd:active {
height: 6rem; height: 6rem;
font: inherit; font: inherit;
outline: none; outline: none;
color: var(--color-text-highlight);
} }
.search-input::placeholder { .search-input::placeholder {
@@ -796,6 +1049,8 @@ kbd:active {
background-color: var(--color-widget-background-highlight); background-color: var(--color-widget-background-highlight);
border-radius: var(--border-radius); border-radius: var(--border-radius);
padding: 0.5rem; padding: 0.5rem;
opacity: 0.7;
flex-shrink: 0;
} }
.bookmarks-icon { .bookmarks-icon {
@@ -804,10 +1059,6 @@ kbd:active {
opacity: 0.8; opacity: 0.8;
} }
.simple-icon {
opacity: 0.7;
}
:root:not(.light-scheme) .simple-icon { :root:not(.light-scheme) .simple-icon {
filter: invert(1); filter: invert(1);
} }
@@ -818,12 +1069,135 @@ kbd:active {
padding: 0.6rem 0; padding: 0.6rem 0;
} }
.calendar-day-today { .calendar-day-today {
border-radius: var(--border-radius); border-radius: var(--border-radius);
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%))); background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%)));
color: var(--color-text-highlight); color: var(--color-text-highlight);
} }
.dns-stats-totals {
transition: opacity .3s;
transition-delay: 50ms;
}
.dns-stats:has(.dns-stats-graph .popover-active) .dns-stats-totals {
opacity: 0.1;
transition-delay: 0s;
}
.dns-stats-graph {
--graph-height: 70px;
height: var(--graph-height);
position: relative;
margin-bottom: 2.5rem;
}
.dns-stats-graph-gridlines-container {
position: absolute;
z-index: -1;
inset: 0;
}
.dns-stats-graph-gridlines {
height: 100%;
width: 100%;
}
.dns-stats-graph-columns {
display: flex;
height: 100%;
}
.dns-stats-graph-column {
display: flex;
justify-content: flex-end;
align-items: center;
flex-direction: column;
width: calc(100% / 8);
position: relative;
}
.dns-stats-graph-column::before {
content: '';
position: absolute;
inset: 1px 0;
z-index: -1;
opacity: 0;
background: var(--color-text-base);
transition: opacity .2s;
}
.dns-stats-graph-column:hover::before {
opacity: 0.05;
}
.dns-stats-graph-bar {
width: 14px;
height: calc((var(--bar-height) / 100) * var(--graph-height));
border: 1px solid var(--color-progress-border);
border-radius: var(--border-radius) var(--border-radius) 0 0;
display: flex;
background: var(--color-widget-background);
padding: 2px 2px 0 2px;
flex-direction: column;
gap: 2px;
transition: border-color .2s;
min-height: 10px;
}
.dns-stats-graph-column.popover-active .dns-stats-graph-bar {
border-color: var(--color-text-subdue);
border-bottom-color: var(--color-progress-border);
}
.dns-stats-graph-bar > * {
border-radius: 2px;
background: var(--color-progress-value);
min-height: 1px;
}
.dns-stats-graph-bar > .queries {
flex-grow: 1;
}
.dns-stats-graph-bar > *:last-child {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.dns-stats-graph-bar > .blocked {
background-color: var(--color-negative);
}
.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time {
opacity: 1;
transform: translateY(0);
}
.dns-stats-graph-time, .dns-stats-graph-columns:hover .dns-stats-graph-time {
position: absolute;
font-size: var(--font-size-h6);
inset-inline: 0;
text-align: center;
height: 2.5rem;
line-height: 2.5rem;
top: 100%;
user-select: none;
opacity: 0;
transform: translateY(-0.5rem);
transition: opacity .2s, transform .2s;
}
.dns-stats-graph-column:hover .dns-stats-graph-time {
opacity: 1;
transform: translateY(0);
}
.dns-stats-graph-columns:hover .dns-stats-graph-column:not(:hover) .dns-stats-graph-time {
opacity: 0;
}
.weather-column { .weather-column {
position: relative; position: relative;
display: flex; display: flex;
@@ -832,7 +1206,6 @@ kbd:active {
flex-direction: column; flex-direction: column;
width: calc(100% / 12); width: calc(100% / 12);
padding-top: 3px; padding-top: 3px;
max-width: 30px;
} }
.weather-column-value, .weather-columns:hover .weather-column-value { .weather-column-value, .weather-columns:hover .weather-column-value {
@@ -982,11 +1355,18 @@ kbd:active {
transition: filter 0.3s, opacity 0.3s; transition: filter 0.3s, opacity 0.3s;
} }
.monitor-site-icon.simple-icon {
opacity: 0.7;
}
.monitor-site:hover .monitor-site-icon { .monitor-site:hover .monitor-site-icon {
filter: grayscale(0);
opacity: 1; opacity: 1;
} }
.monitor-site:hover .monitor-site-icon:not(.simple-icon) {
filter: grayscale(0);
}
.monitor-site-status-icon { .monitor-site-status-icon {
flex-shrink: 0; flex-shrink: 0;
margin-left: auto; margin-left: auto;
@@ -1141,7 +1521,7 @@ kbd:active {
display: none; display: none;
} }
.page-column-full .size-title-dynamic { .page-column-small .size-title-dynamic {
font-size: var(--font-size-h3); font-size: var(--font-size-h3);
} }
@@ -1166,8 +1546,9 @@ kbd:active {
} }
} }
body { .mobile-navigation-offset {
padding-bottom: calc(var(--mobile-navigation-height) + var(--content-bounds-padding)); height: var(--mobile-navigation-height);
flex-shrink: 0;
} }
.mobile-navigation { .mobile-navigation {
@@ -1200,7 +1581,8 @@ kbd:active {
padding: 15px var(--content-bounds-padding); padding: 15px var(--content-bounds-padding);
display: flex; display: flex;
align-items: center; align-items: center;
overflow-x: scroll; overflow-x: auto;
scrollbar-width: thin;
gap: 2.5rem; gap: 2.5rem;
} }
@@ -1340,6 +1722,7 @@ kbd:active {
} }
.rss-detailed-description { .rss-detailed-description {
line-clamp: 3;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
} }
} }
@@ -1359,6 +1742,7 @@ kbd:active {
.color-positive { color: var(--color-positive); } .color-positive { color: var(--color-positive); }
.color-primary { color: var(--color-primary); } .color-primary { color: var(--color-primary); }
.cursor-help { cursor: help; }
.break-all { word-break: break-all; } .break-all { word-break: break-all; }
.text-left { text-align: left; } .text-left { text-align: left; }
.text-right { text-align: right; } .text-right { text-align: right; }
@@ -1370,6 +1754,7 @@ kbd:active {
.shrink-0 { flex-shrink: 0; } .shrink-0 { flex-shrink: 0; }
.min-width-0 { min-width: 0; } .min-width-0 { min-width: 0; }
.max-width-100 { max-width: 100%; } .max-width-100 { max-width: 100%; }
.height-100 { height: 100%; }
.block { display: block; } .block { display: block; }
.inline-block { display: inline-block; } .inline-block { display: inline-block; }
.overflow-hidden { overflow: hidden; } .overflow-hidden { overflow: hidden; }
@@ -1391,6 +1776,7 @@ kbd:active {
.gap-7 { gap: 0.7rem; } .gap-7 { gap: 0.7rem; }
.gap-10 { gap: 1rem; } .gap-10 { gap: 1rem; }
.gap-15 { gap: 1.5rem; } .gap-15 { gap: 1.5rem; }
.gap-20 { gap: 2rem; }
.gap-25 { gap: 2.5rem; } .gap-25 { gap: 2.5rem; }
.gap-35 { gap: 3.5rem; } .gap-35 { gap: 3.5rem; }
.gap-45 { gap: 4.5rem; } .gap-45 { gap: 4.5rem; }
@@ -1400,6 +1786,11 @@ kbd:active {
.margin-top-7 { margin-top: 0.7rem; } .margin-top-7 { margin-top: 0.7rem; }
.margin-top-10 { margin-top: 1rem; } .margin-top-10 { margin-top: 1rem; }
.margin-top-15 { margin-top: 1.5rem; } .margin-top-15 { margin-top: 1.5rem; }
.margin-top-20 { margin-top: 2rem; }
.margin-top-25 { margin-top: 2.5rem; }
.margin-top-35 { margin-top: 3.5rem; }
.margin-top-40 { margin-top: 4rem; }
.margin-top-auto { margin-top: auto; }
.margin-block-3 { margin-block: 0.3rem; } .margin-block-3 { margin-block: 0.3rem; }
.margin-block-5 { margin-block: 0.5rem; } .margin-block-5 { margin-block: 0.5rem; }
.margin-block-7 { margin-block: 0.7rem; } .margin-block-7 { margin-block: 0.7rem; }
@@ -1411,4 +1802,13 @@ kbd:active {
.margin-bottom-10 { margin-bottom: 1rem; } .margin-bottom-10 { margin-bottom: 1rem; }
.margin-bottom-15 { margin-bottom: 1.5rem; } .margin-bottom-15 { margin-bottom: 1.5rem; }
.margin-bottom-auto { margin-bottom: auto; } .margin-bottom-auto { margin-bottom: auto; }
.padding-block-5 { padding-block: 0.5rem; }
.scale-half { transform: scale(0.5); } .scale-half { transform: scale(0.5); }
.list { --list-half-gap: 0rem; }
.list-gap-2 { --list-half-gap: 0.1rem; }
.list-gap-4 { --list-half-gap: 0.2rem; }
.list-gap-10 { --list-half-gap: 0.5rem; }
.list-gap-14 { --list-half-gap: 0.7rem; }
.list-gap-20 { --list-half-gap: 1rem; }
.list-gap-24 { --list-half-gap: 1.2rem; }
.list-gap-34 { --list-half-gap: 1.7rem; }
+1 -1
View File
@@ -6,7 +6,7 @@
"start_url": "/", "start_url": "/",
"icons": [ "icons": [
{ {
"src": "/static/app-icon.png", "src": "app-icon.png",
"type": "image/png", "type": "image/png",
"sizes": "512x512" "sizes": "512x512"
} }
+2 -13
View File
@@ -37,6 +37,8 @@ var (
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html") RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
SearchTemplate = compileTemplate("search.html", "widget-base.html") SearchTemplate = compileTemplate("search.html", "widget-base.html")
ExtensionTemplate = compileTemplate("extension.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{
@@ -49,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()))
}, },
+14 -28
View File
@@ -1,37 +1,23 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
{{ if ne .Style "dynamic-columns-experimental" }} <div class="dynamic-columns list-gap-24 list-with-separator">
<ul class="list list-gap-24 list-with-separator">
{{ range .Groups }}
<li class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
{{ template "group" . }}
</li>
{{ end }}
</ul>
{{ else }}
<div class="dynamic-columns">
{{ range .Groups }} {{ range .Groups }}
<div 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 }}>
{{ template "group" . }} {{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
<ul class="list list-gap-2">
{{ range .Links }}
<li class="flex items-center gap-10">
{{ if ne "" .Icon }}
<div class="bookmarks-icon-container">
<img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
</div>
{{ end }}
<a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
</li>
{{ end }}
</ul>
</div> </div>
{{ end }} {{ end }}
</div> </div>
{{ end }} {{ end }}
{{ end }}
{{ define "group" }}
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
<ul class="list list-gap-2">
{{ range .Links }}
<li class="flex items-center gap-10">
{{ if ne "" .Icon }}
<div class="bookmarks-icon-container">
<img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
</div>
{{ end }}
<a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
</li>
{{ end }}
</ul>
{{ end }}
+22 -20
View File
@@ -1,27 +1,29 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<div class="flex justify-between items-center"> <div class="widget-small-content-bounds">
<div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div> <div class="flex justify-between items-center">
<ul class="list-horizontal-text color-highlight size-h4"> <div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div>
<li>Week {{ .Calendar.CurrentWeekNumber }}</li> <ul class="list-horizontal-text color-highlight size-h4">
<li>{{ .Calendar.CurrentYear }}</li> <li>Week {{ .Calendar.CurrentWeekNumber }}</li>
</ul> <li>{{ .Calendar.CurrentYear }}</li>
</div> </ul>
</div>
<div class="flex flex-wrap size-h6 margin-top-10 color-subdue"> <div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
<div class="calendar-day">Mo</div> <div class="calendar-day">Mo</div>
<div class="calendar-day">Tu</div> <div class="calendar-day">Tu</div>
<div class="calendar-day">We</div> <div class="calendar-day">We</div>
<div class="calendar-day">Th</div> <div class="calendar-day">Th</div>
<div class="calendar-day">Fr</div> <div class="calendar-day">Fr</div>
<div class="calendar-day">Sa</div> <div class="calendar-day">Sa</div>
<div class="calendar-day">Su</div> <div class="calendar-day">Su</div>
</div> </div>
<div class="flex flex-wrap"> <div class="flex flex-wrap">
{{ range .Calendar.Days }} {{ range .Calendar.Days }}
<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 }}
+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 }}
+5 -6
View File
@@ -11,12 +11,11 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Glance"> <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 }}"> <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="/static/app-icon.png"> <link rel="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}">
<link rel="icon" type="image/png" sizes="50x50" href="/static/favicon.png"> <link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
<link rel="manifest" href="/static/manifest.json"> <link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
<link rel="icon" type="image/png" href="/static/favicon.png" /> <link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
<link rel="stylesheet" href="/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}"> <script type="module" src="{{ .App.AssetPath "js/main.js" }}"></script>
<script async src="/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script>
{{ block "document-head-after" . }}{{ end }} {{ block "document-head-after" . }}{{ end }}
</head> </head>
<body> <body>
+6 -2
View File
@@ -6,7 +6,11 @@
<li> <li>
<div class="flex gap-10 row-reverse-on-mobile thumbnail-parent"> <div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
{{ if $.ShowThumbnails }} {{ if $.ShowThumbnails }}
{{ if ne .ThumbnailUrl "" }} {{ if .IsCrosspost }}
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 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"> <img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
{{ else if .HasTargetUrl }} {{ 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)">
@@ -19,7 +23,7 @@
{{ end }} {{ end }}
{{ end }} {{ end }}
<div class="grow min-width-0"> <div class="grow min-width-0">
<a href="{{ .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 }} {{ if gt (len .Tags) 0 }}
<div class="inline-block forum-post-tags-container"> <div class="inline-block forum-post-tags-container">
<ul class="attachments"> <ul class="attachments">
+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 }}
+16 -30
View File
@@ -1,39 +1,25 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
{{ if ne .Style "dynamic-columns-experimental" }} <div class="dynamic-columns list-gap-20 list-with-separator">
<ul class="list list-gap-20 list-with-separator">
{{ range .Markets }}
<li class="flex items-center gap-15">
{{ template "market" . }}
</li>
{{ end }}
</ul>
{{ else }}
<div class="dynamic-columns">
{{ range .Markets }} {{ range .Markets }}
<div class="flex items-center gap-15"> <div class="flex items-center gap-15">
{{ template "market" . }} <div class="min-width-0">
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
<div class="text-truncate">{{ .Name }}</div>
</div>
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
<svg class="market-chart shrink-0" viewBox="0 0 100 50">
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
</svg>
</a>
<div class="market-values shrink-0">
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
</div>
</div> </div>
{{ end }} {{ end }}
</div> </div>
{{ end }} {{ end }}
{{ end }}
{{ define "market" }}
<div class="min-width-0">
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
<div class="text-truncate">{{ .Name }}</div>
</div>
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
<svg class="market-chart shrink-0" viewBox="0 0 100 50">
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
</svg>
</a>
<div class="market-values shrink-0">
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
</div>
{{ end }}
+13 -13
View File
@@ -1,22 +1,22 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
{{ if ne .Style "dynamic-columns-experimental" }} {{ if not (and .ShowFailingOnly (not .HasFailing)) }}
<ul class="list list-gap-20 list-with-separator"> <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 }}
{{ template "site" . }} <div class="monitor-site flex items-center gap-15">
</li>
{{ end }}
</ul>
{{ else }}
<ul class="dynamic-columns">
{{ range .Sites }}
<div class="flex items-center gap-15">
{{ template "site" . }} {{ template "site" . }}
</div> </div>
{{ end }} {{ end }}
</ul> </ul>
{{ 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 }}
{{ end }} {{ end }}
@@ -24,8 +24,8 @@
{{ if .IconUrl }} {{ if .IconUrl }}
<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy"> <img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy">
{{ end }} {{ end }}
<div> <div class="min-width-0">
<a class="size-h3 color-highlight" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a> <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 title="{{ .Status.Code }}">{{ .StatusText }}</li> <li title="{{ .Status.Code }}">{{ .StatusText }}</li>
+46 -33
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,48 +22,59 @@
{{ 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="header-container content-bounds"> <div class="flex flex-column height-100">
<div class="header flex padding-inline-widget widget-content-frame"> {{ if not .Page.HideDesktopNavigation }}
<!-- TODO: Replace G with actual logo, first need an actual logo --> <div class="header-container content-bounds">
<div class="logo">G</div> <div class="header flex padding-inline-widget widget-content-frame">
<div class="nav flex grow"> <!-- TODO: Replace G with actual logo, first need an actual logo -->
<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">
{{ template "navigation-links" . }}
</div>
</div>
</div>
{{ end }}
<div class="mobile-navigation">
<div class="mobile-navigation-icons">
<a class="mobile-navigation-label" href="#top"></a>
{{ range $i, $column := .Page.Columns }}
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
{{ end }}
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
</div>
<div class="mobile-navigation-page-links">
{{ template "navigation-links" . }} {{ template "navigation-links" . }}
</div> </div>
</div> </div>
</div>
<div class="mobile-navigation"> <div class="content-bounds grow">
<div class="mobile-navigation-icons"> <div class="page" id="page">
<a class="mobile-navigation-label" href="#top"></a> <div class="page-content" id="page-content"></div>
{{ range $i, $column := .Page.Columns }} <div class="page-loading-container">
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label> <!-- TODO: add a bigger/better loading indicator -->
{{ end }} <div class="loading-icon"></div>
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label> </div>
</div>
<div class="mobile-navigation-page-links">
{{ template "navigation-links" . }}
</div>
</div>
<div class="content-bounds">
<div class="page" id="page">
<div class="page-content" id="page-content"></div>
<div class="page-loading-container">
<!-- TODO: add a bigger/better loading indicator -->
<div class="loading-icon"></div>
</div> </div>
</div> </div>
</div>
<div class="footer flex items-center flex-column"> {{ if not .App.Config.Branding.HideFooter }}
<div> <div class="footer flex items-center flex-column">
<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 }} {{ if eq "" .App.Config.Branding.CustomFooter }}
<div>
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
</div>
{{ else }}
{{ .App.Config.Branding.CustomFooter }}
{{ end }}
</div> </div>
<a class="color-primary block margin-top-5 size-h5" href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a> {{ end }}
<div class="mobile-navigation-offset"></div>
</div> </div>
{{ end }} {{ end }}
+12 -7
View File
@@ -1,15 +1,20 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}"> <ul class="list list-gap-10 collapsible-container single-line-titles" data-collapse-after="{{ .CollapseAfter }}">
{{ range $i, $release := .Releases }} {{ range .Releases }}
<li> <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 {{ dynamicRelativeTimeAttrs $release.TimeReleased }}></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>
+17
View File
@@ -7,6 +7,23 @@
<li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li> <li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
</ul> </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 }} {{ if gt (len .RepositoryDetails.PullRequests) 0 }}
<hr class="margin-block-10"> <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> <a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)</a>
@@ -33,6 +33,8 @@
{{ end }} {{ end }}
</div> </div>
</li> </li>
{{ else }}
<li>{{ .NoItemsMessage }}</li>
{{ end }} {{ end }}
</ul> </ul>
{{ end }} {{ end }}
@@ -3,6 +3,7 @@
{{ 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"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}> <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
{{ range .Items }} {{ range .Items }}
@@ -25,4 +26,7 @@
{{ end }} {{ end }}
</div> </div>
</div> </div>
{{ else }}
<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
{{ end }}
{{ end }} {{ end }}
@@ -3,6 +3,7 @@
{{ 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"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}> <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
{{ range .Items }} {{ range .Items }}
@@ -25,4 +26,7 @@
{{ end }} {{ end }}
</div> </div>
</div> </div>
{{ else }}
<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
{{ end }}
{{ end }} {{ end }}
+4 -2
View File
@@ -1,10 +1,10 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}"> <ul class="list list-gap-14 collapsible-container{{ if .SingleLineTitles }} single-line-titles{{ end }}" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Items }} {{ range .Items }}
<li> <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 flex-nowrap"> <ul class="list-horizontal-text flex-nowrap">
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li> <li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="min-width-0"> <li class="min-width-0">
@@ -12,6 +12,8 @@
</li> </li>
</ul> </ul>
</li> </li>
{{ else }}
<li>{{ .NoItemsMessage }}</li>
{{ end }} {{ end }}
</ul> </ul>
{{ end }} {{ end }}
+2 -2
View File
@@ -3,7 +3,7 @@
{{ define "widget-content-classes" }}widget-content-frameless{{ end }} {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }} {{ define "widget-content" }}
<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}"> <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"> <div class="search-bangs">
{{ range .Bangs }} {{ range .Bangs }}
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}"> <input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
@@ -16,7 +16,7 @@
</svg> </svg>
</div> </div>
<input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off"> <input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off"{{ if .Autofocus }} autofocus{{ end }}>
<div class="search-bang"></div> <div class="search-bang"></div>
<kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd> <kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
@@ -7,7 +7,9 @@
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent"> <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 .Exists }} {{ if .Exists }}
<img class="twitch-channel-avatar thumbnail" src="{{ .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" />
@@ -18,7 +20,9 @@
<a href="https://twitch.tv/{{ .Login }}" class="size-h3{{ if .IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ .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 .Exists }} {{ if .Exists }}
{{ if .IsLive }} {{ if .IsLive }}
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .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 {{ dynamicRelativeTimeAttrs .LiveSince }}></li> <li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
<li>{{ .ViewersCount | formatViewerCount }} viewers</li> <li>{{ .ViewersCount | formatViewerCount }} viewers</li>
+22 -20
View File
@@ -1,29 +1,31 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<div class="size-h2 color-highlight text-center">{{ .Weather.WeatherCodeAsString }}</div> <div class="widget-small-content-bounds">
<div class="size-h4 text-center">Feels like {{ .Weather.ApparentTemperature }}°{{ if eq .Units "metric" }}C{{ else }}F{{ end }}</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="weather-columns flex margin-top-15 justify-center"> <div class="weather-columns flex margin-top-15 justify-center">
{{ range $i, $column := .Weather.Columns }} {{ range $i, $column := .Weather.Columns }}
<div class="weather-column{{ if eq $i $.Weather.CurrentColumn }} weather-column-current{{ end }}"> <div class="weather-column{{ if eq $i $.Weather.CurrentColumn }} weather-column-current{{ end }}">
{{ if $column.HasPrecipitation }} {{ if $column.HasPrecipitation }}
<div class="weather-column-rain"></div> <div class="weather-column-rain"></div>
{{ end }}
{{ if and (ge $i $.Weather.SunriseColumn) (le $i $.Weather.SunsetColumn ) }}
<div class="weather-column-daylight{{ if eq $i $.Weather.SunriseColumn }} weather-column-daylight-sunrise{{ else if eq $i $.Weather.SunsetColumn }} weather-column-daylight-sunset{{ end }}"></div>
{{ end }}
<div class="weather-column-value{{ if lt $column.Temperature 0 }} weather-column-value-negative{{ end }}">{{ $column.Temperature | absInt }}</div>
<div class="weather-bar" style='--weather-bar-height: {{ printf "%.2f" $column.Scale }}'></div>
<div class="weather-column-time">{{ index $.TimeLabels $i }}</div>
</div>
{{ end }} {{ end }}
{{ if and (ge $i $.Weather.SunriseColumn) (le $i $.Weather.SunsetColumn ) }} </div>
<div class="weather-column-daylight{{ if eq $i $.Weather.SunriseColumn }} weather-column-daylight-sunrise{{ else if eq $i $.Weather.SunsetColumn }} weather-column-daylight-sunset{{ end }}"></div>
{{ end }} {{ if not .HideLocation }}
<div class="weather-column-value{{ if lt $column.Temperature 0 }} weather-column-value-negative{{ end }}">{{ $column.Temperature | absInt }}</div> <div class="flex items-center justify-center margin-top-15 gap-7 size-h5">
<div class="weather-bar" style='--weather-bar-height: {{ printf "%.2f" $column.Scale }}'></div> <div class="location-icon"></div>
<div class="weather-column-time">{{ index $.TimeLabels $i }}</div> <div class="text-truncate">{{ .Place.Name }},{{ if .ShowAreaName }} {{ .Place.Area }},{{ end }} {{ .Place.Country }}</div>
</div> </div>
{{ end }} {{ end }}
</div> </div>
{{ if not .HideLocation }}
<div class="flex items-center justify-center margin-top-15 gap-7 size-h5">
<div class="location-icon"></div>
<div class="text-truncate">{{ .Place.Name }},{{ if .ShowAreaName }} {{ .Place.Area }},{{ end }} {{ .Place.Country }}</div>
</div>
{{ end }}
{{ end }} {{ end }}
+5 -3
View File
@@ -1,13 +1,15 @@
<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>
<div class="widget-content {{ if .ContentAvailable }}{{ block "widget-content-classes" . }}{{ end }}{{ 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 }}
{{ else }} {{ else }}
+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
}
+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
}
+73 -95
View File
@@ -2,119 +2,50 @@ package feed
import ( import (
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"strings"
"sync" "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]
if len(releases) < 1 {
failed++
slog.Error("No releases found", "repository", repositories[i], "url", requests[i].URL)
continue
}
var liveRelease *githubReleaseResponseJson
for i := range releases {
release := &releases[i]
if !release.Draft && !release.PreRelease {
liveRelease = release
break
}
}
if liveRelease == nil {
slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
continue
}
version := liveRelease.TagName
if version[0] != 'v' {
version = "v" + version
}
appReleases = append(appReleases, AppRelease{
Name: repositories[i],
Version: version,
NotesUrl: liveRelease.HtmlUrl,
TimeReleased: parseGithubTime(liveRelease.PublishedAt),
Downvotes: liveRelease.Reactions.Downvotes,
})
} }
if len(appReleases) == 0 { response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest)
return nil, ErrNoContent
if err != nil {
return nil, err
} }
appReleases.SortByNewest() return &AppRelease{
Source: ReleaseSourceGithub,
if failed > 0 { Name: request.Repository,
return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed) Version: normalizeVersionFormat(response.TagName),
} NotesUrl: response.HtmlUrl,
TimeReleased: parseRFC3339Time(response.PublishedAt),
return appReleases, nil Downvotes: response.Reactions.Downvotes,
}, nil
} }
type GithubTicket struct { type GithubTicket struct {
@@ -131,6 +62,8 @@ type RepositoryDetails struct {
PullRequests []GithubTicket PullRequests []GithubTicket
OpenIssues int OpenIssues int
Issues []GithubTicket Issues []GithubTicket
LastCommits int
Commits []CommitDetails
} }
type githubRepositoryDetailsResponseJson struct { type githubRepositoryDetailsResponseJson struct {
@@ -148,21 +81,40 @@ type githubTicketResponseJson struct {
} `json:"items"` } `json:"items"`
} }
func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int) (RepositoryDetails, error) { type CommitDetails struct {
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil) 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 { if err != nil {
return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err) 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) 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) 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 != "" { if token != "" {
token = fmt.Sprintf("Bearer %s", token) token = fmt.Sprintf("Bearer %s", token)
repositoryRequest.Header.Add("Authorization", token) repositoryRequest.Header.Add("Authorization", token)
PRsRequest.Header.Add("Authorization", token) PRsRequest.Header.Add("Authorization", token)
issuesRequest.Header.Add("Authorization", token) issuesRequest.Header.Add("Authorization", token)
CommitsRequest.Header.Add("Authorization", token)
} }
var detailsResponse githubRepositoryDetailsResponseJson var detailsResponse githubRepositoryDetailsResponseJson
@@ -171,6 +123,8 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
var PRsErr error var PRsErr error
var issuesResponse githubTicketResponseJson var issuesResponse githubTicketResponseJson
var issuesErr error var issuesErr error
var commitsResponse []gitHubCommitResponseJson
var CommitsErr error
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(1) wg.Add(1)
@@ -195,6 +149,14 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
})() })()
} }
if maxCommits > 0 {
wg.Add(1)
go (func() {
defer wg.Done()
commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultClient, CommitsRequest)
})()
}
wg.Wait() wg.Wait()
if detailsErr != nil { if detailsErr != nil {
@@ -207,6 +169,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
Forks: detailsResponse.Forks, Forks: detailsResponse.Forks,
PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)), PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)), Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)),
Commits: make([]CommitDetails, 0, len(commitsResponse)),
} }
err = nil err = nil
@@ -220,7 +183,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
for i := range PRsResponse.Tickets { for i := range PRsResponse.Tickets {
details.PullRequests = append(details.PullRequests, GithubTicket{ details.PullRequests = append(details.PullRequests, GithubTicket{
Number: PRsResponse.Tickets[i].Number, Number: PRsResponse.Tickets[i].Number,
CreatedAt: parseGithubTime(PRsResponse.Tickets[i].CreatedAt), CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
Title: PRsResponse.Tickets[i].Title, Title: PRsResponse.Tickets[i].Title,
}) })
} }
@@ -237,12 +200,27 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
for i := range issuesResponse.Tickets { for i := range issuesResponse.Tickets {
details.Issues = append(details.Issues, GithubTicket{ details.Issues = append(details.Issues, GithubTicket{
Number: issuesResponse.Tickets[i].Number, Number: issuesResponse.Tickets[i].Number,
CreatedAt: parseGithubTime(issuesResponse.Tickets[i].CreatedAt), CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
Title: issuesResponse.Tickets[i].Title, Title: issuesResponse.Tickets[i].Title,
}) })
} }
} }
} }
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 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
}
+21 -11
View File
@@ -55,20 +55,30 @@ func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) {
return posts, nil return posts, nil
} }
func FetchLobstersPosts(sortBy string, tags []string) (ForumPosts, error) { func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) {
var feedUrl string var feedUrl string
if sortBy == "hot" { if customURL != "" {
sortBy = "hottest" feedUrl = customURL
} else if sortBy == "new" {
sortBy = "newest"
}
if len(tags) == 0 {
feedUrl = "https://lobste.rs/" + sortBy + ".json"
} else { } else {
tags := strings.Join(tags, ",") if instanceURL != "" {
feedUrl = "https://lobste.rs/t/" + tags + ".json" 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) posts, err := getLobstersPostsFromFeed(feedUrl)
+8 -1
View File
@@ -9,6 +9,7 @@ import (
type SiteStatusRequest struct { type SiteStatusRequest struct {
URL string `yaml:"url"` URL string `yaml:"url"`
CheckURL string `yaml:"check-url"`
AllowInsecure bool `yaml:"allow-insecure"` AllowInsecure bool `yaml:"allow-insecure"`
} }
@@ -20,7 +21,13 @@ type SiteStatus struct {
} }
func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) { func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
request, err := http.NewRequest(http.MethodGet, statusRequest.URL, nil) var url string
if statusRequest.CheckURL != "" {
url = statusRequest.CheckURL
} else {
url = statusRequest.URL
}
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil { if err != nil {
return SiteStatus{ return SiteStatus{
+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
}
+30 -5
View File
@@ -17,6 +17,7 @@ type ForumPost struct {
Engagement float64 Engagement float64
TimePosted time.Time TimePosted time.Time
Tags []string Tags []string
IsCrosspost bool
} }
type ForumPosts []ForumPost type ForumPosts []ForumPost
@@ -40,11 +41,13 @@ type Weather struct {
} }
type AppRelease struct { type AppRelease struct {
Name string Source ReleaseSource
Version string SourceIconURL string
NotesUrl string Name string
TimeReleased time.Time Version string
Downvotes int NotesUrl string
TimeReleased time.Time
Downvotes int
} }
type AppReleases []AppRelease type AppReleases []AppRelease
@@ -85,6 +88,28 @@ var currencyToSymbol = map[string]string{
"PHP": "₱", "PHP": "₱",
} }
type DNSStats struct {
TotalQueries int
BlockedQueries int
BlockedPercent int
ResponseTime int
DomainsBlocked int
Series [8]DNSStatsSeries
TopBlockedDomains []DNSStatsBlockedDomain
}
type DNSStatsSeries struct {
Queries int
Blocked int
PercentTotal int
PercentBlocked int
}
type DNSStatsBlockedDomain struct {
Domain string
PercentBlocked int
}
type MarketRequest struct { type MarketRequest struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Symbol string `yaml:"symbol"` Symbol string `yaml:"symbol"`
+36 -4
View File
@@ -25,12 +25,26 @@ 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, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string) (ForumPosts, error) { func templateRedditCommentsURL(template, subreddit, postId, postPath string) string {
template = strings.ReplaceAll(template, "{SUBREDDIT}", 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{} query := url.Values{}
var requestUrl string var requestUrl string
@@ -85,9 +99,7 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
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{
@@ -107,6 +119,26 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
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)
} }
+72
View File
@@ -0,0 +1,72 @@
package feed
import (
"errors"
"fmt"
"log/slog"
)
type ReleaseSource string
const (
ReleaseSourceCodeberg ReleaseSource = "codeberg"
ReleaseSourceGithub ReleaseSource = "github"
ReleaseSourceGitlab ReleaseSource = "gitlab"
ReleaseSourceDockerHub ReleaseSource = "dockerhub"
)
type ReleaseRequest struct {
Source ReleaseSource
Repository string
Token *string
}
func FetchLatestReleases(requests []*ReleaseRequest) (AppReleases, error) {
job := newJob(fetchLatestReleaseTask, requests).withWorkers(20)
results, errs, err := workerPoolDo(job)
if err != nil {
return nil, err
}
var failed int
releases := make(AppReleases, 0, len(requests))
for i := range results {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch release", "source", requests[i].Source, "repository", requests[i].Repository, "error", errs[i])
continue
}
releases = append(releases, *results[i])
}
if failed == len(requests) {
return nil, ErrNoContent
}
releases.SortByNewest()
if failed > 0 {
return releases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
}
return releases, nil
}
func fetchLatestReleaseTask(request *ReleaseRequest) (*AppRelease, error) {
switch request.Source {
case ReleaseSourceCodeberg:
return fetchLatestCodebergRelease(request)
case ReleaseSourceGithub:
return fetchLatestGithubRelease(request)
case ReleaseSourceGitlab:
return fetchLatestGitLabRelease(request)
case ReleaseSourceDockerHub:
return fetchLatestDockerHubRelease(request)
}
return nil, errors.New("unsupported source")
}
+77 -28
View File
@@ -12,6 +12,7 @@ import (
"time" "time"
"github.com/mmcdole/gofeed" "github.com/mmcdole/gofeed"
gofeedext "github.com/mmcdole/gofeed/extensions"
) )
type RSSFeedItem struct { type RSSFeedItem struct {
@@ -43,12 +44,25 @@ func sanitizeFeedDescription(description string) string {
return description return description
} }
func shortenFeedDescriptionLen(description string, maxLen int) string {
description, _ = limitStringLength(description, 1000)
description = sanitizeFeedDescription(description)
description, limited := limitStringLength(description, maxLen)
if limited {
description += "…"
}
return description
}
type RSSFeedRequest struct { type RSSFeedRequest struct {
Url string `yaml:"url"` Url string `yaml:"url"`
Title string `yaml:"title"` Title string `yaml:"title"`
HideCategories bool `yaml:"hide-categories"` HideCategories bool `yaml:"hide-categories"`
HideDescription bool `yaml:"hide-description"` HideDescription bool `yaml:"hide-description"`
ItemLinkPrefix string `yaml:"item-link-prefix"` ItemLinkPrefix string `yaml:"item-link-prefix"`
IsDetailed bool `yaml:"-"`
} }
type RSSFeedItems []RSSFeedItem type RSSFeedItems []RSSFeedItem
@@ -80,7 +94,6 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
rssItem := RSSFeedItem{ rssItem := RSSFeedItem{
ChannelURL: feed.Link, ChannelURL: feed.Link,
Title: item.Title,
} }
if request.ItemLinkPrefix != "" { if request.ItemLinkPrefix != "" {
@@ -97,7 +110,7 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
if err == nil { if err == nil {
var link string var link string
if item.Link[0] == '/' { if len(item.Link) > 0 && item.Link[0] == '/' {
link = item.Link link = item.Link
} else { } else {
link = "/" + item.Link link = "/" + item.Link
@@ -107,34 +120,34 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
} }
} }
if !request.HideDescription && item.Description != "" { if item.Title != "" {
description, _ := limitStringLength(item.Description, 1000) rssItem.Title = item.Title
description = sanitizeFeedDescription(description) } else {
description, limited := limitStringLength(description, 200) rssItem.Title = shortenFeedDescriptionLen(item.Description, 100)
if limited {
description += "…"
}
rssItem.Description = description
} }
if !request.HideCategories { if request.IsDetailed {
var categories = make([]string, 0, 6) if !request.HideDescription && item.Description != "" && item.Title != "" {
rssItem.Description = shortenFeedDescriptionLen(item.Description, 200)
for _, category := range item.Categories {
if len(categories) == 6 {
break
}
if len(category) == 0 || len(category) > 30 {
continue
}
categories = append(categories, category)
} }
rssItem.Categories = categories if !request.HideCategories {
var categories = make([]string, 0, 6)
for _, category := range item.Categories {
if len(categories) == 6 {
break
}
if len(category) == 0 || len(category) > 30 {
continue
}
categories = append(categories, category)
}
rssItem.Categories = categories
}
} }
if request.Title != "" { if request.Title != "" {
@@ -145,8 +158,14 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
if item.Image != nil { if item.Image != nil {
rssItem.ImageURL = item.Image.URL rssItem.ImageURL = item.Image.URL
} else if url := findThumbnailInItemExtensions(item); url != "" {
rssItem.ImageURL = url
} else if feed.Image != nil { } else if feed.Image != nil {
rssItem.ImageURL = feed.Image.URL if len(feed.Image.URL) > 0 && feed.Image.URL[0] == '/' {
rssItem.ImageURL = strings.TrimRight(feed.Link, "/") + feed.Image.URL
} else {
rssItem.ImageURL = feed.Image.URL
}
} }
if item.PublishedParsed != nil { if item.PublishedParsed != nil {
@@ -161,6 +180,36 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
return items, nil return items, nil
} }
func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extension) string {
for _, exts := range extensions {
for _, ext := range exts {
if ext.Name == "thumbnail" || ext.Name == "image" {
if url, ok := ext.Attrs["url"]; ok {
return url
}
}
if ext.Children != nil {
if url := recursiveFindThumbnailInExtensions(ext.Children); url != "" {
return url
}
}
}
}
return ""
}
func findThumbnailInItemExtensions(item *gofeed.Item) string {
media, ok := item.Extensions["media"]
if !ok {
return ""
}
return recursiveFindThumbnailInExtensions(media)
}
func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) { func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10) job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10)
feeds, errs, err := workerPoolDo(job) feeds, errs, err := workerPoolDo(job)
@@ -183,7 +232,7 @@ func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
entries = append(entries, feeds[i]...) entries = append(entries, feeds[i]...)
} }
if len(entries) == 0 { if failed == len(requests) {
return nil, ErrNoContent return nil, ErrNoContent
} }
+5 -3
View File
@@ -204,9 +204,11 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
result.IsLive = true result.IsLive = true
result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil && streamMetadata.UserOrNull.Stream.Game != nil { if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil {
result.Category = streamMetadata.UserOrNull.Stream.Game.Name if streamMetadata.UserOrNull.Stream.Game != nil {
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug result.Category = streamMetadata.UserOrNull.Stream.Game.Name
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
}
startedAt, err := time.Parse("2006-01-02T15:04:05Z", streamMetadata.UserOrNull.Stream.StartedAt) startedAt, err := time.Parse("2006-01-02T15:04:05Z", streamMetadata.UserOrNull.Stream.StartedAt)
if err == nil { if err == nil {
+21 -1
View File
@@ -7,6 +7,7 @@ import (
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
"time"
) )
var ( var (
@@ -79,7 +80,6 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
return values return values
} }
var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`) var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
func stripURLScheme(url string) string { func stripURLScheme(url string) string {
@@ -95,3 +95,23 @@ func limitStringLength(s string, max int) (string, bool) {
return s, false return s, false
} }
func parseRFC3339Time(t string) time.Time {
parsed, err := time.Parse(time.RFC3339, t)
if err != nil {
return time.Now()
}
return parsed
}
func normalizeVersionFormat(version string) string {
version = strings.ToLower(strings.TrimSpace(version))
if len(version) > 0 && version[0] != 'v' {
return "v" + version
}
return version
}
+14 -14
View File
@@ -10,11 +10,9 @@ import (
) )
type youtubeFeedResponseXml struct { type youtubeFeedResponseXml struct {
Channel string `xml:"title"` Channel string `xml:"author>name"`
ChannelLink struct { ChannelLink string `xml:"author>uri"`
Href string `xml:"href,attr"` Videos []struct {
} `xml:"link"`
Videos []struct {
Title string `xml:"title"` Title string `xml:"title"`
Published string `xml:"published"` Published string `xml:"published"`
Link struct { Link struct {
@@ -39,11 +37,19 @@ func parseYoutubeFeedTime(t string) time.Time {
return parsedTime return parsedTime
} }
func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (Videos, error) { func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, includeShorts bool) (Videos, error) {
requests := make([]*http.Request, 0, len(channelIds)) requests := make([]*http.Request, 0, len(channelIds))
for i := range channelIds { for i := range channelIds {
request, _ := http.NewRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id="+channelIds[i], nil) var feedUrl string
if !includeShorts && strings.HasPrefix(channelIds[i], "UC") {
playlistId := strings.Replace(channelIds[i], "UC", "UULF", 1)
feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId
} else {
feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelIds[i]
}
request, _ := http.NewRequest("GET", feedUrl, nil)
requests = append(requests, request) requests = append(requests, request)
} }
@@ -70,12 +76,6 @@ func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (V
for j := range response.Videos { for j := range response.Videos {
video := &response.Videos[j] video := &response.Videos[j]
// TODO: figure out a better way of skipping shorts
if strings.Contains(video.Title, "#shorts") {
continue
}
var videoUrl string var videoUrl string
if videoUrlTemplate == "" { if videoUrlTemplate == "" {
@@ -95,7 +95,7 @@ func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (V
Title: video.Title, Title: video.Title,
Url: videoUrl, Url: videoUrl,
Author: response.Channel, Author: response.Channel,
AuthorUrl: response.ChannelLink.Href + "/videos", AuthorUrl: response.ChannelLink + "/videos",
TimePosted: parseYoutubeFeedTime(video.Published), TimePosted: parseYoutubeFeedTime(video.Published),
}) })
} }
+26 -5
View File
@@ -8,9 +8,10 @@ import (
) )
type Config struct { type Config struct {
Server Server `yaml:"server"` Server Server `yaml:"server"`
Theme Theme `yaml:"theme"` Theme Theme `yaml:"theme"`
Pages []Page `yaml:"pages"` Branding Branding `yaml:"branding"`
Pages []Page `yaml:"pages"`
} }
func NewConfigFromYml(contents io.Reader) (*Config, error) { func NewConfigFromYml(contents io.Reader) (*Config, error) {
@@ -32,6 +33,16 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
return nil, err return nil, err
} }
for p := range config.Pages {
for c := range config.Pages[p].Columns {
for w := range config.Pages[p].Columns[c].Widgets {
if err := config.Pages[p].Columns[c].Widgets[w].Initialize(); err != nil {
return nil, err
}
}
}
}
return config, nil return config, nil
} }
@@ -50,12 +61,22 @@ func configIsValid(config *Config) error {
return fmt.Errorf("Page %d has no title", i+1) return fmt.Errorf("Page %d has no title", i+1)
} }
if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") {
return fmt.Errorf("Page %d: width can only be either wide or slim", i+1)
}
if len(config.Pages[i].Columns) == 0 { if len(config.Pages[i].Columns) == 0 {
return fmt.Errorf("Page %d has no columns", i+1) return fmt.Errorf("Page %d has no columns", i+1)
} }
if len(config.Pages[i].Columns) > 3 { if config.Pages[i].Width == "slim" {
return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns)) if len(config.Pages[i].Columns) > 2 {
return fmt.Errorf("Page %d is slim and cannot have more than 2 columns", i+1)
}
} else {
if len(config.Pages[i].Columns) > 3 {
return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns))
}
} }
columnSizesCount := make(map[string]int) columnSizesCount := make(map[string]int)
+97 -12
View File
@@ -4,10 +4,12 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -24,6 +26,7 @@ type Application struct {
Version string Version string
Config Config Config Config
slugToPage map[string]*Page slugToPage map[string]*Page
widgetByID map[uint64]widget.Widget
} }
type Theme struct { type Theme struct {
@@ -41,7 +44,17 @@ type Server struct {
Host string `yaml:"host"` Host string `yaml:"host"`
Port uint16 `yaml:"port"` Port uint16 `yaml:"port"`
AssetsPath string `yaml:"assets-path"` AssetsPath string `yaml:"assets-path"`
StartedAt time.Time `yaml:"-"` BaseURL string `yaml:"base-url"`
AssetsHash string `yaml:"-"`
StartedAt time.Time `yaml:"-"` // used in custom css file
}
type Branding struct {
HideFooter bool `yaml:"hide-footer"`
CustomFooter template.HTML `yaml:"custom-footer"`
LogoText string `yaml:"logo-text"`
LogoURL string `yaml:"logo-url"`
FaviconURL string `yaml:"favicon-url"`
} }
type Column struct { type Column struct {
@@ -55,11 +68,14 @@ type templateData struct {
} }
type Page struct { type Page struct {
Title string `yaml:"name"` Title string `yaml:"name"`
Slug string `yaml:"slug"` Slug string `yaml:"slug"`
ShowMobileHeader bool `yaml:"show-mobile-header"` Width string `yaml:"width"`
Columns []Column `yaml:"columns"` ShowMobileHeader bool `yaml:"show-mobile-header"`
mu sync.Mutex HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
CenterVertically bool `yaml:"center-vertically"`
Columns []Column `yaml:"columns"`
mu sync.Mutex
} }
func (p *Page) UpdateOutdatedWidgets() { func (p *Page) UpdateOutdatedWidgets() {
@@ -96,6 +112,14 @@ func titleToSlug(s string) string {
return s return s
} }
func (a *Application) TransformUserDefinedAssetPath(path string) string {
if strings.HasPrefix(path, "/assets/") {
return a.Config.Server.BaseURL + path
}
return path
}
func NewApplication(config *Config) (*Application, error) { func NewApplication(config *Config) (*Application, error) {
if len(config.Pages) == 0 { if len(config.Pages) == 0 {
return nil, fmt.Errorf("no pages configured") return nil, fmt.Errorf("no pages configured")
@@ -105,18 +129,46 @@ func NewApplication(config *Config) (*Application, error) {
Version: buildVersion, Version: buildVersion,
Config: *config, Config: *config,
slugToPage: make(map[string]*Page), slugToPage: make(map[string]*Page),
widgetByID: make(map[uint64]widget.Widget),
} }
app.Config.Server.AssetsHash = assets.PublicFSHash
app.slugToPage[""] = &config.Pages[0] app.slugToPage[""] = &config.Pages[0]
for i := range config.Pages { providers := &widget.Providers{
if config.Pages[i].Slug == "" { AssetResolver: app.AssetPath,
config.Pages[i].Slug = titleToSlug(config.Pages[i].Title) }
for p := range config.Pages {
if config.Pages[p].Slug == "" {
config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
} }
app.slugToPage[config.Pages[i].Slug] = &config.Pages[i] app.slugToPage[config.Pages[p].Slug] = &config.Pages[p]
for c := range config.Pages[p].Columns {
for w := range config.Pages[p].Columns[c].Widgets {
widget := config.Pages[p].Columns[c].Widgets[w]
app.widgetByID[widget.GetID()] = widget
widget.SetProviders(providers)
}
}
} }
config = &app.Config
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
config.Theme.CustomCSSFile = app.TransformUserDefinedAssetPath(config.Theme.CustomCSSFile)
if config.Branding.FaviconURL == "" {
config.Branding.FaviconURL = app.AssetPath("favicon.png")
} else {
config.Branding.FaviconURL = app.TransformUserDefinedAssetPath(config.Branding.FaviconURL)
}
config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL)
return app, nil return app, nil
} }
@@ -189,6 +241,30 @@ func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.H
}) })
} }
func (a *Application) HandleWidgetRequest(w http.ResponseWriter, r *http.Request) {
widgetValue := r.PathValue("widget")
widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
if err != nil {
a.HandleNotFound(w, r)
return
}
widget, exists := a.widgetByID[widgetID]
if !exists {
a.HandleNotFound(w, r)
return
}
widget.HandleRequest(w, r)
}
func (a *Application) AssetPath(asset string) string {
return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset
}
func (a *Application) Serve() error { func (a *Application) Serve() error {
// TODO: add gzip support, static files must have their gzipped contents cached // TODO: add gzip support, static files must have their gzipped contents cached
// TODO: add HTTPS support // TODO: add HTTPS support
@@ -196,8 +272,17 @@ func (a *Application) Serve() error {
mux.HandleFunc("GET /{$}", a.HandlePageRequest) mux.HandleFunc("GET /{$}", a.HandlePageRequest)
mux.HandleFunc("GET /{page}", a.HandlePageRequest) mux.HandleFunc("GET /{page}", a.HandlePageRequest)
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest) mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
mux.Handle("GET /static/{path...}", http.StripPrefix("/static/", FileServerWithCache(http.FS(assets.PublicFS), 2*time.Hour))) mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest)
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.Handle(
fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)),
)
if a.Config.Server.AssetsPath != "" { if a.Config.Server.AssetsPath != "" {
absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath) absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
@@ -217,7 +302,7 @@ func (a *Application) Serve() error {
} }
a.Config.Server.StartedAt = time.Now() a.Config.Server.StartedAt = time.Now()
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL)
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port)
return server.ListenAndServe() return server.ListenAndServe()
} }
-1
View File
@@ -21,7 +21,6 @@ type Bookmarks struct {
HideArrow bool `yaml:"hide-arrow"` HideArrow bool `yaml:"hide-arrow"`
} `yaml:"links"` } `yaml:"links"`
} `yaml:"groups"` } `yaml:"groups"`
Style string `yaml:"style"`
} }
func (widget *Bookmarks) Initialize() error { func (widget *Bookmarks) Initialize() error {
+77
View File
@@ -0,0 +1,77 @@
package widget
import (
"context"
"errors"
"html/template"
"strings"
"time"
"github.com/glanceapp/glance/internal/assets"
"github.com/glanceapp/glance/internal/feed"
)
type DNSStats struct {
widgetBase `yaml:",inline"`
TimeLabels [8]string `yaml:"-"`
Stats *feed.DNSStats `yaml:"-"`
HourFormat string `yaml:"hour-format"`
Service string `yaml:"service"`
URL OptionalEnvString `yaml:"url"`
Token OptionalEnvString `yaml:"token"`
Username OptionalEnvString `yaml:"username"`
Password OptionalEnvString `yaml:"password"`
}
func makeDNSTimeLabels(format string) [8]string {
now := time.Now()
var labels [8]string
for i := 24; i > 0; i -= 3 {
labels[7-(i/3-1)] = strings.ToLower(now.Add(-time.Duration(i) * time.Hour).Format(format))
}
return labels
}
func (widget *DNSStats) Initialize() error {
widget.
withTitle("DNS Stats").
withTitleURL(string(widget.URL)).
withCacheDuration(10 * time.Minute)
if widget.Service != "adguard" && widget.Service != "pihole" {
return errors.New("DNS stats service must be either 'adguard' or 'pihole'")
}
return nil
}
func (widget *DNSStats) Update(ctx context.Context) {
var stats *feed.DNSStats
var err error
if widget.Service == "adguard" {
stats, err = feed.FetchAdguardStats(string(widget.URL), string(widget.Username), string(widget.Password))
} else {
stats, err = feed.FetchPiholeStats(string(widget.URL), string(widget.Token))
}
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if widget.HourFormat == "24h" {
widget.TimeLabels = makeDNSTimeLabels("15:00")
} else {
widget.TimeLabels = makeDNSTimeLabels("3PM")
}
widget.Stats = stats
}
func (widget *DNSStats) Render() template.HTML {
return widget.render(widget, assets.DNSStatsTemplate)
}
+4
View File
@@ -152,6 +152,10 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
return nil return nil
} }
func (f *OptionalEnvString) String() string {
return string(*f)
}
func toSimpleIconIfPrefixed(icon string) (string, bool) { func toSimpleIconIfPrefixed(icon string) (string, bool) {
if !strings.HasPrefix(icon, "si:") { if !strings.HasPrefix(icon, "si:") {
return icon, false return icon, false
+76
View File
@@ -0,0 +1,76 @@
package widget
import (
"context"
"errors"
"html/template"
"sync"
"time"
"github.com/glanceapp/glance/internal/assets"
)
type Group struct {
widgetBase `yaml:",inline"`
Widgets Widgets `yaml:"widgets"`
}
func (widget *Group) Initialize() error {
widget.withError(nil)
widget.HideHeader = true
for i := range widget.Widgets {
widget.Widgets[i].SetHideHeader(true)
if widget.Widgets[i].GetType() == "group" {
return errors.New("nested groups are not allowed")
}
if err := widget.Widgets[i].Initialize(); err != nil {
return err
}
}
return nil
}
func (widget *Group) Update(ctx context.Context) {
var wg sync.WaitGroup
now := time.Now()
for w := range widget.Widgets {
widget := widget.Widgets[w]
if !widget.RequiresUpdate(&now) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
widget.Update(ctx)
}()
}
wg.Wait()
}
func (widget *Group) SetProviders(providers *Providers) {
for i := range widget.Widgets {
widget.Widgets[i].SetProviders(providers)
}
}
func (widget *Group) RequiresUpdate(now *time.Time) bool {
for i := range widget.Widgets {
if widget.Widgets[i].RequiresUpdate(now) {
return true
}
}
return false
}
func (widget *Group) Render() template.HTML {
return widget.render(widget, assets.GroupTemplate)
}
+4 -1
View File
@@ -21,7 +21,10 @@ type HackerNews struct {
} }
func (widget *HackerNews) Initialize() error { func (widget *HackerNews) Initialize() error {
widget.withTitle("Hacker News").withCacheDuration(30 * time.Minute) widget.
withTitle("Hacker News").
withTitleURL("https://news.ycombinator.com/").
withCacheDuration(30 * time.Minute)
if widget.Limit <= 0 { if widget.Limit <= 0 {
widget.Limit = 15 widget.Limit = 15
+10 -2
View File
@@ -12,6 +12,8 @@ import (
type Lobsters struct { type Lobsters struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
Posts feed.ForumPosts `yaml:"-"` Posts feed.ForumPosts `yaml:"-"`
InstanceURL string `yaml:"instance-url"`
CustomURL string `yaml:"custom-url"`
Limit int `yaml:"limit"` Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"` CollapseAfter int `yaml:"collapse-after"`
SortBy string `yaml:"sort-by"` SortBy string `yaml:"sort-by"`
@@ -20,7 +22,13 @@ type Lobsters struct {
} }
func (widget *Lobsters) Initialize() error { func (widget *Lobsters) Initialize() error {
widget.withTitle("Lobsters").withCacheDuration(30 * time.Minute) widget.withTitle("Lobsters").withCacheDuration(time.Hour)
if widget.InstanceURL == "" {
widget.withTitleURL("https://lobste.rs")
} else {
widget.withTitleURL(widget.InstanceURL)
}
if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") { if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") {
widget.SortBy = "hot" widget.SortBy = "hot"
@@ -38,7 +46,7 @@ func (widget *Lobsters) Initialize() error {
} }
func (widget *Lobsters) Update(ctx context.Context) { func (widget *Lobsters) Update(ctx context.Context) {
posts, err := feed.FetchLobstersPosts(widget.SortBy, widget.Tags) posts, err := feed.FetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags)
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
return return
@@ -14,7 +14,6 @@ type Markets struct {
StocksRequests []feed.MarketRequest `yaml:"stocks"` StocksRequests []feed.MarketRequest `yaml:"stocks"`
MarketRequests []feed.MarketRequest `yaml:"markets"` MarketRequests []feed.MarketRequest `yaml:"markets"`
Sort string `yaml:"sort-by"` Sort string `yaml:"sort-by"`
Style string `yaml:"style"`
Markets feed.Markets `yaml:"-"` Markets feed.Markets `yaml:"-"`
} }
+8 -2
View File
@@ -53,7 +53,8 @@ type Monitor struct {
StatusText string `yaml:"-"` StatusText string `yaml:"-"`
StatusStyle string `yaml:"-"` StatusStyle string `yaml:"-"`
} `yaml:"sites"` } `yaml:"sites"`
Style string `yaml:"style"` ShowFailingOnly bool `yaml:"show-failing-only"`
HasFailing bool `yaml:"-"`
} }
func (widget *Monitor) Initialize() error { func (widget *Monitor) Initialize() error {
@@ -79,12 +80,17 @@ func (widget *Monitor) Update(ctx context.Context) {
return return
} }
widget.HasFailing = false
for i := range widget.Sites { for i := range widget.Sites {
site := &widget.Sites[i] site := &widget.Sites[i]
status := &statuses[i] status := &statuses[i]
site.Status = status site.Status = status
if status.Code >= 400 || status.TimedOut || status.Error != nil {
widget.HasFailing = true
}
if !status.TimedOut { if !status.TimedOut {
site.StatusText = statusCodeToText(status.Code) site.StatusText = statusCodeToText(status.Code)
site.StatusStyle = statusCodeToStyle(status.Code) site.StatusStyle = statusCodeToStyle(status.Code)
+6 -1
View File
@@ -17,6 +17,7 @@ type Reddit struct {
Subreddit string `yaml:"subreddit"` Subreddit string `yaml:"subreddit"`
Style string `yaml:"style"` Style string `yaml:"style"`
ShowThumbnails bool `yaml:"show-thumbnails"` ShowThumbnails bool `yaml:"show-thumbnails"`
ShowFlairs bool `yaml:"show-flairs"`
SortBy string `yaml:"sort-by"` SortBy string `yaml:"sort-by"`
TopPeriod string `yaml:"top-period"` TopPeriod string `yaml:"top-period"`
Search string `yaml:"search"` Search string `yaml:"search"`
@@ -54,7 +55,10 @@ func (widget *Reddit) Initialize() error {
} }
} }
widget.withTitle("/r/" + widget.Subreddit).withCacheDuration(30 * time.Minute) widget.
withTitle("/r/" + widget.Subreddit).
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
withCacheDuration(30 * time.Minute)
return nil return nil
} }
@@ -84,6 +88,7 @@ func (widget *Reddit) Update(ctx context.Context) {
widget.Search, widget.Search,
widget.CommentsUrlTemplate, widget.CommentsUrlTemplate,
widget.RequestUrlTemplate, widget.RequestUrlTemplate,
widget.ShowFlairs,
) )
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
+59 -7
View File
@@ -2,7 +2,9 @@ package widget
import ( import (
"context" "context"
"errors"
"html/template" "html/template"
"strings"
"time" "time"
"github.com/glanceapp/glance/internal/assets" "github.com/glanceapp/glance/internal/assets"
@@ -10,12 +12,15 @@ import (
) )
type Releases struct { type Releases struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
Releases feed.AppReleases `yaml:"-"` Releases feed.AppReleases `yaml:"-"`
Repositories []string `yaml:"repositories"` releaseRequests []*feed.ReleaseRequest `yaml:"-"`
Token OptionalEnvString `yaml:"token"` Repositories []string `yaml:"repositories"`
Limit int `yaml:"limit"` Token OptionalEnvString `yaml:"token"`
CollapseAfter int `yaml:"collapse-after"` GitLabToken OptionalEnvString `yaml:"gitlab-token"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
ShowSourceIcon bool `yaml:"show-source-icon"`
} }
func (widget *Releases) Initialize() error { func (widget *Releases) Initialize() error {
@@ -29,11 +34,54 @@ func (widget *Releases) Initialize() error {
widget.CollapseAfter = 5 widget.CollapseAfter = 5
} }
var tokenAsString = widget.Token.String()
var gitLabTokenAsString = widget.GitLabToken.String()
for _, repository := range widget.Repositories {
parts := strings.SplitN(repository, ":", 2)
var request *feed.ReleaseRequest
if len(parts) == 1 {
request = &feed.ReleaseRequest{
Source: feed.ReleaseSourceGithub,
Repository: repository,
}
if widget.Token != "" {
request.Token = &tokenAsString
}
} else if len(parts) == 2 {
if parts[0] == string(feed.ReleaseSourceGitlab) {
request = &feed.ReleaseRequest{
Source: feed.ReleaseSourceGitlab,
Repository: parts[1],
}
if widget.GitLabToken != "" {
request.Token = &gitLabTokenAsString
}
} else if parts[0] == string(feed.ReleaseSourceDockerHub) {
request = &feed.ReleaseRequest{
Source: feed.ReleaseSourceDockerHub,
Repository: parts[1],
}
} else if parts[0] == string(feed.ReleaseSourceCodeberg) {
request = &feed.ReleaseRequest{
Source: feed.ReleaseSourceCodeberg,
Repository: parts[1],
}
} else {
return errors.New("invalid repository source " + parts[0])
}
}
widget.releaseRequests = append(widget.releaseRequests, request)
}
return nil return nil
} }
func (widget *Releases) Update(ctx context.Context) { func (widget *Releases) Update(ctx context.Context) {
releases, err := feed.FetchLatestReleasesFromGithub(widget.Repositories, string(widget.Token)) releases, err := feed.FetchLatestReleases(widget.releaseRequests)
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
return return
@@ -43,6 +91,10 @@ func (widget *Releases) Update(ctx context.Context) {
releases = releases[:widget.Limit] releases = releases[:widget.Limit]
} }
for i := range releases {
releases[i].SourceIconURL = widget.Providers.AssetResolver("icons/" + string(releases[i].Source) + ".svg")
}
widget.Releases = releases widget.Releases = releases
} }
+6
View File
@@ -15,6 +15,7 @@ type Repository struct {
Token OptionalEnvString `yaml:"token"` Token OptionalEnvString `yaml:"token"`
PullRequestsLimit int `yaml:"pull-requests-limit"` PullRequestsLimit int `yaml:"pull-requests-limit"`
IssuesLimit int `yaml:"issues-limit"` IssuesLimit int `yaml:"issues-limit"`
CommitsLimit int `yaml:"commits-limit"`
RepositoryDetails feed.RepositoryDetails RepositoryDetails feed.RepositoryDetails
} }
@@ -29,6 +30,10 @@ func (widget *Repository) Initialize() error {
widget.IssuesLimit = 3 widget.IssuesLimit = 3
} }
if widget.CommitsLimit == 0 || widget.CommitsLimit < -1 {
widget.CommitsLimit = -1
}
return nil return nil
} }
@@ -38,6 +43,7 @@ func (widget *Repository) Update(ctx context.Context) {
string(widget.Token), string(widget.Token),
widget.PullRequestsLimit, widget.PullRequestsLimit,
widget.IssuesLimit, widget.IssuesLimit,
widget.CommitsLimit,
) )
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
+14 -11
View File
@@ -10,14 +10,16 @@ import (
) )
type RSS struct { type RSS struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
FeedRequests []feed.RSSFeedRequest `yaml:"feeds"` FeedRequests []feed.RSSFeedRequest `yaml:"feeds"`
Style string `yaml:"style"` Style string `yaml:"style"`
ThumbnailHeight float64 `yaml:"thumbnail-height"` ThumbnailHeight float64 `yaml:"thumbnail-height"`
CardHeight float64 `yaml:"card-height"` CardHeight float64 `yaml:"card-height"`
Items feed.RSSFeedItems `yaml:"-"` Items feed.RSSFeedItems `yaml:"-"`
Limit int `yaml:"limit"` Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"` CollapseAfter int `yaml:"collapse-after"`
SingleLineTitles bool `yaml:"single-line-titles"`
NoItemsMessage string `yaml:"-"`
} }
func (widget *RSS) Initialize() error { func (widget *RSS) Initialize() error {
@@ -39,13 +41,14 @@ func (widget *RSS) Initialize() error {
widget.CardHeight = 0 widget.CardHeight = 0
} }
if widget.Style != "detailed-list" { if widget.Style == "detailed-list" {
for i := range widget.FeedRequests { for i := range widget.FeedRequests {
widget.FeedRequests[i].HideCategories = true widget.FeedRequests[i].IsDetailed = true
widget.FeedRequests[i].HideDescription = true
} }
} }
widget.NoItemsMessage = "No items were returned from the feeds."
return nil return nil
} }
+2
View File
@@ -19,6 +19,8 @@ type Search struct {
cachedHTML template.HTML `yaml:"-"` cachedHTML template.HTML `yaml:"-"`
SearchEngine string `yaml:"search-engine"` SearchEngine string `yaml:"search-engine"`
Bangs []SearchBang `yaml:"bangs"` Bangs []SearchBang `yaml:"bangs"`
NewTab bool `yaml:"new-tab"`
Autofocus bool `yaml:"autofocus"`
} }
func convertSearchUrl(url string) string { func convertSearchUrl(url string) string {
+4 -1
View File
@@ -18,7 +18,10 @@ type TwitchChannels struct {
} }
func (widget *TwitchChannels) Initialize() error { func (widget *TwitchChannels) Initialize() error {
widget.withTitle("Twitch Channels").withCacheDuration(time.Minute * 10) widget.
withTitle("Twitch Channels").
withTitleURL("https://www.twitch.tv/directory/following").
withCacheDuration(time.Minute * 10)
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 5 widget.CollapseAfter = 5
+4 -1
View File
@@ -18,7 +18,10 @@ type TwitchGames struct {
} }
func (widget *TwitchGames) Initialize() error { func (widget *TwitchGames) Initialize() error {
widget.withTitle("Top games on Twitch").withCacheDuration(time.Minute * 10) widget.
withTitle("Top games on Twitch").
withTitleURL("https://www.twitch.tv/directory?sort=VIEWER_COUNT").
withCacheDuration(time.Minute * 10)
if widget.Limit <= 0 { if widget.Limit <= 0 {
widget.Limit = 10 widget.Limit = 10
+2 -1
View File
@@ -17,6 +17,7 @@ type Videos struct {
CollapseAfterRows int `yaml:"collapse-after-rows"` CollapseAfterRows int `yaml:"collapse-after-rows"`
Channels []string `yaml:"channels"` Channels []string `yaml:"channels"`
Limit int `yaml:"limit"` Limit int `yaml:"limit"`
IncludeShorts bool `yaml:"include-shorts"`
} }
func (widget *Videos) Initialize() error { func (widget *Videos) Initialize() error {
@@ -34,7 +35,7 @@ func (widget *Videos) Initialize() error {
} }
func (widget *Videos) Update(ctx context.Context) { func (widget *Videos) Update(ctx context.Context) {
videos, err := feed.FetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate) videos, err := feed.FetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate, widget.IncludeShorts)
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
return return
+76 -24
View File
@@ -8,6 +8,8 @@ import (
"html/template" "html/template"
"log/slog" "log/slog"
"math" "math"
"net/http"
"sync/atomic"
"time" "time"
"github.com/glanceapp/glance/internal/feed" "github.com/glanceapp/glance/internal/feed"
@@ -15,51 +17,63 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
var uniqueID atomic.Uint64
func New(widgetType string) (Widget, error) { func New(widgetType string) (Widget, error) {
var widget Widget
switch widgetType { switch widgetType {
case "calendar": case "calendar":
return &Calendar{}, nil widget = &Calendar{}
case "clock": case "clock":
return &Clock{}, nil widget = &Clock{}
case "weather": case "weather":
return &Weather{}, nil widget = &Weather{}
case "bookmarks": case "bookmarks":
return &Bookmarks{}, nil widget = &Bookmarks{}
case "iframe": case "iframe":
return &IFrame{}, nil widget = &IFrame{}
case "html": case "html":
return &HTML{}, nil widget = &HTML{}
case "hacker-news": case "hacker-news":
return &HackerNews{}, nil widget = &HackerNews{}
case "releases": case "releases":
return &Releases{}, nil widget = &Releases{}
case "videos": case "videos":
return &Videos{}, nil widget = &Videos{}
case "markets", "stocks": case "markets", "stocks":
return &Markets{}, nil widget = &Markets{}
case "reddit": case "reddit":
return &Reddit{}, nil widget = &Reddit{}
case "rss": case "rss":
return &RSS{}, nil widget = &RSS{}
case "monitor": case "monitor":
return &Monitor{}, nil widget = &Monitor{}
case "twitch-top-games": case "twitch-top-games":
return &TwitchGames{}, nil widget = &TwitchGames{}
case "twitch-channels": case "twitch-channels":
return &TwitchChannels{}, nil widget = &TwitchChannels{}
case "lobsters": case "lobsters":
return &Lobsters{}, nil widget = &Lobsters{}
case "change-detection": case "change-detection":
return &ChangeDetection{}, nil widget = &ChangeDetection{}
case "repository": case "repository":
return &Repository{}, nil widget = &Repository{}
case "search": case "search":
return &Search{}, nil widget = &Search{}
case "extension": case "extension":
return &Extension{}, nil widget = &Extension{}
case "group":
widget = &Group{}
case "dns-stats":
widget = &DNSStats{}
default: default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType) return nil, fmt.Errorf("unknown widget type: %s", widgetType)
} }
widget.SetID(uniqueID.Add(1))
return widget, nil
} }
type Widgets []Widget type Widgets []Widget
@@ -90,10 +104,6 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
return err return err
} }
if err = widget.Initialize(); err != nil {
return err
}
*w = append(*w, widget) *w = append(*w, widget)
} }
@@ -103,9 +113,14 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
type Widget interface { type Widget interface {
Initialize() error Initialize() error
RequiresUpdate(*time.Time) bool RequiresUpdate(*time.Time) bool
SetProviders(*Providers)
Update(context.Context) Update(context.Context)
Render() template.HTML Render() template.HTML
GetType() string GetType() string
GetID() uint64
SetID(uint64)
HandleRequest(w http.ResponseWriter, r *http.Request)
SetHideHeader(bool)
} }
type cacheType int type cacheType int
@@ -117,8 +132,12 @@ const (
) )
type widgetBase struct { type widgetBase struct {
ID uint64 `yaml:"-"`
Providers *Providers `yaml:"-"`
Type string `yaml:"type"` Type string `yaml:"type"`
Title string `yaml:"title"` Title string `yaml:"title"`
TitleURL string `yaml:"title-url"`
CSSClass string `yaml:"css-class"`
CustomCacheDuration DurationField `yaml:"cache"` CustomCacheDuration DurationField `yaml:"cache"`
ContentAvailable bool `yaml:"-"` ContentAvailable bool `yaml:"-"`
Error error `yaml:"-"` Error error `yaml:"-"`
@@ -128,6 +147,11 @@ type widgetBase struct {
cacheType cacheType `yaml:"-"` cacheType cacheType `yaml:"-"`
nextUpdate time.Time `yaml:"-"` nextUpdate time.Time `yaml:"-"`
updateRetriedTimes int `yaml:"-"` updateRetriedTimes int `yaml:"-"`
HideHeader bool `yaml:"-"`
}
type Providers struct {
AssetResolver func(string) string
} }
func (w *widgetBase) RequiresUpdate(now *time.Time) bool { func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
@@ -146,10 +170,30 @@ func (w *widgetBase) Update(ctx context.Context) {
} }
func (w *widgetBase) GetID() uint64 {
return w.ID
}
func (w *widgetBase) SetID(id uint64) {
w.ID = id
}
func (w *widgetBase) SetHideHeader(value bool) {
w.HideHeader = value
}
func (widget *widgetBase) HandleRequest(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not implemented", http.StatusNotImplemented)
}
func (w *widgetBase) GetType() string { func (w *widgetBase) GetType() string {
return w.Type return w.Type
} }
func (w *widgetBase) SetProviders(providers *Providers) {
w.Providers = providers
}
func (w *widgetBase) render(data any, t *template.Template) template.HTML { func (w *widgetBase) render(data any, t *template.Template) template.HTML {
w.templateBuffer.Reset() w.templateBuffer.Reset()
err := t.Execute(&w.templateBuffer, data) err := t.Execute(&w.templateBuffer, data)
@@ -185,6 +229,14 @@ func (w *widgetBase) withTitle(title string) *widgetBase {
return w return w
} }
func (w *widgetBase) withTitleURL(titleURL string) *widgetBase {
if w.TitleURL == "" {
w.TitleURL = titleURL
}
return w
}
func (w *widgetBase) withCacheDuration(duration time.Duration) *widgetBase { func (w *widgetBase) withCacheDuration(duration time.Duration) *widgetBase {
w.cacheType = cacheTypeDuration w.cacheType = cacheTypeDuration
-303
View File
@@ -1,303 +0,0 @@
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"path"
"strings"
)
// bunch of spaget but it does the job for now
// TODO: tidy up and add a proper build system with CI
const buildPath = "./build"
const archivesPath = "./build/archives"
const executableName = "glance"
const ownerAndRepo = "glanceapp/glance"
const moduleName = "github.com/" + ownerAndRepo
type archiveType int
const (
archiveTypeTarGz archiveType = iota
archiveTypeZip
)
type buildInfo struct {
version string
}
type buildTarget struct {
os string
arch string
armV int
extension string
archive archiveType
}
var buildTargets = []buildTarget{
{
os: "windows",
arch: "amd64",
extension: ".exe",
archive: archiveTypeZip,
},
{
os: "windows",
arch: "arm64",
extension: ".exe",
archive: archiveTypeZip,
},
{
os: "linux",
arch: "amd64",
},
{
os: "linux",
arch: "arm64",
},
{
os: "linux",
arch: "arm",
armV: 6,
},
{
os: "linux",
arch: "arm",
armV: 7,
},
{
os: "openbsd",
arch: "amd64",
},
{
os: "openbsd",
arch: "386",
},
}
func hasUncommitedChanges() (bool, error) {
output, err := exec.Command("git", "status", "--porcelain").CombinedOutput()
if err != nil {
return false, err
}
return len(output) > 0, nil
}
func main() {
flags := flag.NewFlagSet("", flag.ExitOnError)
specificTag := flags.String("tag", "", "Which tagged version to build")
err := flags.Parse(os.Args[1:])
if err != nil {
fmt.Println(err)
os.Exit(1)
}
uncommitedChanges, err := hasUncommitedChanges()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
if uncommitedChanges {
fmt.Println("There are uncommited changes - commit, stash or discard them first")
os.Exit(1)
}
cwd, err := os.Getwd()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
_, err = os.Stat(buildPath)
if err == nil {
fmt.Println("Cleaning up build path")
os.RemoveAll(buildPath)
}
os.Mkdir(buildPath, 0755)
os.Mkdir(archivesPath, 0755)
var version string
if *specificTag == "" {
version, err := getVersionFromGit()
if err != nil {
fmt.Println(version, err)
os.Exit(1)
}
} else {
version = *specificTag
}
output, err := exec.Command("git", "checkout", "tags/"+version).CombinedOutput()
if err != nil {
fmt.Println(string(output))
fmt.Println(err)
os.Exit(1)
}
info := buildInfo{
version: version,
}
for _, target := range buildTargets {
fmt.Printf("Building for %s/%s\n", target.os, target.arch)
if err := build(cwd, info, target); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
versionTag := fmt.Sprintf("%s:%s", ownerAndRepo, version)
latestTag := fmt.Sprintf("%s:latest", ownerAndRepo)
fmt.Println("Building docker image")
var dockerBuildOptions = []string{
"docker", "build",
"--platform=linux/amd64,linux/arm64,linux/arm/v7",
"-t", versionTag,
}
if !strings.Contains(version, "beta") {
dockerBuildOptions = append(dockerBuildOptions, "-t", latestTag)
}
dockerBuildOptions = append(dockerBuildOptions, ".")
output, err = exec.Command("sudo", dockerBuildOptions...).CombinedOutput()
if err != nil {
fmt.Println(string(output))
fmt.Println(err)
os.Exit(1)
}
var input string
fmt.Print("Push docker image? [y/n]: ")
fmt.Scanln(&input)
if input != "y" {
os.Exit(0)
}
output, err = exec.Command(
"sudo", "docker", "push", versionTag,
).CombinedOutput()
if err != nil {
fmt.Printf("Failed pushing %s:\n", versionTag)
fmt.Println(string(output))
fmt.Println(err)
os.Exit(1)
}
if strings.Contains(version, "beta") {
return
}
output, err = exec.Command(
"sudo", "docker", "push", latestTag,
).CombinedOutput()
if err != nil {
fmt.Printf("Failed pushing %s:\n", latestTag)
fmt.Println(string(output))
fmt.Println(err)
os.Exit(1)
}
}
func getVersionFromGit() (string, error) {
output, err := exec.Command("git", "describe", "--tags", "--abbrev=0").CombinedOutput()
if err == nil {
return strings.TrimSpace(string(output)), err
}
return string(output), err
}
func archiveFile(name string, target string, t archiveType) error {
var output []byte
var err error
if t == archiveTypeZip {
output, err = exec.Command("zip", "-j", path.Join(archivesPath, name+".zip"), target).CombinedOutput()
} else if t == archiveTypeTarGz {
output, err = exec.Command("tar", "-C", buildPath, "-czf", path.Join(archivesPath, name+".tar.gz"), name).CombinedOutput()
}
if err != nil {
fmt.Println(string(output))
return err
}
return nil
}
func build(workingDir string, info buildInfo, target buildTarget) error {
var name string
if target.arch != "arm" {
name = fmt.Sprintf("%s-%s-%s%s", executableName, target.os, target.arch, target.extension)
} else {
name = fmt.Sprintf("%s-%s-%sv%d", executableName, target.os, target.arch, target.armV)
}
binaryPath := path.Join(buildPath, name)
glancePackage := moduleName + "/internal/glance"
flags := "-s -w"
flags += fmt.Sprintf(" -X %s.buildVersion=%s", glancePackage, info.version)
cmd := exec.Command(
"go",
"build",
"--trimpath",
"--ldflags",
flags,
"-o",
binaryPath,
)
cmd.Dir = workingDir
env := append(os.Environ(), "GOOS="+target.os, "GOARCH="+target.arch, "CGO_ENABLED=0")
if target.arch == "arm" {
env = append(env, fmt.Sprintf("GOARM=%d", target.armV))
}
cmd.Env = env
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Println(err)
fmt.Println(string(output))
return err
}
os.Chmod(binaryPath, 0755)
fmt.Println("Creating archive")
if err := archiveFile(name, binaryPath, target.archive); err != nil {
return err
}
return nil
}