Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
066e512844 | ||
|
|
d60457afaf | ||
|
|
f5a788220e | ||
|
|
2fd5edc6e4 | ||
|
|
26716d0b89 | ||
|
|
3e0f57d0c7 | ||
|
|
5347717f3d | ||
|
|
6b0a569998 | ||
|
|
4476391287 | ||
|
|
16a9d8c244 | ||
|
|
bbda9a0ee8 | ||
|
|
776fdcc6ce | ||
|
|
f76e06ec57 | ||
|
|
3a9cff697b | ||
|
|
e6979c77e3 | ||
|
|
99866507f5 | ||
|
|
cf0dd07c21 | ||
|
|
b9bf8c6c96 | ||
|
|
725d0da15d | ||
|
|
37164070d2 | ||
|
|
d7bd34531f | ||
|
|
71ca1753ef | ||
|
|
c6bfbf4ac1 | ||
|
|
a27fde72ee | ||
|
|
785f6a36bf | ||
|
|
1a4c12d851 | ||
|
|
b484e32b08 | ||
|
|
303438834b | ||
|
|
70b7b7615a | ||
|
|
5c42e60faf | ||
|
|
48ef60e0eb | ||
|
|
01af97ddab | ||
|
|
038794fa1c | ||
|
|
ffb1bccb10 | ||
|
|
371eb3bee6 | ||
|
|
466d935d02 | ||
|
|
7ffca9c3f1 | ||
|
|
a5981b3106 | ||
|
|
1df080983a | ||
|
|
822b72eee4 | ||
|
|
014abdcc00 | ||
|
|
b35cc437d3 | ||
|
|
7ce87c7168 | ||
|
|
2a3f8eedf6 | ||
|
|
895c3f2f97 | ||
|
|
8860f4591b | ||
|
|
984f26807e | ||
|
|
88c58e6108 | ||
|
|
83c7c4a14a | ||
|
|
b4a4df480e | ||
|
|
9843b4d218 | ||
|
|
c1251af597 | ||
|
|
7d09b8bf1b | ||
|
|
f57bdeec12 | ||
|
|
9899f6b761 | ||
|
|
328e10b89f | ||
|
|
b37f8a8375 | ||
|
|
139937f887 | ||
|
|
b8b90451b6 | ||
|
|
c9a2aff6a6 | ||
|
|
b25b117717 | ||
|
|
e174fd731c | ||
|
|
34e6d463be | ||
|
|
808169f43c | ||
|
|
82b6531a07 | ||
|
|
917e2cabfc | ||
|
|
a981025679 | ||
|
|
f9ea2d73e1 | ||
|
|
a5728e9407 | ||
|
|
404a2acc8f | ||
|
|
7bf61ee8ed | ||
|
|
bf1cc47525 | ||
|
|
74ad27d5fb | ||
|
|
60b73aff24 | ||
|
|
4de465e544 | ||
|
|
e1161b9227 | ||
|
|
c2cdd0fa08 | ||
|
|
b365a03e71 | ||
|
|
2e629efc7f | ||
|
|
fcaa2bef6e | ||
|
|
3b2a9fedef | ||
|
|
64256f2638 | ||
|
|
5c8122d93b | ||
|
|
6ffb95779d | ||
|
|
af975d0e7f | ||
|
|
34b2b88cf3 | ||
|
|
3fea166274 | ||
|
|
233b905492 | ||
|
|
7afad765cb | ||
|
|
39663cb594 | ||
|
|
457e0539fa | ||
|
|
871ba619a3 | ||
|
|
f5427310e8 | ||
|
|
e4435252f4 | ||
|
|
a66aa74c7f | ||
|
|
3949e15f77 | ||
|
|
acaf50bf8a | ||
|
|
09eedc08c1 | ||
|
|
738bcf8bcb | ||
|
|
795caa5d9d | ||
|
|
c041197f3f | ||
|
|
0730789cb3 | ||
|
|
1eb0fbd2c9 | ||
|
|
0484122b7f | ||
|
|
7bc9c704d1 | ||
|
|
c9efdc2c16 | ||
|
|
e0dee998d4 | ||
|
|
e09ea60616 | ||
|
|
7cab07fa01 | ||
|
|
5a62a8afe9 | ||
|
|
23d6d41148 | ||
|
|
a07fbfd7b7 | ||
|
|
1411268e35 | ||
|
|
9bda88edbd | ||
|
|
16da25898f | ||
|
|
88ae2b8e83 | ||
|
|
7d1ede8c91 | ||
|
|
3805f8b156 | ||
|
|
39f40670d0 | ||
|
|
514cf2b81c | ||
|
|
4dbb5975c0 | ||
|
|
b327e59ab1 | ||
|
|
917d51e54b | ||
|
|
122d05346b | ||
|
|
645802981e | ||
|
|
0348a2475c | ||
|
|
a2b7f59dc9 | ||
|
|
c23dc93c7a | ||
|
|
d3e3667405 | ||
|
|
d4ed5afe93 | ||
|
|
790ecef1c1 | ||
|
|
edb59ffdc6 | ||
|
|
e137038586 | ||
|
|
761eb343f9 | ||
|
|
6c15c907bf | ||
|
|
06b21e3d27 | ||
|
|
263d2e6f30 | ||
|
|
c97fec66f4 | ||
|
|
d5cf881d7a | ||
|
|
050d3e6cd2 | ||
|
|
757f68c872 | ||
|
|
b83bf9bc09 | ||
|
|
8d96e883d6 | ||
|
|
75079a981b | ||
|
|
e8043be107 | ||
|
|
c276f66017 | ||
|
|
db7df33b27 | ||
|
|
1b8e355944 | ||
|
|
342ef90cbe | ||
|
|
b3be60acf6 | ||
|
|
b58d3e2849 | ||
|
|
4206fb943d | ||
|
|
ee58ff40f1 | ||
|
|
21fd842bde | ||
|
|
b63b5eb262 | ||
|
|
9c69509a19 | ||
|
|
511147fb90 | ||
|
|
9ce0030482 | ||
|
|
4164263639 | ||
|
|
1bebb88d0e | ||
|
|
d2f5dbbc26 | ||
|
|
2af6500028 | ||
|
|
a7cf8be8c4 | ||
|
|
69b3e544bd | ||
|
|
0c63c61db5 | ||
|
|
8dc34ddfa7 | ||
|
|
8efda0047c | ||
|
|
e3c17e0347 | ||
|
|
038c43bc09 | ||
|
|
720549ec7e | ||
|
|
3aa1f273b6 | ||
|
|
5105ebb870 | ||
|
|
0a55c12f4f | ||
|
|
67c20c3a5f | ||
|
|
f4f7892123 | ||
|
|
00a93e466d | ||
|
|
d27993d2c9 | ||
|
|
bfc65d51e3 | ||
|
|
3c29d38599 | ||
|
|
f8816ff227 | ||
|
|
a5be460c2e | ||
|
|
e1e7853c34 | ||
|
|
3f862f67ab | ||
|
|
4bb5b2641b | ||
|
|
f044fdf5dc | ||
|
|
d1be4637ad | ||
|
|
d18f645c18 | ||
|
|
6663360caa | ||
|
|
c2d24ee4d9 | ||
|
|
37471fcdc1 | ||
|
|
0f42567d98 | ||
|
|
8148f09b9c | ||
|
|
c270afb5e5 | ||
|
|
6de0f73ec1 | ||
|
|
a80a34afb1 | ||
|
|
584f813fc4 | ||
|
|
a44056dc43 | ||
|
|
c941c78ebf | ||
|
|
9e9f001fdf | ||
|
|
9060887694 | ||
|
|
6d9ea27e9f | ||
|
|
4f4973157c | ||
|
|
859a6d2f27 | ||
|
|
e3ad646bae | ||
|
|
53a661861a | ||
|
|
4a27ec3271 | ||
|
|
926de71274 | ||
|
|
b0e57bae9f | ||
|
|
0bbb4573b2 | ||
|
|
eeda28a287 | ||
|
|
59c55b96be | ||
|
|
060609e2cb | ||
|
|
a1c48bf012 | ||
|
|
a67fb66d2e | ||
|
|
3001f4845a | ||
|
|
192c1ad669 | ||
|
|
89ae8a3607 | ||
|
|
21909e0bda | ||
|
|
5e9635a76f | ||
|
|
90d718c186 | ||
|
|
6627a635d5 | ||
|
|
20740f33af | ||
|
|
0681a04607 | ||
|
|
3cfbe0c89b | ||
|
|
21d491843f | ||
|
|
566ac99a53 | ||
|
|
fca504901f | ||
|
|
e03231cbd7 | ||
|
|
693051f4e9 | ||
|
|
06e891f9a9 | ||
|
|
a2bb1b88ea | ||
|
|
9602269f27 | ||
|
|
27b9af6067 | ||
|
|
d43c0749c1 | ||
|
|
f4fca7600a | ||
|
|
6134f1c812 | ||
|
|
8392b7a606 | ||
|
|
94cf82c3d9 | ||
|
|
b0018c3f06 | ||
|
|
8977114806 | ||
|
|
36f8eac3e4 | ||
|
|
0f01a8eda5 | ||
|
|
de965dace8 | ||
|
|
f0ca2edb25 | ||
|
|
514d5f92d1 | ||
|
|
7e05f8a5eb | ||
|
|
48d4d4e966 | ||
|
|
6a7b50b35f | ||
|
|
6cad5a8efb | ||
|
|
7743664527 | ||
|
|
714e1b6d29 | ||
|
|
a0585e097f | ||
|
|
6363b76d33 | ||
|
|
503562f2e5 | ||
|
|
01a7331354 | ||
|
|
c07a1ab636 | ||
|
|
eb9f92f04a | ||
|
|
c05607934b | ||
|
|
f9ca93e0b0 | ||
|
|
09b7d71103 | ||
|
|
bc06578e53 | ||
|
|
344f518991 | ||
|
|
6bb44bef20 | ||
|
|
aef0c84286 | ||
|
|
0fbc8dcb41 | ||
|
|
818138340c | ||
|
|
4beab6bcec | ||
|
|
b9b7ae1cac | ||
|
|
91c75518da | ||
|
|
82f51293df | ||
|
|
840ba0f240 | ||
|
|
c5e3eed64b | ||
|
|
7dd1cca65d | ||
|
|
57414bdb67 | ||
|
|
646be63895 | ||
|
|
3c1fb97c12 | ||
|
|
e78619f5e1 | ||
|
|
aa04904a72 | ||
|
|
54102ae35e | ||
|
|
7c4c1798b6 | ||
|
|
f30006093d | ||
|
|
c8ef47064a | ||
|
|
461c984317 | ||
|
|
ecbad7ce6c | ||
|
|
7adf624e95 | ||
|
|
56cb97706b | ||
|
|
dd5bec799f | ||
|
|
2389415071 | ||
|
|
976fb02bd3 | ||
|
|
007fe18d27 | ||
|
|
11f2999b52 | ||
|
|
e204029d3c | ||
|
|
d3eaae2fb0 | ||
|
|
251c22a14f | ||
|
|
7d0a7186e8 |
@@ -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,128 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
glanceapp@duck.com.
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
https://www.contributor-covenant.org/translations.
|
||||||
@@ -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,9 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
Security updates will be applied to the latest as well as previous minor version release depending on severity and if applicable.
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Please report any suspected security vulnerabilities to [glanceapp@duck.com](mailto:glanceapp@duck.com) and do not disclose them publicly. You should receive a response within a few days and if confirmed the issue will be resolved as soon as possible.
|
||||||
@@ -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,10 +1,13 @@
|
|||||||
FROM alpine:3.19
|
FROM golang:1.22.5-alpine3.20 AS builder
|
||||||
|
|
||||||
ARG TARGETOS
|
|
||||||
ARG TARGETARCH
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY build/glance-$TARGETOS-$TARGETARCH /app/glance
|
COPY . /app
|
||||||
|
RUN CGO_ENABLED=0 go build .
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/glance .
|
||||||
|
|
||||||
EXPOSE 8080/tcp
|
EXPOSE 8080/tcp
|
||||||
ENTRYPOINT ["/app/glance"]
|
ENTRYPOINT ["/app/glance"]
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY glance .
|
||||||
|
|
||||||
|
EXPOSE 8080/tcp
|
||||||
|
|
||||||
|
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,13 +10,18 @@
|
|||||||
* Subreddit posts
|
* Subreddit posts
|
||||||
* Weather
|
* Weather
|
||||||
* Bookmarks
|
* Bookmarks
|
||||||
|
* Hacker News
|
||||||
|
* Lobsters
|
||||||
* Latest YouTube videos from specific channels
|
* Latest YouTube videos from specific channels
|
||||||
|
* Clock
|
||||||
* Calendar
|
* Calendar
|
||||||
* Stocks
|
* Stocks
|
||||||
* iframe
|
* iframe
|
||||||
* Twitch channels & top games
|
* Twitch channels & top games
|
||||||
* GitHub releases
|
* GitHub releases
|
||||||
|
* Repository overview
|
||||||
* Site monitor
|
* Site monitor
|
||||||
|
* Search box
|
||||||
|
|
||||||
#### Themeable
|
#### Themeable
|
||||||

