Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db1ed9e257 | ||
|
|
adef35049f | ||
|
|
bd9abf2e52 | ||
|
|
aa74c00fd4 | ||
|
|
a68a907fa7 | ||
|
|
1a05d6d03a | ||
|
|
60f4183057 | ||
|
|
04c3afc850 | ||
|
|
066e512844 | ||
|
|
d60457afaf | ||
|
|
f5a788220e | ||
|
|
2fd5edc6e4 | ||
|
|
26716d0b89 | ||
|
|
3e0f57d0c7 | ||
|
|
5347717f3d | ||
|
|
6b0a569998 | ||
|
|
4476391287 | ||
|
|
16a9d8c244 | ||
|
|
bbda9a0ee8 | ||
|
|
776fdcc6ce | ||
|
|
f76e06ec57 | ||
|
|
3a9cff697b | ||
|
|
e6979c77e3 | ||
|
|
99866507f5 | ||
|
|
cf0dd07c21 | ||
|
|
b9bf8c6c96 | ||
|
|
725d0da15d | ||
|
|
37164070d2 | ||
|
|
d7bd34531f | ||
|
|
71ca1753ef | ||
|
|
c6bfbf4ac1 | ||
|
|
a27fde72ee | ||
|
|
785f6a36bf | ||
|
|
1a4c12d851 | ||
|
|
b484e32b08 | ||
|
|
303438834b | ||
|
|
70b7b7615a | ||
|
|
5c42e60faf | ||
|
|
48ef60e0eb | ||
|
|
01af97ddab | ||
|
|
038794fa1c | ||
|
|
ffb1bccb10 | ||
|
|
371eb3bee6 | ||
|
|
466d935d02 | ||
|
|
7ffca9c3f1 | ||
|
|
a5981b3106 | ||
|
|
1df080983a | ||
|
|
822b72eee4 | ||
|
|
014abdcc00 | ||
|
|
b35cc437d3 | ||
|
|
7ce87c7168 | ||
|
|
2a3f8eedf6 | ||
|
|
895c3f2f97 | ||
|
|
8860f4591b | ||
|
|
984f26807e | ||
|
|
88c58e6108 | ||
|
|
83c7c4a14a | ||
|
|
b4a4df480e | ||
|
|
9843b4d218 | ||
|
|
c1251af597 | ||
|
|
7d09b8bf1b | ||
|
|
f57bdeec12 | ||
|
|
9899f6b761 | ||
|
|
328e10b89f | ||
|
|
b37f8a8375 | ||
|
|
139937f887 | ||
|
|
b8b90451b6 | ||
|
|
c9a2aff6a6 | ||
|
|
b25b117717 | ||
|
|
e174fd731c | ||
|
|
34e6d463be | ||
|
|
808169f43c | ||
|
|
82b6531a07 | ||
|
|
917e2cabfc | ||
|
|
a981025679 | ||
|
|
f9ea2d73e1 | ||
|
|
a5728e9407 | ||
|
|
404a2acc8f | ||
|
|
7bf61ee8ed | ||
|
|
bf1cc47525 | ||
|
|
74ad27d5fb | ||
|
|
60b73aff24 | ||
|
|
4de465e544 | ||
|
|
e1161b9227 | ||
|
|
c2cdd0fa08 | ||
|
|
b365a03e71 | ||
|
|
2e629efc7f | ||
|
|
fcaa2bef6e | ||
|
|
3b2a9fedef | ||
|
|
64256f2638 | ||
|
|
5c8122d93b | ||
|
|
6ffb95779d | ||
|
|
af975d0e7f | ||
|
|
34b2b88cf3 | ||
|
|
3fea166274 | ||
|
|
233b905492 | ||
|
|
7afad765cb | ||
|
|
39663cb594 | ||
|
|
457e0539fa | ||
|
|
871ba619a3 | ||
|
|
f5427310e8 | ||
|
|
e4435252f4 | ||
|
|
a66aa74c7f | ||
|
|
3949e15f77 | ||
|
|
acaf50bf8a | ||
|
|
09eedc08c1 | ||
|
|
738bcf8bcb | ||
|
|
795caa5d9d | ||
|
|
c041197f3f | ||
|
|
0730789cb3 | ||
|
|
1eb0fbd2c9 | ||
|
|
0484122b7f | ||
|
|
7bc9c704d1 | ||
|
|
c9efdc2c16 | ||
|
|
e0dee998d4 | ||
|
|
e09ea60616 | ||
|
|
7cab07fa01 | ||
|
|
5a62a8afe9 | ||
|
|
23d6d41148 | ||
|
|
a07fbfd7b7 | ||
|
|
1411268e35 | ||
|
|
9bda88edbd | ||
|
|
16da25898f | ||
|
|
88ae2b8e83 | ||
|
|
7d1ede8c91 | ||
|
|
3805f8b156 | ||
|
|
39f40670d0 | ||
|
|
514cf2b81c | ||
|
|
4dbb5975c0 | ||
|
|
b327e59ab1 | ||
|
|
917d51e54b | ||
|
|
122d05346b | ||
|
|
645802981e | ||
|
|
0348a2475c | ||
|
|
a2b7f59dc9 | ||
|
|
c23dc93c7a | ||
|
|
d3e3667405 | ||
|
|
d4ed5afe93 | ||
|
|
790ecef1c1 | ||
|
|
edb59ffdc6 | ||
|
|
e137038586 | ||
|
|
761eb343f9 | ||
|
|
6c15c907bf | ||
|
|
06b21e3d27 | ||
|
|
263d2e6f30 | ||
|
|
c97fec66f4 | ||
|
|
d5cf881d7a | ||
|
|
050d3e6cd2 | ||
|
|
757f68c872 | ||
|
|
b83bf9bc09 | ||
|
|
8d96e883d6 | ||
|
|
75079a981b | ||
|
|
e8043be107 | ||
|
|
c276f66017 | ||
|
|
db7df33b27 | ||
|
|
1b8e355944 | ||
|
|
342ef90cbe | ||
|
|
b3be60acf6 | ||
|
|
b58d3e2849 | ||
|
|
4206fb943d | ||
|
|
ee58ff40f1 | ||
|
|
21fd842bde | ||
|
|
b63b5eb262 | ||
|
|
9c69509a19 | ||
|
|
511147fb90 | ||
|
|
9ce0030482 | ||
|
|
4164263639 | ||
|
|
1bebb88d0e | ||
|
|
d2f5dbbc26 | ||
|
|
2af6500028 | ||
|
|
a7cf8be8c4 | ||
|
|
69b3e544bd | ||
|
|
0c63c61db5 | ||
|
|
8dc34ddfa7 | ||
|
|
8efda0047c | ||
|
|
e3c17e0347 | ||
|
|
038c43bc09 | ||
|
|
720549ec7e | ||
|
|
3aa1f273b6 | ||
|
|
5105ebb870 | ||
|
|
0a55c12f4f | ||
|
|
67c20c3a5f | ||
|
|
f4f7892123 | ||
|
|
00a93e466d | ||
|
|
d27993d2c9 | ||
|
|
bfc65d51e3 | ||
|
|
3c29d38599 | ||
|
|
f8816ff227 | ||
|
|
a5be460c2e | ||
|
|
e1e7853c34 | ||
|
|
3f862f67ab | ||
|
|
4bb5b2641b | ||
|
|
f044fdf5dc | ||
|
|
d1be4637ad | ||
|
|
d18f645c18 | ||
|
|
6663360caa | ||
|
|
c2d24ee4d9 | ||
|
|
37471fcdc1 | ||
|
|
0f42567d98 | ||
|
|
8148f09b9c | ||
|
|
c270afb5e5 | ||
|
|
6de0f73ec1 | ||
|
|
a80a34afb1 | ||
|
|
584f813fc4 | ||
|
|
a44056dc43 | ||
|
|
c941c78ebf | ||
|
|
9e9f001fdf | ||
|
|
9060887694 | ||
|
|
6d9ea27e9f | ||
|
|
4f4973157c | ||
|
|
859a6d2f27 | ||
|
|
e3ad646bae | ||
|
|
53a661861a | ||
|
|
4a27ec3271 | ||
|
|
926de71274 | ||
|
|
b0e57bae9f | ||
|
|
0bbb4573b2 | ||
|
|
eeda28a287 | ||
|
|
59c55b96be | ||
|
|
060609e2cb | ||
|
|
a1c48bf012 | ||
|
|
a67fb66d2e | ||
|
|
3001f4845a | ||
|
|
192c1ad669 | ||
|
|
89ae8a3607 | ||
|
|
90d718c186 | ||
|
|
3cfbe0c89b | ||
|
|
8392b7a606 | ||
|
|
0f01a8eda5 | ||
|
|
6cad5a8efb | ||
|
|
c07a1ab636 | ||
|
|
eb9f92f04a | ||
|
|
c05607934b | ||
|
|
344f518991 | ||
|
|
aa04904a72 | ||
|
|
7adf624e95 |
@@ -0,0 +1,10 @@
|
|||||||
|
# https://docs.docker.com/build/building/context/#dockerignore-files
|
||||||
|
# Ignore all files by default
|
||||||
|
*
|
||||||
|
|
||||||
|
# Only add necessary files to the Docker build context (Dockerfiles are always included implicitly)
|
||||||
|
!/build/
|
||||||
|
!/internal/
|
||||||
|
!/go.mod
|
||||||
|
!/go.sum
|
||||||
|
!main.go
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
github: [glanceapp]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<!--
|
||||||
|
|
||||||
|
If your pull request adds new features or changes existing ones please use the latest release/* branch as the base.
|
||||||
|
|
||||||
|
Documentation updates (including new themes) can be submitted to the main branch.
|
||||||
|
|
||||||
|
-->
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
/assets
|
/assets
|
||||||
/build
|
/build
|
||||||
/playground
|
/playground
|
||||||
glance.yml
|
glance*.yml
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
FROM alpine:3.19
|
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"]
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
FROM alpine:3.19
|
FROM alpine:3.20
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY build/glance /app/glance
|
COPY glance .
|
||||||
|
|
||||||
EXPOSE 8080/tcp
|
EXPOSE 8080/tcp
|
||||||
|
|
||||||
ENTRYPOINT ["/app/glance"]
|
ENTRYPOINT ["/app/glance"]
|
||||||
@@ -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>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -10,7 +10,10 @@
|
|||||||
* Subreddit posts
|
* Subreddit posts
|
||||||
* Weather
|
* Weather
|
||||||
* Bookmarks
|
* Bookmarks
|
||||||
|
* Hacker News
|
||||||
|
* Lobsters
|
||||||
* Latest YouTube videos from specific channels
|
* Latest YouTube videos from specific channels
|
||||||
|
* Clock
|
||||||
* Calendar
|
* Calendar
|
||||||
* Stocks
|
* Stocks
|
||||||
* iframe
|
* iframe
|
||||||
@@ -18,6 +21,7 @@
|
|||||||
* GitHub releases
|
* GitHub releases
|
||||||
* Repository overview
|
* Repository overview
|
||||||
* Site monitor
|
* Site monitor
|
||||||
|
* Search box
|
||||||
|
|
||||||
#### Themeable
|
#### Themeable
|
||||||

|

|
||||||
@@ -92,18 +96,12 @@ go run .
|
|||||||
|
|
||||||
### Building Docker image
|
### Building Docker image
|
||||||
|
|
||||||
Build Glance with CGO disabled:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CGO_ENABLED=0 go build -o build/glance .
|
|
||||||
```
|
|
||||||
|
|
||||||
Build the image:
|
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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -10,17 +11,25 @@
|
|||||||
- [RSS](#rss)
|
- [RSS](#rss)
|
||||||
- [Videos](#videos)
|
- [Videos](#videos)
|
||||||
- [Hacker News](#hacker-news)
|
- [Hacker News](#hacker-news)
|
||||||
|
- [Lobsters](#lobsters)
|
||||||
- [Reddit](#reddit)
|
- [Reddit](#reddit)
|
||||||
|
- [Search](#search-widget)
|
||||||
|
- [Group](#group)
|
||||||
|
- [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)
|
||||||
- [Stocks](#stocks)
|
- [ChangeDetection.io](#changedetectionio)
|
||||||
|
- [Clock](#clock)
|
||||||
|
- [Markets](#markets)
|
||||||
- [Twitch Channels](#twitch-channels)
|
- [Twitch Channels](#twitch-channels)
|
||||||
- [Twitch Top Games](#twitch-top-games)
|
- [Twitch Top Games](#twitch-top-games)
|
||||||
- [iframe](#iframe)
|
- [iframe](#iframe)
|
||||||
|
- [HTML](#html)
|
||||||
|
|
||||||
## Intro
|
## Intro
|
||||||
Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error.
|
Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error.
|
||||||
@@ -76,8 +85,8 @@ pages:
|
|||||||
- type: weather
|
- type: weather
|
||||||
location: London, United Kingdom
|
location: London, United Kingdom
|
||||||
|
|
||||||
- type: stocks
|
- type: markets
|
||||||
stocks:
|
markets:
|
||||||
- symbol: SPY
|
- symbol: SPY
|
||||||
name: S&P 500
|
name: S&P 500
|
||||||
- symbol: BTC-USD
|
- symbol: BTC-USD
|
||||||
@@ -117,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`
|
||||||
@@ -125,6 +135,9 @@ 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.
|
||||||
|
|
||||||
#### `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.
|
||||||
|
|
||||||
@@ -162,6 +175,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.
|
||||||
|
|
||||||
@@ -228,6 +277,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
|
||||||
@@ -255,6 +306,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 | |
|
||||||
|
|
||||||
@@ -264,6 +318,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.
|
||||||
|
|
||||||
@@ -348,7 +419,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.
|
||||||
@@ -356,6 +429,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:
|
||||||
|
|
||||||
@@ -370,6 +446,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.
|
||||||
|
|
||||||
@@ -396,15 +475,27 @@ 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`
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
`detailed-list`
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
`horizontal-cards`
|
`horizontal-cards`
|
||||||
|
|
||||||

|

|
||||||
@@ -423,14 +514,23 @@ Used to modify the height of cards when using the `horizontal-cards-2` style. Th
|
|||||||
An array of RSS/atom feeds. The title can optionally be changed.
|
An array of RSS/atom feeds. The title can optionally be changed.
|
||||||
|
|
||||||
###### Properties for each feed
|
###### Properties for each feed
|
||||||
| Name | Type | Required | Default |
|
| Name | Type | Required | Default | Notes |
|
||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- | ----- |
|
||||||
| url | string | yes | |
|
| url | string | yes | | |
|
||||||
| title | string | no | the title provided by the feed |
|
| title | string | no | the title provided by the feed | |
|
||||||
|
| hide-categories | boolean | no | false | Only applicable for `detailed-list` style |
|
||||||
|
| hide-description | boolean | no | false | Only applicable for `detailed-list` style |
|
||||||
|
| item-link-prefix | string | no | | |
|
||||||
|
|
||||||
|
###### `item-link-prefix`
|
||||||
|
If an RSS feed isn't returning item links with a base domain and Glance has failed to automatically detect the correct domain you can manually add a prefix to each link with this property.
|
||||||
|
|
||||||
##### `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.
|
||||||
|
|
||||||
@@ -456,6 +556,8 @@ Preview:
|
|||||||
| channels | array | yes | |
|
| channels | array | yes | |
|
||||||
| limit | integer | no | 25 |
|
| limit | integer | no | 25 |
|
||||||
| style | string | no | horizontal-cards |
|
| style | string | no | horizontal-cards |
|
||||||
|
| 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`
|
||||||
@@ -470,6 +572,9 @@ Then scroll down and click on "Share channel", then "Copy channel ID":
|
|||||||
##### `limit`
|
##### `limit`
|
||||||
The maximum number of videos to show.
|
The maximum number of videos to show.
|
||||||
|
|
||||||
|
##### `collapse-after-rows`
|
||||||
|
Specify the number of rows to show when using the `grid-cards` style before the "SHOW MORE" button appears.
|
||||||
|
|
||||||
##### `style`
|
##### `style`
|
||||||
Used to change the appearance of the widget. Possible values are `horizontal-cards` and `grid-cards`.
|
Used to change the appearance of the widget. Possible values are `horizontal-cards` and `grid-cards`.
|
||||||
|
|
||||||
@@ -530,6 +635,57 @@ Can be used to specify an additional sort which will be applied on top of the al
|
|||||||
|
|
||||||
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
|
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
|
||||||
|
|
||||||
|
### Lobsters
|
||||||
|
Display a list of posts from [Lobsters](https://lobste.rs).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: lobsters
|
||||||
|
sort-by: hot
|
||||||
|
tags:
|
||||||
|
- go
|
||||||
|
- security
|
||||||
|
- linux
|
||||||
|
limit: 15
|
||||||
|
collapse-after: 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview:
|
||||||
|

|
||||||
|
|
||||||
|
#### Properties
|
||||||
|
| Name | Type | Required | Default |
|
||||||
|
| ---- | ---- | -------- | ------- |
|
||||||
|
| instance-url | string | no | https://lobste.rs/ |
|
||||||
|
| custom-url | string | no | |
|
||||||
|
| limit | integer | no | 15 |
|
||||||
|
| collapse-after | integer | no | 5 |
|
||||||
|
| sort-by | string | no | hot |
|
||||||
|
| 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`
|
||||||
|
The maximum number of posts to show.
|
||||||
|
|
||||||
|
##### `collapse-after`
|
||||||
|
How many posts are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
|
||||||
|
|
||||||
|
##### `sort-by`
|
||||||
|
The sort order in which posts are returned. Possible options are `hot` and `new`.
|
||||||
|
|
||||||
|
##### `tags`
|
||||||
|
Limit to posts containing one of the given tags. **You cannot specify a sort order when filtering by tags, it will default to `hot`.**
|
||||||
|
|
||||||
### Reddit
|
### Reddit
|
||||||
Display a list of posts from a specific subreddit.
|
Display a list of posts from a specific subreddit.
|
||||||
|
|
||||||
@@ -550,6 +706,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} |
|
||||||
@@ -586,6 +743,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.
|
||||||
|
|
||||||
@@ -626,7 +786,7 @@ https://your.proxy/?url={REQUEST-URL}
|
|||||||
##### `sort-by`
|
##### `sort-by`
|
||||||
Can be used to specify the order in which the posts should get returned. Possible values are `hot`, `new`, `top` and `rising`.
|
Can be used to specify the order in which the posts should get returned. Possible values are `hot`, `new`, `top` and `rising`.
|
||||||
|
|
||||||
##### `top-perid`
|
##### `top-period`
|
||||||
Available only when `sort-by` is set to `top`. Possible values are `hour`, `day`, `week`, `month`, `year` and `all`.
|
Available only when `sort-by` is set to `top`. Possible values are `hour`, `day`, `week`, `month`, `year` and `all`.
|
||||||
|
|
||||||
##### `search`
|
##### `search`
|
||||||
@@ -639,6 +799,167 @@ Can be used to specify an additional sort which will be applied on top of the al
|
|||||||
|
|
||||||
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
|
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
|
||||||
|
|
||||||
|
### Search Widget
|
||||||
|
Display a search bar that can be used to search for specific terms on various search engines.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: search
|
||||||
|
search-engine: duckduckgo
|
||||||
|
bangs:
|
||||||
|
- title: YouTube
|
||||||
|
shortcut: "!yt"
|
||||||
|
url: https://www.youtube.com/results?search_query={QUERY}
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Keyboard shortcuts
|
||||||
|
| Keys | Action | Condition |
|
||||||
|
| ---- | ------ | --------- |
|
||||||
|
| <kbd>S</kbd> | Focus the search bar | Not already focused on another input field |
|
||||||
|
| <kbd>Enter</kbd> | Perform search in the same 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 |
|
||||||
|
|
||||||
|
> [!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
|
||||||
|
| Name | Type | Required | Default |
|
||||||
|
| ---- | ---- | -------- | ------- |
|
||||||
|
| search-engine | string | no | duckduckgo |
|
||||||
|
| new-tab | boolean | no | false |
|
||||||
|
| autofocus | boolean | no | false |
|
||||||
|
| bangs | array | no | |
|
||||||
|
|
||||||
|
##### `search-engine`
|
||||||
|
Either a value from the table below or a URL to a custom search engine. Use `{QUERY}` to indicate where the query value gets placed.
|
||||||
|
|
||||||
|
| Name | URL |
|
||||||
|
| ---- | --- |
|
||||||
|
| duckduckgo | `https://duckduckgo.com/?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`
|
||||||
|
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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
##### Properties for each bang
|
||||||
|
| Name | Type | Required |
|
||||||
|
| ---- | ---- | -------- |
|
||||||
|
| title | string | no |
|
||||||
|
| shortcut | string | yes |
|
||||||
|
| url | string | yes |
|
||||||
|
|
||||||
|
###### `title`
|
||||||
|
Optional title that will appear on the right side of the search bar when the query starts with the associated shortcut.
|
||||||
|
|
||||||
|
###### `shortcut`
|
||||||
|
Any value you wish to use as the shortcut for the search engine. It does not have to start with `!`.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
>
|
||||||
|
> In YAML some characters have special meaning when placed in the beginning of a value. If your shortcut starts with `!` (and potentially some other special characters) you'll have to wrap the value in quotes:
|
||||||
|
> ```yaml
|
||||||
|
> shortcut: "!yt"
|
||||||
|
>```
|
||||||
|
|
||||||
|
###### `url`
|
||||||
|
The URL of the search engine. Use `{QUERY}` to indicate where the query value gets placed. Examples:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: https://www.reddit.com/search?q={QUERY}
|
||||||
|
url: https://store.steampowered.com/search/?term={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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 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
|
||||||
|
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).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: extension
|
||||||
|
url: https://domain.com/widget/display-a-message
|
||||||
|
allow-potentially-dangerous-html: true
|
||||||
|
parameters:
|
||||||
|
message: Hello, world!
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Properties
|
||||||
|
| Name | Type | Required | Default |
|
||||||
|
| ---- | ---- | -------- | ------- |
|
||||||
|
| url | string | yes | |
|
||||||
|
| allow-potentially-dangerous-html | boolean | no | false |
|
||||||
|
| parameters | key & value | no | |
|
||||||
|
|
||||||
|
##### `url`
|
||||||
|
The URL of the extension.
|
||||||
|
|
||||||
|
##### `allow-potentially-dangerous-html`
|
||||||
|
Whether to allow the extension to display HTML.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
>
|
||||||
|
> There's a reason this property is scary-sounding. It's intended to be used by developers who are comfortable with developing and using their own extensions. Do not enable it if you have no idea what it means or if you're not **absolutely sure** that the extension URL you're using is safe.
|
||||||
|
|
||||||
|
##### `parameters`
|
||||||
|
A list of keys and values that will be sent to the extension as query paramters.
|
||||||
|
|
||||||
### Weather
|
### Weather
|
||||||
Display weather information for a specific location. The data is provided by https://open-meteo.com/.
|
Display weather information for a specific location. The data is provided by https://open-meteo.com/.
|
||||||
|
|
||||||
@@ -702,7 +1023,7 @@ Greenville, United States
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Monitor
|
### Monitor
|
||||||
Display a list of sites and whether they are reachable (online) or not. This is determined by sending a HEAD request to the specified URL, if the response is 200 then the site is OK. The time it took to receive a response is also shown in milliseconds.
|
Display a list of sites and whether they are reachable (online) or not. This is determined by sending a GET request to the specified URL, if the response is 200 then the site is OK. The time it took to receive a response is also shown in milliseconds.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -737,13 +1058,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`
|
||||||
|
|
||||||
@@ -753,7 +1074,9 @@ 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 |
|
||||||
| same-tab | boolean | no | false |
|
| same-tab | boolean | no | false |
|
||||||
|
|
||||||
`title`
|
`title`
|
||||||
@@ -762,28 +1085,49 @@ 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`
|
||||||
|
|
||||||
Optional URL to an image which will be used as the icon for the site. Can be an external URL or internal via [server configured assets](#assets-path).
|
Optional URL to an image which will be used as the icon for the site. Can be an external URL or internal via [server configured assets](#assets-path). You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
icon: si:jellyfin
|
||||||
|
icon: si:gitea
|
||||||
|
icon: si:adguard
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
>
|
||||||
|
> Simple Icons are loaded externally and are hosted on `cdnjs.cloudflare.com`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
|
||||||
|
|
||||||
|
`allow-insecure`
|
||||||
|
|
||||||
|
Whether to ignore invalid/self-signed certificates.
|
||||||
|
|
||||||
`same-tab`
|
`same-tab`
|
||||||
|
|
||||||
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:
|
||||||
@@ -795,12 +1139,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.
|
||||||
@@ -825,12 +1199,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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!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.
|
||||||
|
|
||||||
@@ -841,6 +1268,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:
|
||||||
@@ -855,6 +1283,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.
|
||||||
@@ -868,6 +1297,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.
|
||||||
|
|
||||||
@@ -917,14 +1349,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 |
|
||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
@@ -963,6 +1391,98 @@ Whether to open the link in the same tab or a new one.
|
|||||||
|
|
||||||
Whether to hide the colored arrow on each link.
|
Whether to hide the colored arrow on each link.
|
||||||
|
|
||||||
|
### ChangeDetection.io
|
||||||
|
Display a list watches from changedetection.io.
|
||||||
|
|
||||||
|
Example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: change-detection
|
||||||
|
instance-url: https://changedetection.mydomain.com/
|
||||||
|
token: ${CHANGE_DETECTION_TOKEN}
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Properties
|
||||||
|
|
||||||
|
| Name | Type | Required | Default |
|
||||||
|
| ---- | ---- | -------- | ------- |
|
||||||
|
| instance-url | string | no | `https://www.changedetection.io` |
|
||||||
|
| token | string | no | |
|
||||||
|
| limit | integer | no | 10 |
|
||||||
|
| collapse-after | integer | no | 5 |
|
||||||
|
| watches | array of strings | no | |
|
||||||
|
|
||||||
|
##### `instance-url`
|
||||||
|
The URL pointing to your instance of `changedetection.io`.
|
||||||
|
|
||||||
|
##### `token`
|
||||||
|
The API access token which can be found in `SETTINGS > API`. Optionally, you can specify this using an environment variable with the syntax `${VARIABLE_NAME}`.
|
||||||
|
|
||||||
|
##### `limit`
|
||||||
|
The maximum number of watches to show.
|
||||||
|
|
||||||
|
##### `collapse-after`
|
||||||
|
How many watches are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
|
||||||
|
|
||||||
|
##### `watches`
|
||||||
|
By default all of the configured watches will be shown. Optionally, you can specify a list of UUIDs for the specific watches you want to have listed:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: change-detection
|
||||||
|
watches:
|
||||||
|
- 1abca041-6d4f-4554-aa19-809147f538d3
|
||||||
|
- 705ed3e4-ea86-4d25-a064-822a6425be2c
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clock
|
||||||
|
Display a clock showing the current time and date. Optionally, also display the the time in other timezones.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: clock
|
||||||
|
hour-format: 24h
|
||||||
|
timezones:
|
||||||
|
- timezone: Europe/Paris
|
||||||
|
label: Paris
|
||||||
|
- timezone: America/New_York
|
||||||
|
label: New York
|
||||||
|
- timezone: Asia/Tokyo
|
||||||
|
label: Tokyo
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Properties
|
||||||
|
|
||||||
|
| Name | Type | Required | Default |
|
||||||
|
| ---- | ---- | -------- | ------- |
|
||||||
|
| hour-format | string | no | 24h |
|
||||||
|
| timezones | array | no | |
|
||||||
|
|
||||||
|
##### `hour-format`
|
||||||
|
Whether to show the time in 12 or 24 hour format. Possible values are `12h` and `24h`.
|
||||||
|
|
||||||
|
#### Properties for each timezone
|
||||||
|
|
||||||
|
| Name | Type | Required | Default |
|
||||||
|
| ---- | ---- | -------- | ------- |
|
||||||
|
| timezone | string | yes | |
|
||||||
|
| label | string | no | |
|
||||||
|
|
||||||
|
##### `timezone`
|
||||||
|
A timezone identifier such as `Europe/London`, `America/New_York`, etc. The full list of available identifiers can be found [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
|
||||||
|
|
||||||
|
##### `label`
|
||||||
|
Optionally, override the display value for the timezone to something more meaningful such as "Home", "Work" or anything else.
|
||||||
|
|
||||||
|
|
||||||
### Calendar
|
### Calendar
|
||||||
Display a calendar.
|
Display a calendar.
|
||||||
|
|
||||||
@@ -980,14 +1500,14 @@ Preview:
|
|||||||
>
|
>
|
||||||
> There is currently no customizability available for the calendar. Extra features will be added in the future.
|
> There is currently no customizability available for the calendar. Extra features will be added in the future.
|
||||||
|
|
||||||
### Stocks
|
### Markets
|
||||||
Display a list of stocks, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance.
|
Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- type: stocks
|
- type: markets
|
||||||
stocks:
|
markets:
|
||||||
- symbol: SPY
|
- symbol: SPY
|
||||||
name: S&P 500
|
name: S&P 500
|
||||||
- symbol: BTC-USD
|
- symbol: BTC-USD
|
||||||
@@ -1002,24 +1522,20 @@ Example:
|
|||||||
|
|
||||||
Preview:
|
Preview:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
#### Properties
|
#### Properties
|
||||||
|
|
||||||
| Name | Type | Required |
|
| Name | Type | Required |
|
||||||
| ---- | ---- | -------- |
|
| ---- | ---- | -------- |
|
||||||
| stocks | array | yes |
|
| markets | array | yes |
|
||||||
| sort-by | string | no |
|
| sort-by | string | no |
|
||||||
| style | string | no |
|
|
||||||
|
|
||||||
##### `stocks`
|
##### `markets`
|
||||||
An array of stocks for which to display information about.
|
An array of markets for which to display information about.
|
||||||
|
|
||||||
##### `sort-by`
|
##### `sort-by`
|
||||||
By default the stocks 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 |
|
||||||
@@ -1141,3 +1657,16 @@ The source of the iframe.
|
|||||||
|
|
||||||
##### `height`
|
##### `height`
|
||||||
The height of the iframe. The minimum allowed height is 50.
|
The height of the iframe. The minimum allowed height is 50.
|
||||||
|
|
||||||
|
### HTML
|
||||||
|
Embed any HTML.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: html
|
||||||
|
source: |
|
||||||
|
<p>Hello, <span class="color-primary">World</span>!</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the use of `|` after `source:`, this allows you to insert a multi-line string.
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Extensions
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
>
|
||||||
|
> **This document as well as the extensions feature are a work in progress. The API may change in the future. You are responsible for maintaining your own extensions.**
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
With the intention of requiring minimal knowledge in order to develop extensions, rather than being a convoluted protocol they are nothing more than an HTTP request to a server that returns a few special headers. The exchange between Glance and extensions can be seen in the following diagram:
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
**Class names or features may change, once again, you are responsible for maintaining your own extensions.**
|
||||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 946 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 637 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 370 KiB After Width: | Height: | Size: 482 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 549 KiB |
@@ -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
|
||||||
|
|
||||||
|

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

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

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

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

|

|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
module github.com/glanceapp/glance
|
module github.com/glanceapp/glance
|
||||||
|
|
||||||
go 1.22.0
|
go 1.22.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/mmcdole/gofeed v1.3.0
|
github.com/mmcdole/gofeed v1.3.0
|
||||||
golang.org/x/text v0.14.0
|
golang.org/x/text v0.16.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.9.1 // indirect
|
github.com/PuerkitoBio/goquery v1.9.2 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/mmcdole/goxpp v1.1.1 // indirect
|
github.com/mmcdole/goxpp v1.1.1 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
golang.org/x/net v0.24.0 // indirect
|
golang.org/x/net v0.27.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -0,0 +1,620 @@
|
|||||||
|
import { setupPopovers } from './popover.js';
|
||||||
|
|
||||||
|
function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
||||||
|
let debounceTimeout;
|
||||||
|
let timesDebounced = 0;
|
||||||
|
|
||||||
|
return function () {
|
||||||
|
if (timesDebounced == maxDebounceTimes) {
|
||||||
|
clearTimeout(debounceTimeout);
|
||||||
|
timesDebounced = 0;
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(debounceTimeout);
|
||||||
|
timesDebounced++;
|
||||||
|
|
||||||
|
debounceTimeout = setTimeout(() => {
|
||||||
|
timesDebounced = 0;
|
||||||
|
callback();
|
||||||
|
}, debounceDelay);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
async function fetchPageContent(pageData) {
|
||||||
|
// TODO: handle non 200 status codes/time outs
|
||||||
|
// TODO: add retries
|
||||||
|
const response = await fetch(`${pageData.baseURL}/api/pages/${pageData.slug}/content/`);
|
||||||
|
const content = await response.text();
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCarousels() {
|
||||||
|
const carouselElements = document.getElementsByClassName("carousel-container");
|
||||||
|
|
||||||
|
if (carouselElements.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < carouselElements.length; i++) {
|
||||||
|
const carousel = carouselElements[i];
|
||||||
|
carousel.classList.add("show-right-cutoff");
|
||||||
|
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
|
||||||
|
|
||||||
|
const determineSideCutoffs = () => {
|
||||||
|
if (itemsContainer.scrollLeft != 0) {
|
||||||
|
carousel.classList.add("show-left-cutoff");
|
||||||
|
} else {
|
||||||
|
carousel.classList.remove("show-left-cutoff");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.ceil(itemsContainer.scrollLeft) + itemsContainer.clientWidth < itemsContainer.scrollWidth) {
|
||||||
|
carousel.classList.add("show-right-cutoff");
|
||||||
|
} else {
|
||||||
|
carousel.classList.remove("show-right-cutoff");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
|
||||||
|
|
||||||
|
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
|
||||||
|
window.addEventListener("resize", determineSideCutoffsRateLimited);
|
||||||
|
|
||||||
|
afterContentReady(determineSideCutoffs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minuteInSeconds = 60;
|
||||||
|
const hourInSeconds = minuteInSeconds * 60;
|
||||||
|
const dayInSeconds = hourInSeconds * 24;
|
||||||
|
const monthInSeconds = dayInSeconds * 30;
|
||||||
|
const yearInSeconds = monthInSeconds * 12;
|
||||||
|
|
||||||
|
function relativeTimeSince(timestamp) {
|
||||||
|
const delta = Math.round((Date.now() / 1000) - timestamp);
|
||||||
|
|
||||||
|
if (delta < minuteInSeconds) {
|
||||||
|
return "1m";
|
||||||
|
}
|
||||||
|
if (delta < hourInSeconds) {
|
||||||
|
return Math.floor(delta / minuteInSeconds) + "m";
|
||||||
|
}
|
||||||
|
if (delta < dayInSeconds) {
|
||||||
|
return Math.floor(delta / hourInSeconds) + "h";
|
||||||
|
}
|
||||||
|
if (delta < monthInSeconds) {
|
||||||
|
return Math.floor(delta / dayInSeconds) + "d";
|
||||||
|
}
|
||||||
|
if (delta < yearInSeconds) {
|
||||||
|
return Math.floor(delta / monthInSeconds) + "mo";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.floor(delta / yearInSeconds) + "y";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRelativeTimeForElements(elements)
|
||||||
|
{
|
||||||
|
for (let i = 0; i < elements.length; i++)
|
||||||
|
{
|
||||||
|
const element = elements[i];
|
||||||
|
const timestamp = element.dataset.dynamicRelativeTime;
|
||||||
|
|
||||||
|
if (timestamp === undefined)
|
||||||
|
continue
|
||||||
|
|
||||||
|
element.textContent = relativeTimeSince(timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSearchBoxes() {
|
||||||
|
const searchWidgets = document.getElementsByClassName("search");
|
||||||
|
|
||||||
|
if (searchWidgets.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < searchWidgets.length; i++) {
|
||||||
|
const widget = searchWidgets[i];
|
||||||
|
const defaultSearchUrl = widget.dataset.defaultSearchUrl;
|
||||||
|
const newTab = widget.dataset.newTab === "true";
|
||||||
|
const inputElement = widget.getElementsByClassName("search-input")[0];
|
||||||
|
const bangElement = widget.getElementsByClassName("search-bang")[0];
|
||||||
|
const bangs = widget.querySelectorAll(".search-bangs > input");
|
||||||
|
const bangsMap = {};
|
||||||
|
const kbdElement = widget.getElementsByTagName("kbd")[0];
|
||||||
|
let currentBang = null;
|
||||||
|
|
||||||
|
for (let j = 0; j < bangs.length; j++) {
|
||||||
|
const bang = bangs[j];
|
||||||
|
bangsMap[bang.dataset.shortcut] = bang;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (event.key == "Escape") {
|
||||||
|
inputElement.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key == "Enter") {
|
||||||
|
const input = inputElement.value.trim();
|
||||||
|
let query;
|
||||||
|
let searchUrlTemplate;
|
||||||
|
|
||||||
|
if (currentBang != null) {
|
||||||
|
query = input.slice(currentBang.dataset.shortcut.length + 1);
|
||||||
|
searchUrlTemplate = currentBang.dataset.url;
|
||||||
|
} else {
|
||||||
|
query = input;
|
||||||
|
searchUrlTemplate = defaultSearchUrl;
|
||||||
|
}
|
||||||
|
if (query.length == 0 && currentBang == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
|
||||||
|
|
||||||
|
if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
|
||||||
|
window.open(url, '_blank').focus();
|
||||||
|
} else {
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeCurrentBang = (bang) => {
|
||||||
|
currentBang = bang;
|
||||||
|
bangElement.textContent = bang != null ? bang.dataset.title : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInput = (event) => {
|
||||||
|
const value = event.target.value.trim();
|
||||||
|
if (value in bangsMap) {
|
||||||
|
changeCurrentBang(bangsMap[value]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = value.split(" ");
|
||||||
|
if (words.length >= 2 && words[0] in bangsMap) {
|
||||||
|
changeCurrentBang(bangsMap[words[0]]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeCurrentBang(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
inputElement.addEventListener("focus", () => {
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
document.addEventListener("input", handleInput);
|
||||||
|
});
|
||||||
|
inputElement.addEventListener("blur", () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
document.removeEventListener("input", handleInput);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
|
||||||
|
if (event.key != "s") return;
|
||||||
|
|
||||||
|
inputElement.focus();
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
kbdElement.addEventListener("mousedown", () => {
|
||||||
|
requestAnimationFrame(() => inputElement.focus());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDynamicRelativeTime() {
|
||||||
|
const elements = document.querySelectorAll("[data-dynamic-relative-time]");
|
||||||
|
const updateInterval = 60 * 1000;
|
||||||
|
let lastUpdateTime = Date.now();
|
||||||
|
|
||||||
|
updateRelativeTimeForElements(elements);
|
||||||
|
|
||||||
|
const updateElementsAndTimestamp = () => {
|
||||||
|
updateRelativeTimeForElements(elements);
|
||||||
|
lastUpdateTime = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRepeatingUpdate = () => setInterval(updateElementsAndTimestamp, updateInterval);
|
||||||
|
|
||||||
|
if (document.hidden === undefined) {
|
||||||
|
scheduleRepeatingUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout = scheduleRepeatingUpdate();
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = Date.now() - lastUpdateTime;
|
||||||
|
|
||||||
|
if (delta >= updateInterval) {
|
||||||
|
updateElementsAndTimestamp();
|
||||||
|
timeout = scheduleRepeatingUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
updateElementsAndTimestamp();
|
||||||
|
timeout = scheduleRepeatingUpdate();
|
||||||
|
}, updateInterval - delta);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupGroups() {
|
||||||
|
const groups = document.getElementsByClassName("widget-type-group");
|
||||||
|
|
||||||
|
if (groups.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let g = 0; g < groups.length; g++) {
|
||||||
|
const group = groups[g];
|
||||||
|
const titles = group.getElementsByClassName("widget-header")[0].children;
|
||||||
|
const tabs = group.getElementsByClassName("widget-group-contents")[0].children;
|
||||||
|
let current = 0;
|
||||||
|
|
||||||
|
for (let t = 0; t < titles.length; t++) {
|
||||||
|
const title = titles[t];
|
||||||
|
title.addEventListener("click", () => {
|
||||||
|
if (t == current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < titles.length; i++) {
|
||||||
|
titles[i].classList.remove("widget-group-title-current");
|
||||||
|
tabs[i].classList.remove("widget-group-content-current");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current < t) {
|
||||||
|
tabs[t].dataset.direction = "right";
|
||||||
|
} else {
|
||||||
|
tabs[t].dataset.direction = "left";
|
||||||
|
}
|
||||||
|
|
||||||
|
current = t;
|
||||||
|
|
||||||
|
title.classList.add("widget-group-title-current");
|
||||||
|
tabs[t].classList.add("widget-group-content-current");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupLazyImages() {
|
||||||
|
const images = document.querySelectorAll("img[loading=lazy]");
|
||||||
|
|
||||||
|
if (images.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageFinishedTransition(image) {
|
||||||
|
image.classList.add("finished-transition");
|
||||||
|
}
|
||||||
|
|
||||||
|
afterContentReady(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
const image = images[i];
|
||||||
|
|
||||||
|
if (image.complete) {
|
||||||
|
image.classList.add("cached");
|
||||||
|
setTimeout(() => imageFinishedTransition(image), 1);
|
||||||
|
} else {
|
||||||
|
// TODO: also handle error event
|
||||||
|
image.addEventListener("load", () => {
|
||||||
|
image.classList.add("loaded");
|
||||||
|
setTimeout(() => imageFinishedTransition(image), 400);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachExpandToggleButton(collapsibleContainer) {
|
||||||
|
const showMoreText = "Show more";
|
||||||
|
const showLessText = "Show less";
|
||||||
|
|
||||||
|
let expanded = false;
|
||||||
|
const button = document.createElement("button");
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.classList.add("expand-toggle-button-icon");
|
||||||
|
const textNode = document.createTextNode(showMoreText);
|
||||||
|
button.classList.add("expand-toggle-button");
|
||||||
|
button.append(textNode, icon);
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
expanded = !expanded;
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
collapsibleContainer.classList.add("container-expanded");
|
||||||
|
button.classList.add("container-expanded");
|
||||||
|
textNode.nodeValue = showLessText;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const topBefore = button.getClientRects()[0].top;
|
||||||
|
|
||||||
|
collapsibleContainer.classList.remove("container-expanded");
|
||||||
|
button.classList.remove("container-expanded");
|
||||||
|
textNode.nodeValue = showMoreText;
|
||||||
|
|
||||||
|
const topAfter = button.getClientRects()[0].top;
|
||||||
|
|
||||||
|
if (topAfter > 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
window.scrollBy({
|
||||||
|
top: topAfter - topBefore,
|
||||||
|
behavior: "instant"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
collapsibleContainer.after(button);
|
||||||
|
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function setupCollapsibleLists() {
|
||||||
|
const collapsibleLists = document.querySelectorAll(".list.collapsible-container");
|
||||||
|
|
||||||
|
if (collapsibleLists.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < collapsibleLists.length; i++) {
|
||||||
|
const list = collapsibleLists[i];
|
||||||
|
|
||||||
|
if (list.dataset.collapseAfter === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapseAfter = parseInt(list.dataset.collapseAfter);
|
||||||
|
|
||||||
|
if (collapseAfter == -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.children.length <= collapseAfter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachExpandToggleButton(list);
|
||||||
|
|
||||||
|
for (let c = collapseAfter; c < list.children.length; c++) {
|
||||||
|
const child = list.children[c];
|
||||||
|
child.classList.add("collapsible-item");
|
||||||
|
child.style.animationDelay = ((c - collapseAfter) * 20).toString() + "ms";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCollapsibleGrids() {
|
||||||
|
const collapsibleGridElements = document.querySelectorAll(".cards-grid.collapsible-container");
|
||||||
|
|
||||||
|
if (collapsibleGridElements.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < collapsibleGridElements.length; i++) {
|
||||||
|
const gridElement = collapsibleGridElements[i];
|
||||||
|
|
||||||
|
if (gridElement.dataset.collapseAfterRows === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapseAfterRows = parseInt(gridElement.dataset.collapseAfterRows);
|
||||||
|
|
||||||
|
if (collapseAfterRows == -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCardsPerRow = () => {
|
||||||
|
return parseInt(getComputedStyle(gridElement).getPropertyValue('--cards-per-row'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const button = attachExpandToggleButton(gridElement);
|
||||||
|
|
||||||
|
let cardsPerRow = 2;
|
||||||
|
|
||||||
|
const resolveCollapsibleItems = () => {
|
||||||
|
const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
|
||||||
|
|
||||||
|
if (hideItemsAfterIndex >= gridElement.children.length) {
|
||||||
|
button.style.display = "none";
|
||||||
|
} else {
|
||||||
|
button.style.removeProperty("display");
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < gridElement.children.length; i++) {
|
||||||
|
const child = gridElement.children[i];
|
||||||
|
|
||||||
|
if (i >= hideItemsAfterIndex) {
|
||||||
|
child.classList.add("collapsible-item");
|
||||||
|
child.style.animationDelay = (row * 40).toString() + "ms";
|
||||||
|
|
||||||
|
if (i % cardsPerRow + 1 == cardsPerRow) {
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
child.classList.remove("collapsible-item");
|
||||||
|
child.style.removeProperty("animation-delay");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
afterContentReady(() => {
|
||||||
|
cardsPerRow = getCardsPerRow();
|
||||||
|
resolveCollapsibleItems();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
const newCardsPerRow = getCardsPerRow();
|
||||||
|
|
||||||
|
if (cardsPerRow == newCardsPerRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cardsPerRow = newCardsPerRow;
|
||||||
|
resolveCollapsibleItems();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentReadyCallbacks = [];
|
||||||
|
|
||||||
|
function afterContentReady(callback) {
|
||||||
|
contentReadyCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekDayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||||
|
|
||||||
|
function makeSettableTimeElement(element, hourFormat) {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
const hour = document.createElement('span');
|
||||||
|
const minute = document.createElement('span');
|
||||||
|
const amPm = document.createElement('span');
|
||||||
|
fragment.append(hour, document.createTextNode(':'), minute);
|
||||||
|
|
||||||
|
if (hourFormat == '12h') {
|
||||||
|
fragment.append(document.createTextNode(' '), amPm);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.append(fragment);
|
||||||
|
|
||||||
|
return (date) => {
|
||||||
|
const hours = date.getHours();
|
||||||
|
|
||||||
|
if (hourFormat == '12h') {
|
||||||
|
amPm.textContent = hours < 12 ? 'AM' : 'PM';
|
||||||
|
hour.textContent = hours % 12 || 12;
|
||||||
|
} else {
|
||||||
|
hour.textContent = hours < 10 ? '0' + hours : hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = date.getMinutes();
|
||||||
|
minute.textContent = minutes < 10 ? '0' + minutes : minutes;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function timeInZone(now, zone) {
|
||||||
|
let timeInZone;
|
||||||
|
|
||||||
|
try {
|
||||||
|
timeInZone = new Date(now.toLocaleString('en-US', { timeZone: zone }));
|
||||||
|
} catch (e) {
|
||||||
|
// TODO: indicate to the user that this is an invalid timezone
|
||||||
|
console.error(e);
|
||||||
|
timeInZone = now
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
|
||||||
|
|
||||||
|
return { time: timeInZone, diffInHours: diffInHours };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupClocks() {
|
||||||
|
const clocks = document.getElementsByClassName('clock');
|
||||||
|
|
||||||
|
if (clocks.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCallbacks = [];
|
||||||
|
|
||||||
|
for (var i = 0; i < clocks.length; i++) {
|
||||||
|
const clock = clocks[i];
|
||||||
|
const hourFormat = clock.dataset.hourFormat;
|
||||||
|
const localTimeContainer = clock.querySelector('[data-local-time]');
|
||||||
|
const localDateElement = localTimeContainer.querySelector('[data-date]');
|
||||||
|
const localWeekdayElement = localTimeContainer.querySelector('[data-weekday]');
|
||||||
|
const localYearElement = localTimeContainer.querySelector('[data-year]');
|
||||||
|
const timeZoneContainers = clock.querySelectorAll('[data-time-in-zone]');
|
||||||
|
|
||||||
|
const setLocalTime = makeSettableTimeElement(
|
||||||
|
localTimeContainer.querySelector('[data-time]'),
|
||||||
|
hourFormat
|
||||||
|
);
|
||||||
|
|
||||||
|
updateCallbacks.push((now) => {
|
||||||
|
setLocalTime(now);
|
||||||
|
localDateElement.textContent = now.getDate() + ' ' + monthNames[now.getMonth()];
|
||||||
|
localWeekdayElement.textContent = weekDayNames[now.getDay()];
|
||||||
|
localYearElement.textContent = now.getFullYear();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (var z = 0; z < timeZoneContainers.length; z++) {
|
||||||
|
const timeZoneContainer = timeZoneContainers[z];
|
||||||
|
const diffElement = timeZoneContainer.querySelector('[data-time-diff]');
|
||||||
|
|
||||||
|
const setZoneTime = makeSettableTimeElement(
|
||||||
|
timeZoneContainer.querySelector('[data-time]'),
|
||||||
|
hourFormat
|
||||||
|
);
|
||||||
|
|
||||||
|
updateCallbacks.push((now) => {
|
||||||
|
const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
|
||||||
|
setZoneTime(time);
|
||||||
|
diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateClocks = () => {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (var i = 0; i < updateCallbacks.length; i++)
|
||||||
|
updateCallbacks[i](now);
|
||||||
|
|
||||||
|
setTimeout(updateClocks, (60 - now.getSeconds()) * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateClocks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupPage() {
|
||||||
|
const pageElement = document.getElementById("page");
|
||||||
|
const pageContentElement = document.getElementById("page-content");
|
||||||
|
const pageContent = await fetchPageContent(pageData);
|
||||||
|
|
||||||
|
pageContentElement.innerHTML = pageContent;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setupPopovers();
|
||||||
|
setupClocks()
|
||||||
|
setupCarousels();
|
||||||
|
setupSearchBoxes();
|
||||||
|
setupCollapsibleLists();
|
||||||
|
setupCollapsibleGrids();
|
||||||
|
setupGroups();
|
||||||
|
setupDynamicRelativeTime();
|
||||||
|
setupLazyImages();
|
||||||
|
} finally {
|
||||||
|
pageElement.classList.add("content-ready");
|
||||||
|
|
||||||
|
for (let i = 0; i < contentReadyCallbacks.length; i++) {
|
||||||
|
contentReadyCallbacks[i]();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.classList.add("page-columns-transitioned");
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupPage();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
|
||||||
let debounceTimeout;
|
|
||||||
let timesDebounced = 0;
|
|
||||||
|
|
||||||
return function () {
|
|
||||||
if (timesDebounced == maxDebounceTimes) {
|
|
||||||
clearTimeout(debounceTimeout);
|
|
||||||
timesDebounced = 0;
|
|
||||||
callback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(debounceTimeout);
|
|
||||||
timesDebounced++;
|
|
||||||
|
|
||||||
debounceTimeout = setTimeout(() => {
|
|
||||||
timesDebounced = 0;
|
|
||||||
callback();
|
|
||||||
}, debounceDelay);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
async function fetchPageContent(pageSlug) {
|
|
||||||
// TODO: handle non 200 status codes/time outs
|
|
||||||
// TODO: add retries
|
|
||||||
const response = await fetch(`/api/pages/${pageSlug}/content/`);
|
|
||||||
const content = await response.text();
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupCarousels() {
|
|
||||||
const carouselElements = document.getElementsByClassName("carousel-container");
|
|
||||||
|
|
||||||
if (carouselElements.length == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < carouselElements.length; i++) {
|
|
||||||
const carousel = carouselElements[i];
|
|
||||||
carousel.classList.add("show-right-cutoff");
|
|
||||||
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
|
|
||||||
|
|
||||||
const determineSideCutoffs = () => {
|
|
||||||
if (itemsContainer.scrollLeft != 0) {
|
|
||||||
carousel.classList.add("show-left-cutoff");
|
|
||||||
} else {
|
|
||||||
carousel.classList.remove("show-left-cutoff");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.ceil(itemsContainer.scrollLeft) + itemsContainer.clientWidth < itemsContainer.scrollWidth) {
|
|
||||||
carousel.classList.add("show-right-cutoff");
|
|
||||||
} else {
|
|
||||||
carousel.classList.remove("show-right-cutoff");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
|
|
||||||
|
|
||||||
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
|
|
||||||
document.addEventListener("resize", determineSideCutoffsRateLimited);
|
|
||||||
|
|
||||||
setTimeout(determineSideCutoffs, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const minuteInSeconds = 60;
|
|
||||||
const hourInSeconds = minuteInSeconds * 60;
|
|
||||||
const dayInSeconds = hourInSeconds * 24;
|
|
||||||
const monthInSeconds = dayInSeconds * 30;
|
|
||||||
const yearInSeconds = monthInSeconds * 12;
|
|
||||||
|
|
||||||
function relativeTimeSince(timestamp) {
|
|
||||||
const delta = Math.round((Date.now() / 1000) - timestamp);
|
|
||||||
|
|
||||||
if (delta < minuteInSeconds) {
|
|
||||||
return "1m";
|
|
||||||
}
|
|
||||||
if (delta < hourInSeconds) {
|
|
||||||
return Math.floor(delta / minuteInSeconds) + "m";
|
|
||||||
}
|
|
||||||
if (delta < dayInSeconds) {
|
|
||||||
return Math.floor(delta / hourInSeconds) + "h";
|
|
||||||
}
|
|
||||||
if (delta < monthInSeconds) {
|
|
||||||
return Math.floor(delta / dayInSeconds) + "d";
|
|
||||||
}
|
|
||||||
if (delta < yearInSeconds) {
|
|
||||||
return Math.floor(delta / monthInSeconds) + "mo";
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.floor(delta / yearInSeconds) + "y";
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRelativeTimeForElements(elements)
|
|
||||||
{
|
|
||||||
for (let i = 0; i < elements.length; i++)
|
|
||||||
{
|
|
||||||
const element = elements[i];
|
|
||||||
const timestamp = element.dataset.dynamicRelativeTime;
|
|
||||||
|
|
||||||
if (timestamp === undefined)
|
|
||||||
continue
|
|
||||||
|
|
||||||
element.innerText = relativeTimeSince(timestamp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupDynamicRelativeTime() {
|
|
||||||
const elements = document.querySelectorAll("[data-dynamic-relative-time]");
|
|
||||||
const updateInterval = 60 * 1000;
|
|
||||||
let lastUpdateTime = Date.now();
|
|
||||||
|
|
||||||
updateRelativeTimeForElements(elements);
|
|
||||||
|
|
||||||
const updateElementsAndTimestamp = () => {
|
|
||||||
updateRelativeTimeForElements(elements);
|
|
||||||
lastUpdateTime = Date.now();
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleRepeatingUpdate = () => setInterval(updateElementsAndTimestamp, updateInterval);
|
|
||||||
|
|
||||||
if (document.hidden === undefined) {
|
|
||||||
scheduleRepeatingUpdate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeout = scheduleRepeatingUpdate();
|
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", () => {
|
|
||||||
if (document.hidden) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delta = Date.now() - lastUpdateTime;
|
|
||||||
|
|
||||||
if (delta >= updateInterval) {
|
|
||||||
updateElementsAndTimestamp();
|
|
||||||
timeout = scheduleRepeatingUpdate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
updateElementsAndTimestamp();
|
|
||||||
timeout = scheduleRepeatingUpdate();
|
|
||||||
}, updateInterval - delta);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupLazyImages() {
|
|
||||||
const images = document.querySelectorAll("img[loading=lazy]");
|
|
||||||
|
|
||||||
if (images.length == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
function imageFinishedTransition(image) {
|
|
||||||
image.classList.add("finished-transition");
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
for (let i = 0; i < images.length; i++) {
|
|
||||||
const image = images[i];
|
|
||||||
|
|
||||||
if (image.complete) {
|
|
||||||
image.classList.add("cached");
|
|
||||||
setTimeout(() => imageFinishedTransition(image), 5);
|
|
||||||
} else {
|
|
||||||
// TODO: also handle error event
|
|
||||||
image.addEventListener("load", () => {
|
|
||||||
image.classList.add("loaded");
|
|
||||||
setTimeout(() => imageFinishedTransition(image), 500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachExpandToggleButton(collapsibleContainer) {
|
|
||||||
const showMoreText = "Show more";
|
|
||||||
const showLessText = "Show less";
|
|
||||||
|
|
||||||
let expanded = false;
|
|
||||||
const button = document.createElement("button");
|
|
||||||
const icon = document.createElement("span");
|
|
||||||
icon.classList.add("expand-toggle-button-icon");
|
|
||||||
const textNode = document.createTextNode(showMoreText);
|
|
||||||
button.classList.add("expand-toggle-button");
|
|
||||||
button.append(textNode, icon);
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
expanded = !expanded;
|
|
||||||
|
|
||||||
if (expanded) {
|
|
||||||
collapsibleContainer.classList.add("container-expanded");
|
|
||||||
button.classList.add("container-expanded");
|
|
||||||
textNode.nodeValue = showLessText;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const topBefore = button.getClientRects()[0].top;
|
|
||||||
|
|
||||||
collapsibleContainer.classList.remove("container-expanded");
|
|
||||||
button.classList.remove("container-expanded");
|
|
||||||
textNode.nodeValue = showMoreText;
|
|
||||||
|
|
||||||
const topAfter = button.getClientRects()[0].top;
|
|
||||||
|
|
||||||
if (topAfter > 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
window.scrollBy({
|
|
||||||
top: topAfter - topBefore,
|
|
||||||
behavior: "instant"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
collapsibleContainer.after(button);
|
|
||||||
|
|
||||||
return button;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
function setupCollapsibleLists() {
|
|
||||||
const collapsibleLists = document.querySelectorAll(".list.collapsible-container");
|
|
||||||
|
|
||||||
if (collapsibleLists.length == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < collapsibleLists.length; i++) {
|
|
||||||
const list = collapsibleLists[i];
|
|
||||||
|
|
||||||
if (list.dataset.collapseAfter === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collapseAfter = parseInt(list.dataset.collapseAfter);
|
|
||||||
|
|
||||||
if (collapseAfter == -1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (list.children.length <= collapseAfter) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
attachExpandToggleButton(list);
|
|
||||||
|
|
||||||
for (let c = collapseAfter; c < list.children.length; c++) {
|
|
||||||
const child = list.children[c];
|
|
||||||
child.classList.add("collapsible-item");
|
|
||||||
child.style.animationDelay = ((c - collapseAfter) * 20).toString() + "ms";
|
|
||||||
}
|
|
||||||
|
|
||||||
list.classList.add("ready");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupCollapsibleGrids() {
|
|
||||||
const collapsibleGridElements = document.querySelectorAll(".cards-grid.collapsible-container");
|
|
||||||
|
|
||||||
if (collapsibleGridElements.length == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < collapsibleGridElements.length; i++) {
|
|
||||||
const gridElement = collapsibleGridElements[i];
|
|
||||||
|
|
||||||
if (gridElement.dataset.collapseAfterRows === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collapseAfterRows = parseInt(gridElement.dataset.collapseAfterRows);
|
|
||||||
|
|
||||||
if (collapseAfterRows == -1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCardsPerRow = () => {
|
|
||||||
return parseInt(getComputedStyle(gridElement).getPropertyValue('--cards-per-row'));
|
|
||||||
};
|
|
||||||
|
|
||||||
const button = attachExpandToggleButton(gridElement);
|
|
||||||
|
|
||||||
let cardsPerRow = 2;
|
|
||||||
|
|
||||||
const resolveCollapsibleItems = () => {
|
|
||||||
const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
|
|
||||||
|
|
||||||
if (hideItemsAfterIndex >= gridElement.children.length) {
|
|
||||||
button.style.display = "none";
|
|
||||||
} else {
|
|
||||||
button.style.removeProperty("display");
|
|
||||||
}
|
|
||||||
|
|
||||||
let row = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < gridElement.children.length; i++) {
|
|
||||||
const child = gridElement.children[i];
|
|
||||||
|
|
||||||
if (i >= hideItemsAfterIndex) {
|
|
||||||
child.classList.add("collapsible-item");
|
|
||||||
child.style.animationDelay = (row * 40).toString() + "ms";
|
|
||||||
|
|
||||||
if (i % cardsPerRow + 1 == cardsPerRow) {
|
|
||||||
row++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
child.classList.remove("collapsible-item");
|
|
||||||
child.style.removeProperty("animation-delay");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
cardsPerRow = getCardsPerRow();
|
|
||||||
resolveCollapsibleItems();
|
|
||||||
gridElement.classList.add("ready");
|
|
||||||
}, 1);
|
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
|
||||||
const newCardsPerRow = getCardsPerRow();
|
|
||||||
|
|
||||||
if (cardsPerRow == newCardsPerRow) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cardsPerRow = newCardsPerRow;
|
|
||||||
resolveCollapsibleItems();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupPage() {
|
|
||||||
const pageElement = document.getElementById("page");
|
|
||||||
const pageContentElement = document.getElementById("page-content");
|
|
||||||
const pageContent = await fetchPageContent(pageData.slug);
|
|
||||||
|
|
||||||
pageContentElement.innerHTML = pageContent;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
document.body.classList.add("animate-element-transition");
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
try {
|
|
||||||
setupLazyImages();
|
|
||||||
setupCarousels();
|
|
||||||
setupCollapsibleLists();
|
|
||||||
setupCollapsibleGrids();
|
|
||||||
setupDynamicRelativeTime();
|
|
||||||
} finally {
|
|
||||||
pageElement.classList.add("content-ready");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
|
||||||
document.addEventListener("DOMContentLoaded", setupPage);
|
|
||||||
} else {
|
|
||||||
setupPage();
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "Glance",
|
"name": "Glance",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"background_color": "#151519",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ var (
|
|||||||
PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
|
PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
|
||||||
PageContentTemplate = compileTemplate("content.html")
|
PageContentTemplate = compileTemplate("content.html")
|
||||||
CalendarTemplate = compileTemplate("calendar.html", "widget-base.html")
|
CalendarTemplate = compileTemplate("calendar.html", "widget-base.html")
|
||||||
|
ClockTemplate = compileTemplate("clock.html", "widget-base.html")
|
||||||
BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html")
|
BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html")
|
||||||
IFrameTemplate = compileTemplate("iframe.html", "widget-base.html")
|
IFrameTemplate = compileTemplate("iframe.html", "widget-base.html")
|
||||||
WeatherTemplate = compileTemplate("weather.html", "widget-base.html")
|
WeatherTemplate = compileTemplate("weather.html", "widget-base.html")
|
||||||
@@ -22,16 +23,22 @@ var (
|
|||||||
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
|
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
|
||||||
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
|
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
|
||||||
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
|
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
|
||||||
|
ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html")
|
||||||
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
|
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
|
||||||
VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
|
VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
|
||||||
StocksTemplate = compileTemplate("stocks.html", "widget-base.html")
|
MarketsTemplate = compileTemplate("markets.html", "widget-base.html")
|
||||||
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
|
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
|
||||||
|
RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html")
|
||||||
RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
|
RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
|
||||||
RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
|
RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
|
||||||
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
|
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
|
||||||
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
|
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
|
||||||
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
|
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
|
||||||
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
|
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
|
||||||
|
SearchTemplate = compileTemplate("search.html", "widget-base.html")
|
||||||
|
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
|
||||||
|
GroupTemplate = compileTemplate("group.html", "widget-base.html")
|
||||||
|
DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html")
|
||||||
)
|
)
|
||||||
|
|
||||||
var globalTemplateFunctions = template.FuncMap{
|
var globalTemplateFunctions = template.FuncMap{
|
||||||
@@ -44,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()))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 }}
|
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
|
{{ range .ChangeDetections }}
|
||||||
|
<li>
|
||||||
|
<a class="size-h4 block text-truncate color-highlight" href="{{ .URL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
<li {{ dynamicRelativeTimeAttrs .LastChanged }}></li>
|
||||||
|
<li class="shrink min-width-0"><a class="visited-indicator" href="{{ .DiffURL }}" target="_blank" rel="noreferrer">diff:{{ .PreviousHash }}</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{{ else }}
|
||||||
|
<li>No watches configured</li>
|
||||||
|
{{ end}}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="clock" data-hour-format="{{ .HourFormat }}">
|
||||||
|
<div class="flex justify-between items-center" data-local-time>
|
||||||
|
<div>
|
||||||
|
<div class="color-highlight size-h1" data-date></div>
|
||||||
|
<div data-year></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="clock-time size-h1" data-time></div>
|
||||||
|
<div data-weekday></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ if gt (len .Timezones) 0 }}
|
||||||
|
<hr class="margin-block-10">
|
||||||
|
<ul class="list list-gap-4">
|
||||||
|
{{ range .Timezones }}
|
||||||
|
<li class="flex items-center gap-15" data-time-in-zone="{{ .Timezone }}">
|
||||||
|
<div class="grow min-width-0">
|
||||||
|
<div class="text-truncate">{{ if ne .Label "" }}{{ .Label }}{{ else }}{{ .Timezone }}{{ end }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-subdue" data-time-diff></div>
|
||||||
|
<div class="size-h4 clock-time shrink-0 text-right" data-time></div>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="widget-small-content-bounds dns-stats">
|
||||||
|
<div class="flex text-center justify-between dns-stats-totals">
|
||||||
|
<div>
|
||||||
|
<div class="color-highlight size-h3">{{ .Stats.TotalQueries | formatNumber }}</div>
|
||||||
|
<div class="size-h6">QUERIES</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="color-highlight size-h3">{{ .Stats.BlockedPercent }}%</div>
|
||||||
|
<div class="size-h6">BLOCKED</div>
|
||||||
|
</div>
|
||||||
|
{{ if gt .Stats.ResponseTime 0 }}
|
||||||
|
<div>
|
||||||
|
<div class="color-highlight size-h3">{{ .Stats.ResponseTime | formatNumber }}ms</div>
|
||||||
|
<div class="size-h6">LATENCY</div>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="cursor-help" data-popover-type="text" data-popover-text="Total number of blocked domains from all adlists" data-popover-max-width="200px" data-popover-text-align="center">
|
||||||
|
<div class="color-highlight size-h3">{{ .Stats.DomainsBlocked | formatViewerCount }}</div>
|
||||||
|
<div class="size-h6">DOMAINS</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dns-stats-graph margin-top-15">
|
||||||
|
<div class="dns-stats-graph-gridlines-container">
|
||||||
|
<svg class="dns-stats-graph-gridlines" shape-rendering="crispEdges" viewBox="0 0 1 100" preserveAspectRatio="none">
|
||||||
|
<g stroke="var(--color-graph-gridlines)" stroke-width="1">
|
||||||
|
<line x1="0" y1="1" x2="1" y2="1" vector-effect="non-scaling-stroke" />
|
||||||
|
<line x1="0" y1="25" x2="1" y2="25" vector-effect="non-scaling-stroke" />
|
||||||
|
<line x1="0" y1="50" x2="1" y2="50" vector-effect="non-scaling-stroke" />
|
||||||
|
<line x1="0" y1="75" x2="1" y2="75" vector-effect="non-scaling-stroke" />
|
||||||
|
<line x1="0" y1="99" x2="1" y2="99" vector-effect="non-scaling-stroke" stroke="var(--color-progress-bar-border)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dns-stats-graph-columns">
|
||||||
|
{{ range $i, $column := .Stats.Series }}
|
||||||
|
<div class="dns-stats-graph-column" data-popover-type="html" data-popover-position="above" data-popover-show-delay="500">
|
||||||
|
<div data-popover-html>
|
||||||
|
<div class="flex text-center justify-between gap-25">
|
||||||
|
<div>
|
||||||
|
<div class="color-highlight size-h3">{{ $column.Queries | formatNumber }}</div>
|
||||||
|
<div class="size-h6">QUERIES</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="color-highlight size-h3">{{ $column.PercentBlocked }}%</div>
|
||||||
|
<div class="size-h6">BLOCKED</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ if gt $column.PercentTotal 0}}
|
||||||
|
<div class="dns-stats-graph-bar" style="--bar-height: {{ $column.PercentTotal }}">
|
||||||
|
{{ if ne $column.Queries $column.Blocked }}
|
||||||
|
<div class="queries"></div>
|
||||||
|
{{ end }}
|
||||||
|
{{ if or (gt $column.Blocked 0) (and (lt $column.PercentTotal 15) (lt $column.PercentBlocked 10)) }}
|
||||||
|
<div class="blocked" style="flex-basis: {{ $column.PercentBlocked }}%"></div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<div class="dns-stats-graph-time">{{ index $.TimeLabels $i }}</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if .Stats.TopBlockedDomains }}
|
||||||
|
<details class="details margin-top-40">
|
||||||
|
<summary class="summary">Top blocked domains</summary>
|
||||||
|
<ul class="list list-gap-4 list-with-transition size-h5">
|
||||||
|
{{ range .Stats.TopBlockedDomains }}
|
||||||
|
<li class="flex justify-between align-center">
|
||||||
|
<div class="text-truncate rtl">{{ .Domain }}</div>
|
||||||
|
<div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -11,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>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
{{ .Extension.Content }}
|
||||||
|
{{ end }}
|
||||||
@@ -4,9 +4,13 @@
|
|||||||
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range .Posts }}
|
{{ range .Posts }}
|
||||||
<li>
|
<li>
|
||||||
<div class="forum-post-list-item thumbnail-container">
|
<div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
|
||||||
{{ if $.ShowThumbnails }}
|
{{ if $.ShowThumbnails }}
|
||||||
{{ if ne .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)">
|
||||||
@@ -18,14 +22,23 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div class="grow">
|
<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 }}
|
||||||
|
<div class="inline-block forum-post-tags-container">
|
||||||
|
<ul class="attachments">
|
||||||
|
{{ range .Tags }}
|
||||||
|
<li>{{ . }}</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li>{{ .Score | formatNumber }} points</li>
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
<li>{{ .CommentCount | formatNumber }} comments</li>
|
<li>{{ .CommentCount | formatNumber }} comments</li>
|
||||||
{{ if .HasTargetUrl }}
|
{{ if .HasTargetUrl }}
|
||||||
<li class="shrink min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 }}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="dynamic-columns list-gap-20 list-with-separator">
|
||||||
|
{{ range .Markets }}
|
||||||
|
<div class="flex items-center gap-15">
|
||||||
|
<div class="min-width-0">
|
||||||
|
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
|
||||||
|
<div class="text-truncate">{{ .Name }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
|
||||||
|
<svg class="market-chart shrink-0" viewBox="0 0 100 50">
|
||||||
|
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="market-values shrink-0">
|
||||||
|
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
|
||||||
|
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -1,34 +1,34 @@
|
|||||||
{{ 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 }}
|
||||||
|
|
||||||
{{ define "site" }}
|
{{ define "site" }}
|
||||||
{{ if .IconUrl }}
|
{{ if .IconUrl }}
|
||||||
<img class="monitor-site-icon" 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>{{ .StatusText }}</li>
|
<li title="{{ .Status.Code }}">{{ .StatusText }}</li>
|
||||||
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
|
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
|
||||||
{{ else if .Status.TimedOut }}
|
{{ else if .Status.TimedOut }}
|
||||||
<li class="color-negative">Timed Out</li>
|
<li class="color-negative">Timed Out</li>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{{ if eq .StatusStyle "good" }}
|
{{ if eq .StatusStyle "ok" }}
|
||||||
<div class="monitor-site-status-icon">
|
<div class="monitor-site-status-icon">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
|
||||||
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<ul class="list list-gap-24 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
|
{{ range .Items }}
|
||||||
|
<li class="flex gap-15 items-start row-reverse-on-mobile thumbnail-parent">
|
||||||
|
<div class="thumbnail-container rss-detailed-thumbnail">
|
||||||
|
{{ if ne "" .ImageURL }}
|
||||||
|
<img class="thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||||
|
{{ else }}
|
||||||
|
<svg class="scale-half hide-on-mobile" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||||
|
</svg>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="grow min-width-0">
|
||||||
|
<a class="size-h3 color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text flex-nowrap">
|
||||||
|
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
|
<li class="min-width-0">
|
||||||
|
<a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{{ if ne "" .Description }}
|
||||||
|
<p class="rss-detailed-description text-truncate-2-lines margin-top-10">{{ .Description }}</p>
|
||||||
|
{{ end }}
|
||||||
|
{{ if gt (len .Categories) 0 }}
|
||||||
|
<ul class="attachments margin-top-10">
|
||||||
|
{{ range .Categories }}
|
||||||
|
<li>{{ . }}</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{ else }}
|
||||||
|
<li>{{ .NoItemsMessage }}</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
@@ -3,10 +3,11 @@
|
|||||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
|
{{ if gt (len .Items) 0 }}
|
||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
<div class="cards-horizontal carousel-items-container"{{ 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 }}
|
||||||
<div class="card rss-card-2 widget-content-frame thumbnail-container">
|
<div class="card rss-card-2 widget-content-frame thumbnail-parent">
|
||||||
{{ if ne "" .ImageURL }}
|
{{ if ne "" .ImageURL }}
|
||||||
<img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
<img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||||
{{ else }}
|
{{ else }}
|
||||||
@@ -18,11 +19,14 @@
|
|||||||
<a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text flex-nowrap margin-top-5">
|
<ul class="list-horizontal-text flex-nowrap margin-top-5">
|
||||||
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
<li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
|
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
|
||||||
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
|
{{ if gt (len .Items) 0 }}
|
||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
<div class="cards-horizontal carousel-items-container"{{ 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 }}
|
||||||
<div class="card widget-content-frame thumbnail-container">
|
<div class="card widget-content-frame thumbnail-parent">
|
||||||
{{ if ne "" .ImageURL }}
|
{{ if ne "" .ImageURL }}
|
||||||
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||||
{{ else }}
|
{{ else }}
|
||||||
@@ -18,11 +19,14 @@
|
|||||||
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
||||||
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
<li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
|
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
|
||||||
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
{{ 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">
|
<ul class="list-horizontal-text flex-nowrap">
|
||||||
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
{{ if gt (len $.FeedRequests) 1 }}
|
<li class="min-width-0">
|
||||||
<li><a href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a></li>
|
<a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
|
||||||
{{ end }}
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
{{ else }}
|
||||||
|
<li>{{ .NoItemsMessage }}</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}" data-new-tab="{{ .NewTab }}">
|
||||||
|
<div class="search-bangs">
|
||||||
|
{{ range .Bangs }}
|
||||||
|
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-icon-container">
|
||||||
|
<svg class="search-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off"{{ if .Autofocus }} autofocus{{ end }}>
|
||||||
|
|
||||||
|
<div class="search-bang"></div>
|
||||||
|
<kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{{ template "widget-base.html" . }}
|
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
|
||||||
{{ if ne .Style "dynamic-columns-experimental" }}
|
|
||||||
<ul class="list list-gap-20 list-with-separator">
|
|
||||||
{{ range .Stocks }}
|
|
||||||
<li class="flex items-center gap-15">
|
|
||||||
{{ template "stock" . }}
|
|
||||||
</li>
|
|
||||||
{{ end }}
|
|
||||||
</ul>
|
|
||||||
{{ else }}
|
|
||||||
<div class="dynamic-columns">
|
|
||||||
{{ range .Stocks }}
|
|
||||||
<div class="flex items-center gap-15">
|
|
||||||
{{ template "stock" . }}
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ define "stock" }}
|
|
||||||
<div class="shrink 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="stock-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
|
|
||||||
<svg class="stock-chart shrink-0" viewBox="0 0 100 50">
|
|
||||||
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="stock-values shrink-0">
|
|
||||||
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
|
|
||||||
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
@@ -4,21 +4,25 @@
|
|||||||
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range .Channels }}
|
{{ range .Channels }}
|
||||||
<li>
|
<li>
|
||||||
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-container">
|
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
|
||||||
<div class="twitch-channel-avatar-container">
|
<div class="twitch-channel-avatar-container">
|
||||||
{{ if .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" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink min-width-0">
|
<div class="min-width-0">
|
||||||
<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>
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
{{ 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" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range .Categories }}
|
{{ range .Categories }}
|
||||||
<li class="twitch-category thumbnail-container">
|
<li class="twitch-category thumbnail-parent">
|
||||||
<div class="flex gap-10 items-center">
|
<div class="flex gap-10 items-start">
|
||||||
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ .AvatarUrl }}" alt="">
|
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ .AvatarUrl }}" alt="">
|
||||||
<div class="shrink min-width-0">
|
<div class="min-width-0">
|
||||||
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
|
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
{{ if eq $i 0 }}
|
{{ if eq $i 0 }}
|
||||||
<li class="shrink-0">{{ $tag.Name }}</li>
|
<li class="shrink-0">{{ $tag.Name }}</li>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<li class="text-truncate shrink min-width-0">{{ $tag.Name }}</li>
|
<li class="text-truncate min-width-0">{{ $tag.Name }}</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
|
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
||||||
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li class="shrink min-width-0">
|
<li class="min-width-0">
|
||||||
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
|
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<div class="cards-grid collapsible-container" data-collapse-after-rows="{{ .CollapseAfterRows }}">
|
<div class="cards-grid collapsible-container" data-collapse-after-rows="{{ .CollapseAfterRows }}">
|
||||||
{{ range .Videos }}
|
{{ range .Videos }}
|
||||||
<div class="card widget-content-frame thumbnail-container">
|
<div class="card widget-content-frame thumbnail-parent">
|
||||||
{{ template "video-card-contents" . }}
|
{{ template "video-card-contents" . }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
<div class="cards-horizontal carousel-items-container">
|
<div class="cards-horizontal carousel-items-container">
|
||||||
{{ range .Videos }}
|
{{ range .Videos }}
|
||||||
<div class="card widget-content-frame thumbnail-container">
|
<div class="card widget-content-frame thumbnail-parent">
|
||||||
{{ template "video-card-contents" . }}
|
{{ template "video-card-contents" . }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChangeDetectionWatch struct {
|
||||||
|
Title string
|
||||||
|
URL string
|
||||||
|
LastChanged time.Time
|
||||||
|
DiffURL string
|
||||||
|
PreviousHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChangeDetectionWatches []ChangeDetectionWatch
|
||||||
|
|
||||||
|
func (r ChangeDetectionWatches) SortByNewest() ChangeDetectionWatches {
|
||||||
|
sort.Slice(r, func(i, j int) bool {
|
||||||
|
return r[i].LastChanged.After(r[j].LastChanged)
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type changeDetectionResponseJson struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
LastChanged int64 `json:"last_changed"`
|
||||||
|
DateCreated int64 `json:"date_created"`
|
||||||
|
PreviousHash string `json:"previous_md5"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) {
|
||||||
|
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil)
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
request.Header.Add("x-api-key", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultClient, request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uuids := make([]string, 0, len(uuidsMap))
|
||||||
|
|
||||||
|
for uuid := range uuidsMap {
|
||||||
|
uuids = append(uuids, uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) {
|
||||||
|
watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs))
|
||||||
|
|
||||||
|
if len(requestedWatchIDs) == 0 {
|
||||||
|
return watches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := make([]*http.Request, len(requestedWatchIDs))
|
||||||
|
|
||||||
|
for i, repository := range requestedWatchIDs {
|
||||||
|
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch/%s", instanceURL, repository), nil)
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
request.Header.Add("x-api-key", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
requests[i] = request
|
||||||
|
}
|
||||||
|
|
||||||
|
task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultClient)
|
||||||
|
job := newJob(task, requests).withWorkers(15)
|
||||||
|
responses, errs, err := workerPoolDo(job)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var failed int
|
||||||
|
|
||||||
|
for i := range responses {
|
||||||
|
if errs[i] != nil {
|
||||||
|
failed++
|
||||||
|
slog.Error("Failed to fetch or parse change detection watch", "error", errs[i], "url", requests[i].URL)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
watchJson := responses[i]
|
||||||
|
|
||||||
|
watch := ChangeDetectionWatch{
|
||||||
|
URL: watchJson.URL,
|
||||||
|
DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1),
|
||||||
|
}
|
||||||
|
|
||||||
|
if watchJson.LastChanged == 0 {
|
||||||
|
watch.LastChanged = time.Unix(watchJson.DateCreated, 0)
|
||||||
|
} else {
|
||||||
|
watch.LastChanged = time.Unix(watchJson.LastChanged, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if watchJson.Title != "" {
|
||||||
|
watch.Title = watchJson.Title
|
||||||
|
} else {
|
||||||
|
watch.Title = strings.TrimPrefix(strings.Trim(stripURLScheme(watchJson.URL), "/"), "www.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if watchJson.PreviousHash != "" {
|
||||||
|
var hashLength = 8
|
||||||
|
|
||||||
|
if len(watchJson.PreviousHash) < hashLength {
|
||||||
|
hashLength = len(watchJson.PreviousHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch.PreviousHash = watchJson.PreviousHash[0:hashLength]
|
||||||
|
}
|
||||||
|
|
||||||
|
watches = append(watches, watch)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(watches) == 0 {
|
||||||
|
return nil, ErrNoContent
|
||||||
|
}
|
||||||
|
|
||||||
|
watches.SortByNewest()
|
||||||
|
|
||||||
|
if failed > 0 {
|
||||||
|
return watches, fmt.Errorf("%w: could not get %d watches", ErrPartialContent, failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return watches, nil
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExtensionType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ExtensionContentHTML ExtensionType = iota
|
||||||
|
ExtensionContentUnknown = iota
|
||||||
|
)
|
||||||
|
|
||||||
|
var ExtensionStringToType = map[string]ExtensionType{
|
||||||
|
"html": ExtensionContentHTML,
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ExtensionHeaderTitle = "Widget-Title"
|
||||||
|
ExtensionHeaderContentType = "Widget-Content-Type"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExtensionRequestOptions struct {
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
Parameters map[string]string `yaml:"parameters"`
|
||||||
|
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Extension struct {
|
||||||
|
Title string
|
||||||
|
Content template.HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertExtensionContent(options ExtensionRequestOptions, content []byte, contentType ExtensionType) template.HTML {
|
||||||
|
switch contentType {
|
||||||
|
case ExtensionContentHTML:
|
||||||
|
if options.AllowHtml {
|
||||||
|
return template.HTML(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
return template.HTML(html.EscapeString(string(content)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchExtension(options ExtensionRequestOptions) (Extension, error) {
|
||||||
|
request, _ := http.NewRequest("GET", options.URL, nil)
|
||||||
|
|
||||||
|
query := url.Values{}
|
||||||
|
|
||||||
|
for key, value := range options.Parameters {
|
||||||
|
query.Set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.URL.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
response, err := http.DefaultClient.Do(request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed fetching extension", "error", err, "url", options.URL)
|
||||||
|
return Extension{}, fmt.Errorf("%w: request failed: %w", ErrNoContent, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(response.Body)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed reading response body of extension", "error", err, "url", options.URL)
|
||||||
|
return Extension{}, fmt.Errorf("%w: could not read body: %w", ErrNoContent, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension := Extension{}
|
||||||
|
|
||||||
|
if response.Header.Get(ExtensionHeaderTitle) == "" {
|
||||||
|
extension.Title = "Extension"
|
||||||
|
} else {
|
||||||
|
extension.Title = response.Header.Get(ExtensionHeaderTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)]
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
contentType = ExtensionContentUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
extension.Content = convertExtensionContent(options, body, contentType)
|
||||||
|
|
||||||
|
return extension, nil
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type lobstersPostResponseJson struct {
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
CommentCount int `json:"comment_count"`
|
||||||
|
CommentsURL string `json:"comments_url"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type lobstersFeedResponseJson []lobstersPostResponseJson
|
||||||
|
|
||||||
|
func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) {
|
||||||
|
request, err := http.NewRequest("GET", feedUrl, nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
posts := make(ForumPosts, 0, len(feed))
|
||||||
|
|
||||||
|
for i := range feed {
|
||||||
|
createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt)
|
||||||
|
|
||||||
|
posts = append(posts, ForumPost{
|
||||||
|
Title: feed[i].Title,
|
||||||
|
DiscussionUrl: feed[i].CommentsURL,
|
||||||
|
TargetUrl: feed[i].URL,
|
||||||
|
TargetUrlDomain: extractDomainFromUrl(feed[i].URL),
|
||||||
|
CommentCount: feed[i].CommentCount,
|
||||||
|
Score: feed[i].Score,
|
||||||
|
TimePosted: createdAt,
|
||||||
|
Tags: feed[i].Tags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(posts) == 0 {
|
||||||
|
return nil, ErrNoContent
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) {
|
||||||
|
var feedUrl string
|
||||||
|
|
||||||
|
if customURL != "" {
|
||||||
|
feedUrl = customURL
|
||||||
|
} else {
|
||||||
|
if instanceURL != "" {
|
||||||
|
instanceURL = strings.TrimRight(instanceURL, "/") + "/"
|
||||||
|
} else {
|
||||||
|
instanceURL = "https://lobste.rs/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if sortBy == "hot" {
|
||||||
|
sortBy = "hottest"
|
||||||
|
} else if sortBy == "new" {
|
||||||
|
sortBy = "newest"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tags) == 0 {
|
||||||
|
feedUrl = instanceURL + sortBy + ".json"
|
||||||
|
} else {
|
||||||
|
tags := strings.Join(tags, ",")
|
||||||
|
feedUrl = instanceURL + "t/" + tags + ".json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
posts, err := getLobstersPostsFromFeed(feedUrl)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type SiteStatusRequest struct {
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
CheckURL string `yaml:"check-url"`
|
||||||
|
AllowInsecure bool `yaml:"allow-insecure"`
|
||||||
|
}
|
||||||
|
|
||||||
type SiteStatus struct {
|
type SiteStatus struct {
|
||||||
Code int
|
Code int
|
||||||
TimedOut bool
|
TimedOut bool
|
||||||
@@ -14,14 +20,34 @@ type SiteStatus struct {
|
|||||||
Error error
|
Error error
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
|
func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
|
||||||
|
var url string
|
||||||
|
if statusRequest.CheckURL != "" {
|
||||||
|
url = statusRequest.CheckURL
|
||||||
|
} else {
|
||||||
|
url = statusRequest.URL
|
||||||
|
}
|
||||||
|
request, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return SiteStatus{
|
||||||
|
Error: err,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
request = request.WithContext(ctx)
|
request = request.WithContext(ctx)
|
||||||
start := time.Now()
|
requestSentAt := time.Now()
|
||||||
response, err := http.DefaultClient.Do(request)
|
var response *http.Response
|
||||||
took := time.Since(start)
|
|
||||||
status := SiteStatus{ResponseTime: took}
|
if !statusRequest.AllowInsecure {
|
||||||
|
response, err = defaultClient.Do(request)
|
||||||
|
} else {
|
||||||
|
response, err = defaultInsecureClient.Do(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := SiteStatus{ResponseTime: time.Since(requestSentAt)}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
@@ -29,7 +55,7 @@ func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
status.Error = err
|
status.Error = err
|
||||||
return status, err
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
@@ -39,7 +65,7 @@ func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
|
|||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchStatusesForRequests(requests []*http.Request) ([]SiteStatus, error) {
|
func FetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) {
|
||||||
job := newJob(getSiteStatusTask, requests).withWorkers(20)
|
job := newJob(getSiteStatusTask, requests).withWorkers(20)
|
||||||
results, _, err := workerPoolDo(job)
|
results, _, err := workerPoolDo(job)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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 map[string]int `json:"top_ads"`
|
||||||
|
DomainsBlocked int `json:"domains_being_blocked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ type ForumPost struct {
|
|||||||
Score int
|
Score int
|
||||||
Engagement float64
|
Engagement float64
|
||||||
TimePosted time.Time
|
TimePosted time.Time
|
||||||
|
Tags []string
|
||||||
|
IsCrosspost bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type ForumPosts []ForumPost
|
type ForumPosts []ForumPost
|
||||||
@@ -39,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
|
||||||
@@ -84,20 +88,46 @@ var currencyToSymbol = map[string]string{
|
|||||||
"PHP": "₱",
|
"PHP": "₱",
|
||||||
}
|
}
|
||||||
|
|
||||||
type Stock struct {
|
type DNSStats struct {
|
||||||
Name string `yaml:"name"`
|
TotalQueries int
|
||||||
Symbol string `yaml:"symbol"`
|
BlockedQueries int
|
||||||
ChartLink string `yaml:"chart-link"`
|
BlockedPercent int
|
||||||
SymbolLink string `yaml:"symbol-link"`
|
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 {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Symbol string `yaml:"symbol"`
|
||||||
|
ChartLink string `yaml:"chart-link"`
|
||||||
|
SymbolLink string `yaml:"symbol-link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Market struct {
|
||||||
|
MarketRequest
|
||||||
Currency string `yaml:"-"`
|
Currency string `yaml:"-"`
|
||||||
Price float64 `yaml:"-"`
|
Price float64 `yaml:"-"`
|
||||||
PercentChange float64 `yaml:"-"`
|
PercentChange float64 `yaml:"-"`
|
||||||
SvgChartPoints string `yaml:"-"`
|
SvgChartPoints string `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Stocks []Stock
|
type Markets []Market
|
||||||
|
|
||||||
func (t Stocks) SortByAbsChange() {
|
func (t Markets) SortByAbsChange() {
|
||||||
sort.Slice(t, func(i, j int) bool {
|
sort.Slice(t, func(i, j int) bool {
|
||||||
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
|
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package feed
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -11,8 +12,19 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultClientTimeout = 5 * time.Second
|
||||||
|
|
||||||
var defaultClient = &http.Client{
|
var defaultClient = &http.Client{
|
||||||
Timeout: 5 * time.Second,
|
Timeout: defaultClientTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
var insecureClientTransport = &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultInsecureClient = &http.Client{
|
||||||
|
Timeout: defaultClientTimeout,
|
||||||
|
Transport: insecureClientTransport,
|
||||||
}
|
}
|
||||||
|
|
||||||
type RequestDoer interface {
|
type RequestDoer interface {
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ package feed
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mmcdole/gofeed"
|
"github.com/mmcdole/gofeed"
|
||||||
|
gofeedext "github.com/mmcdole/gofeed/extensions"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RSSFeedItem struct {
|
type RSSFeedItem struct {
|
||||||
@@ -16,12 +21,48 @@ type RSSFeedItem struct {
|
|||||||
Title string
|
Title string
|
||||||
Link string
|
Link string
|
||||||
ImageURL string
|
ImageURL string
|
||||||
|
Categories []string
|
||||||
|
Description string
|
||||||
PublishedAt time.Time
|
PublishedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doesn't cover all cases but works the vast majority of the time
|
||||||
|
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
|
||||||
|
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
|
||||||
|
|
||||||
|
func sanitizeFeedDescription(description string) string {
|
||||||
|
if description == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
description = strings.ReplaceAll(description, "\n", " ")
|
||||||
|
description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
|
||||||
|
description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
|
||||||
|
description = strings.TrimSpace(description)
|
||||||
|
description = html.UnescapeString(description)
|
||||||
|
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortenFeedDescriptionLen(description string, maxLen int) string {
|
||||||
|
description, _ = limitStringLength(description, 1000)
|
||||||
|
description = sanitizeFeedDescription(description)
|
||||||
|
description, limited := limitStringLength(description, maxLen)
|
||||||
|
|
||||||
|
if limited {
|
||||||
|
description += "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
|
||||||
type RSSFeedRequest struct {
|
type RSSFeedRequest struct {
|
||||||
Url string `yaml:"url"`
|
Url string `yaml:"url"`
|
||||||
Title string `yaml:"title"`
|
Title string `yaml:"title"`
|
||||||
|
HideCategories bool `yaml:"hide-categories"`
|
||||||
|
HideDescription bool `yaml:"hide-description"`
|
||||||
|
ItemLinkPrefix string `yaml:"item-link-prefix"`
|
||||||
|
IsDetailed bool `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RSSFeedItems []RSSFeedItem
|
type RSSFeedItems []RSSFeedItem
|
||||||
@@ -53,8 +94,60 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
|||||||
|
|
||||||
rssItem := RSSFeedItem{
|
rssItem := RSSFeedItem{
|
||||||
ChannelURL: feed.Link,
|
ChannelURL: feed.Link,
|
||||||
Title: item.Title,
|
}
|
||||||
Link: item.Link,
|
|
||||||
|
if request.ItemLinkPrefix != "" {
|
||||||
|
rssItem.Link = request.ItemLinkPrefix + item.Link
|
||||||
|
} else if strings.HasPrefix(item.Link, "http://") || strings.HasPrefix(item.Link, "https://") {
|
||||||
|
rssItem.Link = item.Link
|
||||||
|
} else {
|
||||||
|
parsedUrl, err := url.Parse(feed.Link)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
parsedUrl, err = url.Parse(request.Url)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
var link string
|
||||||
|
|
||||||
|
if len(item.Link) > 0 && item.Link[0] == '/' {
|
||||||
|
link = item.Link
|
||||||
|
} else {
|
||||||
|
link = "/" + item.Link
|
||||||
|
}
|
||||||
|
|
||||||
|
rssItem.Link = parsedUrl.Scheme + "://" + parsedUrl.Host + link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Title != "" {
|
||||||
|
rssItem.Title = item.Title
|
||||||
|
} else {
|
||||||
|
rssItem.Title = shortenFeedDescriptionLen(item.Description, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.IsDetailed {
|
||||||
|
if !request.HideDescription && item.Description != "" && item.Title != "" {
|
||||||
|
rssItem.Description = shortenFeedDescriptionLen(item.Description, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !request.HideCategories {
|
||||||
|
var categories = make([]string, 0, 6)
|
||||||
|
|
||||||
|
for _, category := range item.Categories {
|
||||||
|
if len(categories) == 6 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(category) == 0 || len(category) > 30 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
categories = append(categories, category)
|
||||||
|
}
|
||||||
|
|
||||||
|
rssItem.Categories = categories
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.Title != "" {
|
if request.Title != "" {
|
||||||
@@ -65,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 {
|
||||||
@@ -81,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)
|
||||||
@@ -103,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -77,3 +79,39 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
|
|||||||
|
|
||||||
return values
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
|
||||||
|
|
||||||
|
func stripURLScheme(url string) string {
|
||||||
|
return urlSchemePattern.ReplaceAllString(url, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func limitStringLength(s string, max int) (string, bool) {
|
||||||
|
asRunes := []rune(s)
|
||||||
|
|
||||||
|
if len(asRunes) > max {
|
||||||
|
return string(asRunes[:max]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, false
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stockResponseJson struct {
|
type marketResponseJson struct {
|
||||||
Chart struct {
|
Chart struct {
|
||||||
Result []struct {
|
Result []struct {
|
||||||
Meta struct {
|
Meta struct {
|
||||||
@@ -25,30 +25,30 @@ type stockResponseJson struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: allow changing chart time frame
|
// TODO: allow changing chart time frame
|
||||||
const stockChartDays = 21
|
const marketChartDays = 21
|
||||||
|
|
||||||
func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
|
func FetchMarketsDataFromYahoo(marketRequests []MarketRequest) (Markets, error) {
|
||||||
requests := make([]*http.Request, 0, len(stockRequests))
|
requests := make([]*http.Request, 0, len(marketRequests))
|
||||||
|
|
||||||
for i := range stockRequests {
|
for i := range marketRequests {
|
||||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", stockRequests[i].Symbol), nil)
|
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil)
|
||||||
requests = append(requests, request)
|
requests = append(requests, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
job := newJob(decodeJsonFromRequestTask[stockResponseJson](defaultClient), requests)
|
job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests)
|
||||||
responses, errs, err := workerPoolDo(job)
|
responses, errs, err := workerPoolDo(job)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stocks := make(Stocks, 0, len(responses))
|
markets := make(Markets, 0, len(responses))
|
||||||
var failed int
|
var failed int
|
||||||
|
|
||||||
for i := range responses {
|
for i := range responses {
|
||||||
if errs[i] != nil {
|
if errs[i] != nil {
|
||||||
failed++
|
failed++
|
||||||
slog.Error("Failed to fetch stock data", "symbol", stockRequests[i].Symbol, "error", errs[i])
|
slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i])
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,14 +56,14 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
|
|||||||
|
|
||||||
if len(response.Chart.Result) == 0 {
|
if len(response.Chart.Result) == 0 {
|
||||||
failed++
|
failed++
|
||||||
slog.Error("Stock response contains no data", "symbol", stockRequests[i].Symbol)
|
slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
prices := response.Chart.Result[0].Indicators.Quote[0].Close
|
prices := response.Chart.Result[0].Indicators.Quote[0].Close
|
||||||
|
|
||||||
if len(prices) > stockChartDays {
|
if len(prices) > marketChartDays {
|
||||||
prices = prices[len(prices)-stockChartDays:]
|
prices = prices[len(prices)-marketChartDays:]
|
||||||
}
|
}
|
||||||
|
|
||||||
previous := response.Chart.Result[0].Meta.RegularMarketPrice
|
previous := response.Chart.Result[0].Meta.RegularMarketPrice
|
||||||
@@ -80,13 +80,10 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
|
|||||||
currency = response.Chart.Result[0].Meta.Currency
|
currency = response.Chart.Result[0].Meta.Currency
|
||||||
}
|
}
|
||||||
|
|
||||||
stocks = append(stocks, Stock{
|
markets = append(markets, Market{
|
||||||
Name: stockRequests[i].Name,
|
MarketRequest: marketRequests[i],
|
||||||
Symbol: response.Chart.Result[0].Meta.Symbol,
|
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
|
||||||
SymbolLink: stockRequests[i].SymbolLink,
|
Currency: currency,
|
||||||
ChartLink: stockRequests[i].ChartLink,
|
|
||||||
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
|
|
||||||
Currency: currency,
|
|
||||||
PercentChange: percentChange(
|
PercentChange: percentChange(
|
||||||
response.Chart.Result[0].Meta.RegularMarketPrice,
|
response.Chart.Result[0].Meta.RegularMarketPrice,
|
||||||
previous,
|
previous,
|
||||||
@@ -95,13 +92,13 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(stocks) == 0 {
|
if len(markets) == 0 {
|
||||||
return nil, ErrNoContent
|
return nil, ErrNoContent
|
||||||
}
|
}
|
||||||
|
|
||||||
if failed > 0 {
|
if failed > 0 {
|
||||||
return stocks, fmt.Errorf("%w: could not fetch data for %d stock(s)", ErrPartialContent, failed)
|
return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", ErrPartialContent, failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
return stocks, nil
|
return markets, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,14 @@ 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.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 +299,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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package widget
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/glanceapp/glance/internal/assets"
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
)
|
)
|
||||||
@@ -22,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 {
|
||||||
@@ -34,11 +32,8 @@ func (widget *Bookmarks) Initialize() error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(widget.Groups[g].Links[l].Icon, "si:") {
|
link := &widget.Groups[g].Links[l]
|
||||||
icon := strings.TrimPrefix(widget.Groups[g].Links[l].Icon, "si:")
|
link.Icon, link.IsSimpleIcon = toSimpleIconIfPrefixed(link.Icon)
|
||||||
widget.Groups[g].Links[l].IsSimpleIcon = true
|
|
||||||
widget.Groups[g].Links[l].Icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
|
"github.com/glanceapp/glance/internal/feed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChangeDetection struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
ChangeDetections feed.ChangeDetectionWatches `yaml:"-"`
|
||||||
|
WatchUUIDs []string `yaml:"watches"`
|
||||||
|
InstanceURL string `yaml:"instance-url"`
|
||||||
|
Token OptionalEnvString `yaml:"token"`
|
||||||
|
Limit int `yaml:"limit"`
|
||||||
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *ChangeDetection) Initialize() error {
|
||||||
|
widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour)
|
||||||
|
|
||||||
|
if widget.Limit <= 0 {
|
||||||
|
widget.Limit = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||||
|
widget.CollapseAfter = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.InstanceURL == "" {
|
||||||
|
widget.InstanceURL = "https://www.changedetection.io"
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *ChangeDetection) Update(ctx context.Context) {
|
||||||
|
if len(widget.WatchUUIDs) == 0 {
|
||||||
|
uuids, err := feed.FetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token))
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.WatchUUIDs = uuids
|
||||||
|
}
|
||||||
|
|
||||||
|
watches, err := feed.FetchWatchesFromChangeDetection(widget.InstanceURL, widget.WatchUUIDs, string(widget.Token))
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(watches) > widget.Limit {
|
||||||
|
watches = watches[:widget.Limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.ChangeDetections = watches
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *ChangeDetection) Render() template.HTML {
|
||||||
|
return widget.render(widget, assets.ChangeDetectionTemplate)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Clock struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
cachedHTML template.HTML `yaml:"-"`
|
||||||
|
HourFormat string `yaml:"hour-format"`
|
||||||
|
Timezones []struct {
|
||||||
|
Timezone string `yaml:"timezone"`
|
||||||
|
Label string `yaml:"label"`
|
||||||
|
} `yaml:"timezones"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Clock) Initialize() error {
|
||||||
|
widget.withTitle("Clock").withError(nil)
|
||||||
|
|
||||||
|
if widget.HourFormat == "" {
|
||||||
|
widget.HourFormat = "24h"
|
||||||
|
} else if widget.HourFormat != "12h" && widget.HourFormat != "24h" {
|
||||||
|
return errors.New("invalid hour format for clock widget, must be either 12h or 24h")
|
||||||
|
}
|
||||||
|
|
||||||
|
for t := range widget.Timezones {
|
||||||
|
if widget.Timezones[t].Timezone == "" {
|
||||||
|
return errors.New("missing timezone value for clock widget")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := time.LoadLocation(widget.Timezones[t].Timezone)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid timezone '%s' for clock widget: %v", widget.Timezones[t].Timezone, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.cachedHTML = widget.render(widget, assets.ClockTemplate)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Clock) Render() template.HTML {
|
||||||
|
return widget.cachedHTML
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
|
"github.com/glanceapp/glance/internal/feed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Extension struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
Parameters map[string]string `yaml:"parameters"`
|
||||||
|
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||||
|
Extension feed.Extension `yaml:"-"`
|
||||||
|
cachedHTML template.HTML `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Extension) Initialize() error {
|
||||||
|
widget.withTitle("Extension").withCacheDuration(time.Minute * 30)
|
||||||
|
|
||||||
|
if widget.URL == "" {
|
||||||
|
return errors.New("no extension URL specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := url.Parse(widget.URL)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Extension) Update(ctx context.Context) {
|
||||||
|
extension, err := feed.FetchExtension(feed.ExtensionRequestOptions{
|
||||||
|
URL: widget.URL,
|
||||||
|
Parameters: widget.Parameters,
|
||||||
|
AllowHtml: widget.AllowHtml,
|
||||||
|
})
|
||||||
|
|
||||||
|
widget.canContinueUpdateAfterHandlingErr(err)
|
||||||
|
|
||||||
|
widget.Extension = extension
|
||||||
|
|
||||||
|
if extension.Title != "" {
|
||||||
|
widget.Title = extension.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.cachedHTML = widget.render(widget, assets.ExtensionTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Extension) Render() template.HTML {
|
||||||
|
return widget.cachedHTML
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -150,3 +151,18 @@ 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) {
|
||||||
|
if !strings.HasPrefix(icon, "si:") {
|
||||||
|
return icon, false
|
||||||
|
}
|
||||||
|
|
||||||
|
icon = strings.TrimPrefix(icon, "si:")
|
||||||
|
icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
|
||||||
|
|
||||||
|
return icon, true
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||