|

|
||||||
@@ -41,16 +46,16 @@ Checkout the [configuration docs](docs/configuration.md) to learn more. A [preco
|
|||||||
#### Manual
|
#### Manual
|
||||||
Checkout the [releases page](https://github.com/glanceapp/glance/releases) for available binaries. You can place the binary inside `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). To specify a different path for the config file use the `--config` option:
|
Checkout the [releases page](https://github.com/glanceapp/glance/releases) for available binaries. You can place the binary inside `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). To specify a different path for the config file use the `--config` option:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
/opt/glance/glance --config /etc/glance.yml
|
/opt/glance/glance --config /etc/glance.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
>
|
>
|
||||||
> Make sure you have a valid `glance.yml` file before running the container.
|
> Make sure you have a valid `glance.yml` file in the same directory before running the container.
|
||||||
|
|
||||||
```console
|
```bash
|
||||||
docker run -d -p 8080:8080 \
|
docker run -d -p 8080:8080 \
|
||||||
-v ./glance.yml:/app/glance.yml \
|
-v ./glance.yml:/app/glance.yml \
|
||||||
-v /etc/timezone:/etc/timezone:ro \
|
-v /etc/timezone:/etc/timezone:ro \
|
||||||
@@ -79,12 +84,28 @@ Requirements: [Go](https://go.dev/dl/) >= v1.22
|
|||||||
|
|
||||||
To build:
|
To build:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
go build .
|
go build -o build/glance .
|
||||||
```
|
```
|
||||||
|
|
||||||
To run:
|
To run:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Building Docker image
|
||||||
|
|
||||||
|
Build the image:
|
||||||
|
|
||||||
|
**Make sure to replace "owner" with your name or organization.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t owner/glance:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
Push the image to your registry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker push owner/glance:latest
|
||||||
|
```
|
||||||
|
|||||||
@@ -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 |
|
After Width: | Height: | Size: 128 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 |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 531 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: 365 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
Before Width: | Height: | Size: 341 KiB After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 549 KiB |
|
After Width: | Height: | Size: 330 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>
|
||||||
@@ -11,8 +11,30 @@ theme:
|
|||||||
contrast-multiplier: 1.1
|
contrast-multiplier: 1.1
|
||||||
```
|
```
|
||||||
|
|
||||||
### Catppuccin
|
### Catppuccin Frappe
|
||||||

|

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

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

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

|
||||||
|
```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
|
||||||
@@ -42,6 +74,18 @@ theme:
|
|||||||
|
|
||||||
## Light
|
## Light
|
||||||
|
|
||||||
|
### Catppuccin Latte
|
||||||
|

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

|

|
||||||
```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)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 7.8 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="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,163 +0,0 @@
|
|||||||
function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
|
||||||
let debounceTimeout;
|
|
||||||
let timesDebounced = 0;
|
|
||||||
|
|
||||||
return function () {
|
|
||||||
if (timesDebounced == maxDebounceTimes) {
|
|
||||||
clearTimeout(debounceTimeout);
|
|
||||||
timesDebounced = 0;
|
|
||||||
callback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(debounceTimeout);
|
|
||||||
timesDebounced++;
|
|
||||||
|
|
||||||
debounceTimeout = setTimeout(() => {
|
|
||||||
timesDebounced = 0;
|
|
||||||
callback();
|
|
||||||
}, debounceDelay);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
async function fetchPageContents (pageSlug) {
|
|
||||||
// TODO: handle non 200 status codes/time outs
|
|
||||||
// TODO: add retries
|
|
||||||
const response = await fetch(`/api/pages/${pageSlug}/content/`);
|
|
||||||
const content = await response.text();
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupCarousels() {
|
|
||||||
const carouselElements = document.getElementsByClassName("carousel-container");
|
|
||||||
|
|
||||||
for (let i = 0; i < carouselElements.length; i++) {
|
|
||||||
const carousel = carouselElements[i];
|
|
||||||
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
|
|
||||||
|
|
||||||
const determineSideCutoffs = () => {
|
|
||||||
if (itemsContainer.scrollLeft != 0) {
|
|
||||||
carousel.classList.add("show-left-cutoff");
|
|
||||||
} else {
|
|
||||||
carousel.classList.remove("show-left-cutoff");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.ceil(itemsContainer.scrollLeft) + itemsContainer.clientWidth < itemsContainer.scrollWidth) {
|
|
||||||
carousel.classList.add("show-right-cutoff");
|
|
||||||
} else {
|
|
||||||
carousel.classList.remove("show-right-cutoff");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
|
|
||||||
|
|
||||||
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
|
|
||||||
document.addEventListener("resize", determineSideCutoffsRateLimited);
|
|
||||||
|
|
||||||
determineSideCutoffs();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const minuteInSeconds = 60;
|
|
||||||
const hourInSeconds = minuteInSeconds * 60;
|
|
||||||
const dayInSeconds = hourInSeconds * 24;
|
|
||||||
const monthInSeconds = dayInSeconds * 30;
|
|
||||||
const yearInSeconds = monthInSeconds * 12;
|
|
||||||
|
|
||||||
function relativeTimeSince(timestamp) {
|
|
||||||
const delta = Math.round((Date.now() / 1000) - timestamp);
|
|
||||||
|
|
||||||
if (delta < minuteInSeconds) {
|
|
||||||
return "1m";
|
|
||||||
}
|
|
||||||
if (delta < hourInSeconds) {
|
|
||||||
return Math.floor(delta / minuteInSeconds) + "m";
|
|
||||||
}
|
|
||||||
if (delta < dayInSeconds) {
|
|
||||||
return Math.floor(delta / hourInSeconds) + "h";
|
|
||||||
}
|
|
||||||
if (delta < monthInSeconds) {
|
|
||||||
return Math.floor(delta / dayInSeconds) + "d";
|
|
||||||
}
|
|
||||||
if (delta < yearInSeconds) {
|
|
||||||
return Math.floor(delta / monthInSeconds) + "mo";
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.floor(delta / yearInSeconds) + "y";
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRelativeTimeForElements(elements)
|
|
||||||
{
|
|
||||||
for (let i = 0; i < elements.length; i++)
|
|
||||||
{
|
|
||||||
const element = elements[i];
|
|
||||||
const timestamp = element.dataset.dynamicRelativeTime;
|
|
||||||
|
|
||||||
if (timestamp === undefined)
|
|
||||||
continue
|
|
||||||
|
|
||||||
element.innerText = relativeTimeSince(timestamp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupDynamicRelativeTime() {
|
|
||||||
const elements = document.querySelectorAll("[data-dynamic-relative-time]");
|
|
||||||
const updateInterval = 60 * 1000;
|
|
||||||
let lastUpdateTime = Date.now();
|
|
||||||
|
|
||||||
const updateElementsAndTimestamp = () => {
|
|
||||||
updateRelativeTimeForElements(elements);
|
|
||||||
lastUpdateTime = Date.now();
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleRepeatingUpdate = () => setInterval(updateElementsAndTimestamp, updateInterval);
|
|
||||||
|
|
||||||
if (document.hidden === undefined) {
|
|
||||||
scheduleRepeatingUpdate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeout = scheduleRepeatingUpdate();
|
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", () => {
|
|
||||||
if (document.hidden) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delta = Date.now() - lastUpdateTime;
|
|
||||||
|
|
||||||
if (delta >= updateInterval) {
|
|
||||||
updateElementsAndTimestamp();
|
|
||||||
timeout = scheduleRepeatingUpdate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
updateElementsAndTimestamp();
|
|
||||||
timeout = scheduleRepeatingUpdate();
|
|
||||||
}, updateInterval - delta);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupPage() {
|
|
||||||
const pageElement = document.getElementById("page");
|
|
||||||
const pageContents = await fetchPageContents(pageData.slug);
|
|
||||||
|
|
||||||
pageElement.innerHTML = pageContents;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
document.body.classList.add("animate-element-transition");
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
setupCarousels();
|
|
||||||
setupDynamicRelativeTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
|
||||||
document.addEventListener("DOMContentLoaded", setupPage);
|
|
||||||
} else {
|
|
||||||
setupPage();
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "Glance",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#151519",
|
||||||
|
"scope": "/",
|
||||||
|
"start_url": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "app-icon.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"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,13 +23,22 @@ var (
|
|||||||
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
|
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
|
||||||
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
|
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
|
||||||
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
|
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
|
||||||
VideosTemplate = compileTemplate("videos.html", "widget-base.html")
|
ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html")
|
||||||
StocksTemplate = compileTemplate("stocks.html", "widget-base.html")
|
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
|
||||||
|
VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
|
||||||
|
MarketsTemplate = compileTemplate("markets.html", "widget-base.html")
|
||||||
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
|
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
|
||||||
RSSCardsTemplate = compileTemplate("rss-cards.html", "widget-base.html")
|
RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html")
|
||||||
|
RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
|
||||||
|
RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
|
||||||
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
|
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
|
||||||
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
|
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
|
||||||
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
|
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
|
||||||
|
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
|
||||||
|
SearchTemplate = compileTemplate("search.html", "widget-base.html")
|
||||||
|
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
|
||||||
|
GroupTemplate = compileTemplate("group.html", "widget-base.html")
|
||||||
|
DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html")
|
||||||
)
|
)
|
||||||
|
|
||||||
var globalTemplateFunctions = template.FuncMap{
|
var globalTemplateFunctions = template.FuncMap{
|
||||||
@@ -41,19 +51,6 @@ var globalTemplateFunctions = template.FuncMap{
|
|||||||
"formatPrice": func(price float64) string {
|
"formatPrice": func(price float64) string {
|
||||||
return intl.Sprintf("%.2f", price)
|
return intl.Sprintf("%.2f", price)
|
||||||
},
|
},
|
||||||
"formatTime": func(t time.Time) string {
|
|
||||||
return t.Format("2006-01-02 15:04:05")
|
|
||||||
},
|
|
||||||
"shouldCollapse": func(i int, collapseAfter int) bool {
|
|
||||||
if collapseAfter < -1 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return i >= collapseAfter
|
|
||||||
},
|
|
||||||
"itemAnimationDelay": func(i int, collapseAfter int) string {
|
|
||||||
return fmt.Sprintf("%dms", (i-collapseAfter)*30)
|
|
||||||
},
|
|
||||||
"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
|
"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
|
||||||
return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix()))
|
return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix()))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-24 list-with-separator">
|
<div class="dynamic-columns list-gap-24 list-with-separator">
|
||||||
{{ range .Groups }}
|
{{ range .Groups }}
|
||||||
<li class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
|
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
|
||||||
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
|
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
|
||||||
<ul class="list list-gap-2">
|
<ul class="list list-gap-2">
|
||||||
{{ range .Links }}
|
{{ range .Links }}
|
||||||
<li class="flex items-center gap-10">
|
<li class="flex items-center gap-10">
|
||||||
{{ if ne "" .Icon }}
|
{{ if ne "" .Icon }}
|
||||||
<div class="bookmarks-icon-container">
|
<div class="bookmarks-icon-container">
|
||||||
<img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
|
<img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ 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>
|
<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>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</div>
|
||||||
{{ end }}
|
{{ 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 }}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
{{ if .Page.ShowMobileHeader }}
|
||||||
|
<div class="mobile-reachability-header">{{ .Page.Title }}</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
<div class="page-columns">
|
<div class="page-columns">
|
||||||
{{ range .Page.Columns }}
|
{{ range .Page.Columns }}
|
||||||
<div class="page-column page-column-{{ .Size }}">
|
<div class="page-column page-column-{{ .Size }}">
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="widget-small-content-bounds dns-stats">
|
||||||
|
<div class="flex text-center justify-between dns-stats-totals">
|
||||||
|
<div>
|
||||||
|
<div class="color-highlight size-h3">{{ .Stats.TotalQueries | formatNumber }}</div>
|
||||||
|
<div class="size-h6">QUERIES</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="color-highlight size-h3">{{ .Stats.BlockedPercent }}%</div>
|
||||||
|
<div class="size-h6">BLOCKED</div>
|
||||||
|
</div>
|
||||||
|
{{ if gt .Stats.ResponseTime 0 }}
|
||||||
|
<div>
|
||||||
|
<div class="color-highlight size-h3">{{ .Stats.ResponseTime | formatNumber }}ms</div>
|
||||||
|
<div class="size-h6">LATENCY</div>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="cursor-help" data-popover-type="text" data-popover-text="Total number of blocked domains from all adlists" data-popover-max-width="200px" data-popover-text-align="center">
|
||||||
|
<div class="color-highlight size-h3">{{ .Stats.DomainsBlocked | formatViewerCount }}</div>
|
||||||
|
<div class="size-h6">DOMAINS</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dns-stats-graph margin-top-15">
|
||||||
|
<div class="dns-stats-graph-gridlines-container">
|
||||||
|
<svg class="dns-stats-graph-gridlines" shape-rendering="crispEdges" viewBox="0 0 1 100" preserveAspectRatio="none">
|
||||||
|
<g stroke="var(--color-graph-gridlines)" stroke-width="1">
|
||||||
|
<line x1="0" y1="1" x2="1" y2="1" vector-effect="non-scaling-stroke" />
|
||||||
|
<line x1="0" y1="25" x2="1" y2="25" vector-effect="non-scaling-stroke" />
|
||||||
|
<line x1="0" y1="50" x2="1" y2="50" vector-effect="non-scaling-stroke" />
|
||||||
|
<line x1="0" y1="75" x2="1" y2="75" vector-effect="non-scaling-stroke" />
|
||||||
|
<line x1="0" y1="99" x2="1" y2="99" vector-effect="non-scaling-stroke" stroke="var(--color-progress-bar-border)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dns-stats-graph-columns">
|
||||||
|
{{ range $i, $column := .Stats.Series }}
|
||||||
|
<div class="dns-stats-graph-column" data-popover-type="html" data-popover-position="above" data-popover-show-delay="500">
|
||||||
|
<div data-popover-html>
|
||||||
|
<div class="flex text-center justify-between gap-25">
|
||||||
|
<div>
|
||||||
|
<div class="color-highlight size-h3">{{ $column.Queries | formatNumber }}</div>
|
||||||
|
<div class="size-h6">QUERIES</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="color-highlight size-h3">{{ $column.PercentBlocked }}%</div>
|
||||||
|
<div class="size-h6">BLOCKED</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ if gt $column.PercentTotal 0}}
|
||||||
|
<div class="dns-stats-graph-bar" style="--bar-height: {{ $column.PercentTotal }}">
|
||||||
|
{{ if ne $column.Queries $column.Blocked }}
|
||||||
|
<div class="queries"></div>
|
||||||
|
{{ end }}
|
||||||
|
{{ if or (gt $column.Blocked 0) (and (lt $column.PercentTotal 15) (lt $column.PercentBlocked 10)) }}
|
||||||
|
<div class="blocked" style="flex-basis: {{ $column.PercentBlocked }}%"></div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<div class="dns-stats-graph-time">{{ index $.TimeLabels $i }}</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if .Stats.TopBlockedDomains }}
|
||||||
|
<details class="details margin-top-40">
|
||||||
|
<summary class="summary">Top blocked domains</summary>
|
||||||
|
<ul class="list list-gap-4 list-with-transition size-h5">
|
||||||
|
{{ range .Stats.TopBlockedDomains }}
|
||||||
|
<li class="flex justify-between align-center">
|
||||||
|
<div class="text-truncate rtl">{{ .Domain }}</div>
|
||||||
|
<div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -5,10 +5,17 @@
|
|||||||
<title>{{ block "document-title" . }}{{ end }}</title>
|
<title>{{ block "document-title" . }}{{ end }}</title>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<link rel="stylesheet" href="/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<script async src="/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script>
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Glance">
|
||||||
|
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
|
||||||
|
<link rel="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}">
|
||||||
|
<link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
|
||||||
|
<link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
|
||||||
|
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
|
||||||
|
<script type="module" src="{{ .App.AssetPath "js/main.js" }}"></script>
|
||||||
{{ block "document-head-after" . }}{{ end }}
|
{{ block "document-head-after" . }}{{ end }}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
{{ .Extension.Content }}
|
||||||
|
{{ end }}
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-14 list-collapsible">
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range $i, $post := .Posts }}
|
{{ range .Posts }}
|
||||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
<li>
|
||||||
<div class="forum-post-list-item thumbnail-container">
|
<div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
|
||||||
{{ if $.ShowThumbnails }}
|
{{ if $.ShowThumbnails }}
|
||||||
{{ if ne $post.ThumbnailUrl "" }}
|
{{ if .IsCrosspost }}
|
||||||
<img class="forum-post-list-thumbnail thumbnail" src="{{ $post.ThumbnailUrl }}" alt="" loading="lazy">
|
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||||
{{ else if $post.HasTargetUrl }}
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||||
|
</svg>
|
||||||
|
{{ else if ne .ThumbnailUrl "" }}
|
||||||
|
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
|
||||||
|
{{ else if .HasTargetUrl }}
|
||||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -18,14 +22,23 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div class="grow">
|
<div class="grow min-width-0">
|
||||||
<a href="{{ $post.DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .DiscussionUrl }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
|
{{ if gt (len .Tags) 0 }}
|
||||||
|
<div class="inline-block forum-post-tags-container">
|
||||||
|
<ul class="attachments">
|
||||||
|
{{ range .Tags }}
|
||||||
|
<li>{{ . }}</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li title="{{ $post.TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs $post.TimePosted }}>{{ $post.TimePosted | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li>{{ $post.Score | formatNumber }} points</li>
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
<li>{{ $post.CommentCount | formatNumber }} comments</li>
|
<li>{{ .CommentCount | formatNumber }} comments</li>
|
||||||
{{ if $post.HasTargetUrl }}
|
{{ if .HasTargetUrl }}
|
||||||
<li class="shrink min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ $post.TargetUrlDomain }}</a></li>
|
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,7 +46,4 @@
|
|||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
{{ if gt (len .Posts) $.CollapseAfter }}
|
|
||||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -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,39 +1,53 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-20 list-with-separator">
|
{{ if not (and .ShowFailingOnly (not .HasFailing)) }}
|
||||||
|
<ul class="dynamic-columns list-gap-20 list-with-separator">
|
||||||
{{ range .Sites }}
|
{{ range .Sites }}
|
||||||
<li class="monitor-site flex items-center gap-15">
|
{{ if and $.ShowFailingOnly (eq .StatusStyle "ok" ) }} {{ continue }} {{ end }}
|
||||||
{{ if .IconUrl }}
|
<div class="monitor-site flex items-center gap-15">
|
||||||
<img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy">
|
{{ template "site" . }}
|
||||||
{{ end }}
|
</div>
|
||||||
<div>
|
|
||||||
<a class="size-h3 color-highlight" href="{{ .Url }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
|
||||||
<ul class="list-horizontal-text">
|
|
||||||
{{ if not .Status.Error }}
|
|
||||||
<li>{{ .StatusText }}</li>
|
|
||||||
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
|
|
||||||
{{ else if .Status.TimedOut }}
|
|
||||||
<li class="color-negative">Timed Out</li>
|
|
||||||
{{ else }}
|
|
||||||
<li class="color-negative" title="{{ .Status.Error }}">ERROR</li>
|
|
||||||
{{ end }}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{{ if eq .StatusStyle "good" }}
|
|
||||||
<div class="monitor-site-status-icon">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
|
|
||||||
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{{ else }}
|
|
||||||
<div class="monitor-site-status-icon">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
|
|
||||||
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
</li>
|
|
||||||
{{ end }}
|
{{ 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 }}
|
||||||
|
|
||||||
|
{{ define "site" }}
|
||||||
|
{{ if .IconUrl }}
|
||||||
|
<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy">
|
||||||
|
{{ end }}
|
||||||
|
<div class="min-width-0">
|
||||||
|
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
{{ if not .Status.Error }}
|
||||||
|
<li title="{{ .Status.Code }}">{{ .StatusText }}</li>
|
||||||
|
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
|
||||||
|
{{ else if .Status.TimedOut }}
|
||||||
|
<li class="color-negative">Timed Out</li>
|
||||||
|
{{ else }}
|
||||||
|
<li class="color-negative" title="{{ .Status.Error }}">ERROR</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ if eq .StatusStyle "ok" }}
|
||||||
|
<div class="monitor-site-status-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
|
||||||
|
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="monitor-site-status-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
|
||||||
|
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -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,50 +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-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">
|
||||||
<span class="size-h3">Glance</span> ({{ .App.Version }})
|
{{ 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>
|
||||||
<ul class="list-horizontal-text margin-top-5 size-h5 color-primary">
|
{{ end }}
|
||||||
<li><a href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a></li>
|
|
||||||
<li><a href="https://github.com/glanceapp/glance/discussions" target="_blank" rel="noreferrer">Submit feedback</a></li>
|
<div class="mobile-navigation-offset"></div>
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text margin-top-7">
|
<ul class="list-horizontal-text margin-top-7">
|
||||||
<li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li>{{ .Score | formatNumber }} points</li>
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text margin-top-7">
|
<ul class="list-horizontal-text margin-top-7">
|
||||||
<li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li>{{ .Score | formatNumber }} points</li>
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-14 list-collapsible">
|
<ul class="list list-gap-10 collapsible-container single-line-titles" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range $i, $release := .Releases }}
|
{{ range .Releases }}
|
||||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
<li>
|
||||||
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
<div class="flex items-center gap-10">
|
||||||
|
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ .NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
|
{{ if $.ShowSourceIcon }}
|
||||||
|
<img class="simple-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li title="{{ $release.TimeReleased | formatTime }}" {{ dynamicRelativeTimeAttrs $release.TimeReleased }}>{{ $release.TimeReleased | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .TimeReleased }}></li>
|
||||||
<li>{{ $release.Version }}</li>
|
<li>{{ .Version }}</li>
|
||||||
{{ if gt $release.Downvotes 3 }}
|
{{ if gt .Downvotes 3 }}
|
||||||
<li>{{ $release.Downvotes | formatNumber }} ⚠</li>
|
<li>{{ .Downvotes | formatNumber }} ⚠</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
{{ if gt (len .Releases) $.CollapseAfter }}
|
|
||||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<a class="size-h4 color-highlight" href="https://github.com/{{ $.RepositoryDetails.Name }}" target="_blank" rel="noreferrer">{{ .RepositoryDetails.Name }}</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
<li>{{ .RepositoryDetails.Stars | formatNumber }} stars</li>
|
||||||
|
<li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{{ if gt (len .RepositoryDetails.Commits) 0 }}
|
||||||
|
<hr class="margin-block-10">
|
||||||
|
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
|
||||||
|
<div class="flex gap-7 size-h5 margin-top-3">
|
||||||
|
<ul class="list list-gap-2">
|
||||||
|
{{ range .RepositoryDetails.Commits }}
|
||||||
|
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
<ul class="list list-gap-2 min-width-0">
|
||||||
|
{{ range .RepositoryDetails.Commits }}
|
||||||
|
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Author }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/commit/{{ .Sha }}">{{ .Message }}</a></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if gt (len .RepositoryDetails.PullRequests) 0 }}
|
||||||
|
<hr class="margin-block-10">
|
||||||
|
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)</a>
|
||||||
|
<div class="flex gap-7 size-h5 margin-top-3">
|
||||||
|
<ul class="list list-gap-2">
|
||||||
|
{{ range .RepositoryDetails.PullRequests }}
|
||||||
|
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
<ul class="list list-gap-2 min-width-0">
|
||||||
|
{{ range .RepositoryDetails.PullRequests }}
|
||||||
|
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if gt (len .RepositoryDetails.Issues) 0 }}
|
||||||
|
<hr class="margin-block-10">
|
||||||
|
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .RepositoryDetails.OpenIssues | formatNumber }} total)</a>
|
||||||
|
<div class="flex gap-7 size-h5 margin-top-3">
|
||||||
|
<ul class="list list-gap-2">
|
||||||
|
{{ range .RepositoryDetails.Issues }}
|
||||||
|
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
<ul class="list list-gap-2 min-width-0">
|
||||||
|
{{ range .RepositoryDetails.Issues }}
|
||||||
|
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<ul class="list list-gap-24 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
|
{{ range .Items }}
|
||||||
|
<li class="flex gap-15 items-start row-reverse-on-mobile thumbnail-parent">
|
||||||
|
<div class="thumbnail-container rss-detailed-thumbnail">
|
||||||
|
{{ if ne "" .ImageURL }}
|
||||||
|
<img class="thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||||
|
{{ else }}
|
||||||
|
<svg class="scale-half hide-on-mobile" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||||
|
</svg>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="grow min-width-0">
|
||||||
|
<a class="size-h3 color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text flex-nowrap">
|
||||||
|
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
|
<li class="min-width-0">
|
||||||
|
<a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{{ if ne "" .Description }}
|
||||||
|
<p class="rss-detailed-description text-truncate-2-lines margin-top-10">{{ .Description }}</p>
|
||||||
|
{{ end }}
|
||||||
|
{{ if gt (len .Categories) 0 }}
|
||||||
|
<ul class="attachments margin-top-10">
|
||||||
|
{{ range .Categories }}
|
||||||
|
<li>{{ . }}</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{ else }}
|
||||||
|
<li>{{ .NoItemsMessage }}</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
{{ if gt (len .Items) 0 }}
|
||||||
|
<div class="carousel-container">
|
||||||
|
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
|
||||||
|
{{ range .Items }}
|
||||||
|
<div class="card rss-card-2 widget-content-frame thumbnail-parent">
|
||||||
|
{{ if ne "" .ImageURL }}
|
||||||
|
<img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||||
|
{{ else }}
|
||||||
|
<svg class="rss-card-2-image" style="transform: scale(0.35) translateY(-25%)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||||
|
</svg>
|
||||||
|
{{ end }}
|
||||||
|
<div class="rss-card-2-content padding-inline-widget">
|
||||||
|
<a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text flex-nowrap margin-top-5">
|
||||||
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
|
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
@@ -3,10 +3,11 @@
|
|||||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
|
{{ if gt (len .Items) 0 }}
|
||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
<div class="cards-horizontal carousel-items-container">
|
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
|
||||||
{{ range .Items }}
|
{{ range .Items }}
|
||||||
<div class="card widget-content-frame thumbnail-container">
|
<div class="card widget-content-frame thumbnail-parent">
|
||||||
{{ if ne "" .ImageURL }}
|
{{ if ne "" .ImageURL }}
|
||||||
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||||
{{ else }}
|
{{ else }}
|
||||||
@@ -17,12 +18,15 @@
|
|||||||
<div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
|
<div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
|
||||||
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
||||||
<li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
<li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
|
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
|
||||||
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-14 list-collapsible">
|
<ul class="list list-gap-14 collapsible-container{{ if .SingleLineTitles }} single-line-titles{{ end }}" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range $i, $item := .Items }}
|
{{ range .Items }}
|
||||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
<li>
|
||||||
<a class="size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a class="title size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text flex-nowrap">
|
||||||
<li title="{{ $item.PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs $item.PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
{{ if gt (len $.FeedRequests) 1 }}
|
<li class="min-width-0">
|
||||||
<li><a href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a></li>
|
<a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
|
||||||
{{ end }}
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
{{ else }}
|
||||||
|
<li>{{ .NoItemsMessage }}</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
{{ if gt (len .Items) $.CollapseAfter }}
|
|
||||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -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,23 +0,0 @@
|
|||||||
{{ template "widget-base.html" . }}
|
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
|
||||||
<ul class="list list-gap-20 list-with-separator">
|
|
||||||
{{ range .Stocks }}
|
|
||||||
<li class="flex items-center gap-15">
|
|
||||||
<div class="shrink min-width-0">
|
|
||||||
<div class="color-highlight size-h3 text-truncate">{{ .Symbol }}</div>
|
|
||||||
<div class="text-truncate">{{ .Name }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<svg class="stock-chart shrink-0" viewBox="0 0 100 50">
|
|
||||||
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="stock-values shrink-0">
|
|
||||||
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
|
|
||||||
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{{ end }}
|
|
||||||
</ul>
|
|
||||||
{{ end }}
|
|
||||||
@@ -1,27 +1,31 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-14 list-collapsible">
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range $i, $channel := .Channels }}
|
{{ range .Channels }}
|
||||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
<li>
|
||||||
<div class="{{ if $channel.IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-container">
|
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
|
||||||
<div class="twitch-channel-avatar-container">
|
<div class="twitch-channel-avatar-container">
|
||||||
{{ if $channel.Exists }}
|
{{ if .Exists }}
|
||||||
<img class="twitch-channel-avatar thumbnail" src="{{ $channel.AvatarUrl }}" alt="" loading="lazy">
|
<a href="https://twitch.tv/{{ .Login }}" class="twitch-channel-avatar-link" target="_blank" rel="noreferrer">
|
||||||
|
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
|
||||||
|
</a>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink min-width-0">
|
<div class="min-width-0">
|
||||||
<a href="https://twitch.tv/{{ $channel.Login }}" class="size-h3{{ if $channel.IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ $channel.Name }}</a>
|
<a href="https://twitch.tv/{{ .Login }}" class="size-h3{{ if .IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
{{ if $channel.Exists }}
|
{{ if .Exists }}
|
||||||
{{ if $channel.IsLive }}
|
{{ if .IsLive }}
|
||||||
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ $channel.CategorySlug }}" target="_blank" rel="noreferrer">{{ $channel.Category }}</a>
|
{{ if .Category }}
|
||||||
|
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a>
|
||||||
|
{{ end }}
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li title="{{ $channel.LiveSince | formatTime }}" {{ dynamicRelativeTimeAttrs $channel.LiveSince }}>{{ $channel.LiveSince | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
|
||||||
<li>{{ $channel.ViewersCount | formatViewerCount }} viewers</li>
|
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
|
||||||
</ul>
|
</ul>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<div>Offline</div>
|
<div>Offline</div>
|
||||||
@@ -34,7 +38,4 @@
|
|||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
{{ if gt (len .Channels) $.CollapseAfter }}
|
|
||||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-14 list-collapsible">
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range $i, $category := .Categories }}
|
{{ range .Categories }}
|
||||||
{{ $shouldCollapseItem := shouldCollapse $i $.CollapseAfter }}
|
<li class="twitch-category thumbnail-parent">
|
||||||
<li class="twitch-category thumbnail-container{{ if $shouldCollapseItem }} list-collapsible-item{{ end }}" {{ if $shouldCollapseItem }}style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
<div class="flex gap-10 items-start">
|
||||||
<div class="flex gap-10 items-center">
|
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ .AvatarUrl }}" alt="">
|
||||||
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ $category.AvatarUrl }}" alt="">
|
<div class="min-width-0">
|
||||||
<div class="shrink min-width-0">
|
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ $category.Slug }}" target="_blank" rel="noreferrer">{{ $category.Name }}</a>
|
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li>{{ $category.ViewersCount | formatViewerCount }} viewers</li>
|
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
|
||||||
{{ if $category.IsNew }}
|
{{ if .IsNew }}
|
||||||
<li class="color-primary">NEW</li>
|
<li class="color-primary">NEW</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="list-horizontal-text flex-nowrap">
|
<ul class="list-horizontal-text flex-nowrap">
|
||||||
{{ range $i, $tag := $category.Tags }}
|
{{ range $i, $tag := .Tags }}
|
||||||
{{ if eq $i 0 }}
|
{{ if eq $i 0 }}
|
||||||
<li class="shrink-0">{{ $tag.Name }}</li>
|
<li class="shrink-0">{{ $tag.Name }}</li>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<li class="text-truncate shrink min-width-0">{{ $tag.Name }}</li>
|
<li class="text-truncate min-width-0">{{ $tag.Name }}</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -29,7 +28,4 @@
|
|||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
{{ if gt (len .Categories) $.CollapseAfter }}
|
|
||||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{{ define "video-card-contents" }}
|
||||||
|
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
||||||
|
<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
|
||||||
|
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
||||||
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
|
<li class="min-width-0">
|
||||||
|
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="cards-grid collapsible-container" data-collapse-after-rows="{{ .CollapseAfterRows }}">
|
||||||
|
{{ range .Videos }}
|
||||||
|
<div class="card widget-content-frame thumbnail-parent">
|
||||||
|
{{ template "video-card-contents" . }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -4,19 +4,10 @@
|
|||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
<div class="videos cards-horizontal carousel-items-container">
|
<div class="cards-horizontal carousel-items-container">
|
||||||
{{ range .Videos }}
|
{{ range .Videos }}
|
||||||
<div class="card widget-content-frame thumbnail-container">
|
<div class="card widget-content-frame thumbnail-parent">
|
||||||
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
{{ template "video-card-contents" . }}
|
||||||
<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
|
|
||||||
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
|
|
||||||
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
|
||||||
<li class="shrink-0" title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
|
||||||
<li class="shrink min-width-0">
|
|
||||||
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,99 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := &DNSStats{
|
||||||
|
TotalQueries: responseJson.TotalQueries,
|
||||||
|
BlockedQueries: responseJson.BlockedQueries,
|
||||||
|
ResponseTime: int(responseJson.ResponseTime * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats.TotalQueries <= 0 {
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
|
||||||
|
|
||||||
|
var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
|
||||||
|
|
||||||
|
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,
|
||||||
|
PercentBlocked: int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adguard _should_ return data for the last 24 hours in a 1 hour interval
|
||||||
|
if len(responseJson.QueriesSeries) != 24 || len(responseJson.BlockedSeries) != 24 {
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
maxQueriesInSeries := 0
|
||||||
|
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
queries := 0
|
||||||
|
blocked := 0
|
||||||
|
|
||||||
|
for j := 0; j < 3; j++ {
|
||||||
|
queries += responseJson.QueriesSeries[i*3+j]
|
||||||
|
blocked += responseJson.BlockedSeries[i*3+j]
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.Series[i] = DNSStatsSeries{
|
||||||
|
Queries: queries,
|
||||||
|
Blocked: blocked,
|
||||||
|
PercentBlocked: int(float64(blocked) / float64(queries) * 100),
|
||||||
|
}
|
||||||
|
|
||||||
|
if queries > maxQueriesInSeries {
|
||||||
|
maxQueriesInSeries = queries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 8; 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,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,116 +2,225 @@ package feed
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type githubReleaseResponseJson struct {
|
type githubReleaseLatestResponseJson struct {
|
||||||
TagName string `json:"tag_name"`
|
TagName string `json:"tag_name"`
|
||||||
PublishedAt string `json:"published_at"`
|
PublishedAt string `json:"published_at"`
|
||||||
HtmlUrl string `json:"html_url"`
|
HtmlUrl string `json:"html_url"`
|
||||||
Draft bool `json:"draft"`
|
|
||||||
PreRelease bool `json:"prerelease"`
|
|
||||||
Reactions struct {
|
Reactions struct {
|
||||||
Downvotes int `json:"-1"`
|
Downvotes int `json:"-1"`
|
||||||
} `json:"reactions"`
|
} `json:"reactions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseGithubTime(t string) time.Time {
|
func fetchLatestGithubRelease(request *ReleaseRequest) (*AppRelease, error) {
|
||||||
parsedTime, err := time.Parse("2006-01-02T15:04:05Z", t)
|
httpRequest, err := http.NewRequest(
|
||||||
|
"GET",
|
||||||
if err != nil {
|
fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository),
|
||||||
return time.Now()
|
nil,
|
||||||
}
|
)
|
||||||
|
|
||||||
return parsedTime
|
|
||||||
}
|
|
||||||
|
|
||||||
func FetchLatestReleasesFromGithub(repositories []string, token string) (AppReleases, error) {
|
|
||||||
appReleases := make(AppReleases, 0, len(repositories))
|
|
||||||
|
|
||||||
if len(repositories) == 0 {
|
|
||||||
return appReleases, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
requests := make([]*http.Request, len(repositories))
|
|
||||||
|
|
||||||
for i, repository := range repositories {
|
|
||||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=10", repository), nil)
|
|
||||||
|
|
||||||
if token != "" {
|
|
||||||
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
|
||||||
}
|
|
||||||
|
|
||||||
requests[i] = request
|
|
||||||
}
|
|
||||||
|
|
||||||
task := decodeJsonFromRequestTask[[]githubReleaseResponseJson](defaultClient)
|
|
||||||
job := newJob(task, requests).withWorkers(15)
|
|
||||||
responses, errs, err := workerPoolDo(job)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var failed int
|
if request.Token != nil {
|
||||||
|
httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
|
||||||
|
}
|
||||||
|
|
||||||
for i := range responses {
|
response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest)
|
||||||
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 err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if len(releases) < 1 {
|
return &AppRelease{
|
||||||
failed++
|
Source: ReleaseSourceGithub,
|
||||||
slog.Error("No releases found", "repository", repositories[i], "url", requests[i].URL)
|
Name: request.Repository,
|
||||||
continue
|
Version: normalizeVersionFormat(response.TagName),
|
||||||
}
|
NotesUrl: response.HtmlUrl,
|
||||||
|
TimeReleased: parseRFC3339Time(response.PublishedAt),
|
||||||
|
Downvotes: response.Reactions.Downvotes,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
var liveRelease *githubReleaseResponseJson
|
type GithubTicket struct {
|
||||||
|
Number int
|
||||||
|
CreatedAt time.Time
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
for i := range releases {
|
type RepositoryDetails struct {
|
||||||
release := &releases[i]
|
Name string
|
||||||
|
Stars int
|
||||||
|
Forks int
|
||||||
|
OpenPullRequests int
|
||||||
|
PullRequests []GithubTicket
|
||||||
|
OpenIssues int
|
||||||
|
Issues []GithubTicket
|
||||||
|
LastCommits int
|
||||||
|
Commits []CommitDetails
|
||||||
|
}
|
||||||
|
|
||||||
if !release.Draft && !release.PreRelease {
|
type githubRepositoryDetailsResponseJson struct {
|
||||||
liveRelease = release
|
Name string `json:"full_name"`
|
||||||
break
|
Stars int `json:"stargazers_count"`
|
||||||
|
Forks int `json:"forks_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type githubTicketResponseJson struct {
|
||||||
|
Count int `json:"total_count"`
|
||||||
|
Tickets []struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommitDetails struct {
|
||||||
|
Sha string
|
||||||
|
Author string
|
||||||
|
CreatedAt time.Time
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitHubCommitResponseJson struct {
|
||||||
|
Sha string `json:"sha"`
|
||||||
|
Commit struct {
|
||||||
|
Author struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
} `json:"author"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"commit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int, maxCommits int) (RepositoryDetails, error) {
|
||||||
|
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
|
||||||
|
if err != nil {
|
||||||
|
return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil)
|
||||||
|
issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil)
|
||||||
|
CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repository, maxCommits), nil)
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
token = fmt.Sprintf("Bearer %s", token)
|
||||||
|
repositoryRequest.Header.Add("Authorization", token)
|
||||||
|
PRsRequest.Header.Add("Authorization", token)
|
||||||
|
issuesRequest.Header.Add("Authorization", token)
|
||||||
|
CommitsRequest.Header.Add("Authorization", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailsResponse githubRepositoryDetailsResponseJson
|
||||||
|
var detailsErr error
|
||||||
|
var PRsResponse githubTicketResponseJson
|
||||||
|
var PRsErr error
|
||||||
|
var issuesResponse githubTicketResponseJson
|
||||||
|
var issuesErr error
|
||||||
|
var commitsResponse []gitHubCommitResponseJson
|
||||||
|
var CommitsErr error
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go (func() {
|
||||||
|
defer wg.Done()
|
||||||
|
detailsResponse, detailsErr = decodeJsonFromRequest[githubRepositoryDetailsResponseJson](defaultClient, repositoryRequest)
|
||||||
|
})()
|
||||||
|
|
||||||
|
if maxPRs > 0 {
|
||||||
|
wg.Add(1)
|
||||||
|
go (func() {
|
||||||
|
defer wg.Done()
|
||||||
|
PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, PRsRequest)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxIssues > 0 {
|
||||||
|
wg.Add(1)
|
||||||
|
go (func() {
|
||||||
|
defer wg.Done()
|
||||||
|
issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, issuesRequest)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxCommits > 0 {
|
||||||
|
wg.Add(1)
|
||||||
|
go (func() {
|
||||||
|
defer wg.Done()
|
||||||
|
commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultClient, CommitsRequest)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if detailsErr != nil {
|
||||||
|
return RepositoryDetails{}, fmt.Errorf("%w: could not get repository details: %s", ErrNoContent, detailsErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
details := RepositoryDetails{
|
||||||
|
Name: detailsResponse.Name,
|
||||||
|
Stars: detailsResponse.Stars,
|
||||||
|
Forks: detailsResponse.Forks,
|
||||||
|
PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
|
||||||
|
Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)),
|
||||||
|
Commits: make([]CommitDetails, 0, len(commitsResponse)),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = nil
|
||||||
|
|
||||||
|
if maxPRs > 0 {
|
||||||
|
if PRsErr != nil {
|
||||||
|
err = fmt.Errorf("%w: could not get PRs: %s", ErrPartialContent, PRsErr)
|
||||||
|
} else {
|
||||||
|
details.OpenPullRequests = PRsResponse.Count
|
||||||
|
|
||||||
|
for i := range PRsResponse.Tickets {
|
||||||
|
details.PullRequests = append(details.PullRequests, GithubTicket{
|
||||||
|
Number: PRsResponse.Tickets[i].Number,
|
||||||
|
CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
|
||||||
|
Title: PRsResponse.Tickets[i].Title,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if liveRelease == nil {
|
if maxIssues > 0 {
|
||||||
slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
|
if issuesErr != nil {
|
||||||
continue
|
// TODO: fix, overwriting the previous error
|
||||||
|
err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, issuesErr)
|
||||||
|
} else {
|
||||||
|
details.OpenIssues = issuesResponse.Count
|
||||||
|
|
||||||
|
for i := range issuesResponse.Tickets {
|
||||||
|
details.Issues = append(details.Issues, GithubTicket{
|
||||||
|
Number: issuesResponse.Tickets[i].Number,
|
||||||
|
CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
|
||||||
|
Title: issuesResponse.Tickets[i].Title,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
version := liveRelease.TagName
|
if maxCommits > 0 {
|
||||||
|
if CommitsErr != nil {
|
||||||
if version[0] != 'v' {
|
err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, CommitsErr)
|
||||||
version = "v" + version
|
} 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],
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
appReleases = append(appReleases, AppRelease{
|
|
||||||
Name: repositories[i],
|
|
||||||
Version: version,
|
|
||||||
NotesUrl: liveRelease.HtmlUrl,
|
|
||||||
TimeReleased: parseGithubTime(liveRelease.PublishedAt),
|
|
||||||
Downvotes: liveRelease.Reactions.Downvotes,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(appReleases) == 0 {
|
return details, err
|
||||||
return nil, ErrNoContent
|
|
||||||
}
|
|
||||||
|
|
||||||
appReleases.SortByNewest()
|
|
||||||
|
|
||||||
if failed > 0 {
|
|
||||||
return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
|
|
||||||
}
|
|
||||||
|
|
||||||
return appReleases, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -18,8 +18,8 @@ type hackerNewsPostResponseJson struct {
|
|||||||
TimePosted int64 `json:"time"`
|
TimePosted int64 `json:"time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHackerNewsTopPostIds() ([]int, error) {
|
func getHackerNewsPostIds(sort string) ([]int, error) {
|
||||||
request, _ := http.NewRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", nil)
|
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
|
||||||
response, err := decodeJsonFromRequest[[]int](defaultClient, request)
|
response, err := decodeJsonFromRequest[[]int](defaultClient, request)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -83,8 +83,8 @@ func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (Forum
|
|||||||
return posts, nil
|
return posts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchHackerNewsTopPosts(limit int, commentsUrlTemplate string) (ForumPosts, error) {
|
func FetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (ForumPosts, error) {
|
||||||
postIds, err := getHackerNewsTopPostIds()
|
postIds, err := getHackerNewsPostIds(sort)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -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,109 @@
|
|||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"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 {
|
||||||
|
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,18 +88,46 @@ var currencyToSymbol = map[string]string{
|
|||||||
"PHP": "₱",
|
"PHP": "₱",
|
||||||
}
|
}
|
||||||
|
|
||||||
type Stock struct {
|
type DNSStats struct {
|
||||||
Name string
|
TotalQueries int
|
||||||
Symbol string
|
BlockedQueries int
|
||||||
Currency string
|
BlockedPercent int
|
||||||
Price float64
|
ResponseTime int
|
||||||
PercentChange float64
|
DomainsBlocked int
|
||||||
SvgChartPoints string
|
Series [8]DNSStatsSeries
|
||||||
|
TopBlockedDomains []DNSStatsBlockedDomain
|
||||||
}
|
}
|
||||||
|
|
||||||
type Stocks []Stock
|
type DNSStatsSeries struct {
|
||||||
|
Queries int
|
||||||
|
Blocked int
|
||||||
|
PercentTotal int
|
||||||
|
PercentBlocked int
|
||||||
|
}
|
||||||
|
|
||||||
func (t Stocks) SortByAbsChange() {
|
type DNSStatsBlockedDomain struct {
|
||||||
|
Domain string
|
||||||
|
PercentBlocked int
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarketRequest struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Symbol string `yaml:"symbol"`
|
||||||
|
ChartLink string `yaml:"chart-link"`
|
||||||
|
SymbolLink string `yaml:"symbol-link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Market struct {
|
||||||
|
MarketRequest
|
||||||
|
Currency string `yaml:"-"`
|
||||||
|
Price float64 `yaml:"-"`
|
||||||
|
PercentChange float64 `yaml:"-"`
|
||||||
|
SvgChartPoints string `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Markets []Market
|
||||||
|
|
||||||
|
func (t Markets) SortByAbsChange() {
|
||||||
sort.Slice(t, func(i, j int) bool {
|
sort.Slice(t, func(i, j int) bool {
|
||||||
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
|
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,17 +25,48 @@ type subredditResponseJson struct {
|
|||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
IsSelf bool `json:"is_self"`
|
IsSelf bool `json:"is_self"`
|
||||||
Thumbnail string `json:"thumbnail"`
|
Thumbnail string `json:"thumbnail"`
|
||||||
|
Flair string `json:"link_flair_text"`
|
||||||
|
ParentList []struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Subreddit string `json:"subreddit"`
|
||||||
|
Permalink string `json:"permalink"`
|
||||||
|
} `json:"crosspost_parent_list"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
} `json:"children"`
|
} `json:"children"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchSubredditPosts(subreddit string, commentsUrlTemplate string, requestUrlTemplate string) (ForumPosts, error) {
|
func templateRedditCommentsURL(template, subreddit, postId, postPath string) string {
|
||||||
subreddit = url.QueryEscape(subreddit)
|
template = strings.ReplaceAll(template, "{SUBREDDIT}", subreddit)
|
||||||
requestUrl := fmt.Sprintf("https://www.reddit.com/r/%s/hot.json", subreddit)
|
template = strings.ReplaceAll(template, "{POST-ID}", postId)
|
||||||
|
template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/"))
|
||||||
|
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (ForumPosts, error) {
|
||||||
|
query := url.Values{}
|
||||||
|
var requestUrl string
|
||||||
|
|
||||||
|
if search != "" {
|
||||||
|
query.Set("q", search+" subreddit:"+subreddit)
|
||||||
|
query.Set("sort", sort)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sort == "top" {
|
||||||
|
query.Set("t", topPeriod)
|
||||||
|
}
|
||||||
|
|
||||||
|
if search != "" {
|
||||||
|
requestUrl = fmt.Sprintf("https://www.reddit.com/search.json?%s", query.Encode())
|
||||||
|
} else {
|
||||||
|
requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode())
|
||||||
|
}
|
||||||
|
|
||||||
if requestUrlTemplate != "" {
|
if requestUrlTemplate != "" {
|
||||||
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
|
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
request, err := http.NewRequest("GET", requestUrl, nil)
|
request, err := http.NewRequest("GET", requestUrl, nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -68,9 +99,7 @@ func FetchSubredditPosts(subreddit string, commentsUrlTemplate string, requestUr
|
|||||||
if commentsUrlTemplate == "" {
|
if commentsUrlTemplate == "" {
|
||||||
commentsUrl = "https://www.reddit.com" + post.Permalink
|
commentsUrl = "https://www.reddit.com" + post.Permalink
|
||||||
} else {
|
} else {
|
||||||
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{SUBREDDIT}", subreddit)
|
commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
|
||||||
commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-ID}", post.Id)
|
|
||||||
commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-PATH}", strings.TrimLeft(post.Permalink, "/"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
forumPost := ForumPost{
|
forumPost := ForumPost{
|
||||||
@@ -90,10 +119,28 @@ func FetchSubredditPosts(subreddit string, commentsUrlTemplate string, requestUr
|
|||||||
forumPost.TargetUrl = post.Url
|
forumPost.TargetUrl = post.Url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if showFlairs && post.Flair != "" {
|
||||||
|
forumPost.Tags = append(forumPost.Tags, post.Flair)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(post.ParentList) > 0 {
|
||||||
|
forumPost.IsCrosspost = true
|
||||||
|
forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit
|
||||||
|
|
||||||
|
if commentsUrlTemplate == "" {
|
||||||
|
forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink
|
||||||
|
} else {
|
||||||
|
forumPost.TargetUrl = templateRedditCommentsURL(
|
||||||
|
commentsUrlTemplate,
|
||||||
|
post.ParentList[0].Subreddit,
|
||||||
|
post.ParentList[0].Id,
|
||||||
|
post.ParentList[0].Permalink,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
posts = append(posts, forumPost)
|
posts = append(posts, forumPost)
|
||||||
}
|
}
|
||||||
|
|
||||||
posts.CalculateEngagement()
|
|
||||||
|
|
||||||
return posts, nil
|
return posts, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReleaseSource string
|
||||||
|
|
||||||
|
const (
|
||||||
|
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 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||