Compare commits
215 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3b79c8e09f | ||
![]() |
d7bbf2b8e2 | ||
![]() |
b8df34309f | ||
![]() |
278fa3c397 | ||
![]() |
6f48ee98e5 | ||
![]() |
774b0c104b | ||
![]() |
fbd4d9a74e | ||
![]() |
3f2fefe4f7 | ||
![]() |
fb8513bc9f | ||
![]() |
e4ec958edb | ||
![]() |
488a1f6070 | ||
![]() |
1e12d937aa | ||
![]() |
7319870289 | ||
![]() |
d4565acfe7 | ||
![]() |
16129c53bd | ||
![]() |
cbf1961510 | ||
![]() |
c76a4d4be7 | ||
![]() |
27af0400c0 | ||
![]() |
76a80ff034 | ||
![]() |
f7f333ad52 | ||
![]() |
0ce45e32aa | ||
![]() |
abeb11c8a6 | ||
![]() |
e01af4adec | ||
![]() |
3043a0bd15 | ||
![]() |
232cab01f8 | ||
![]() |
b301953249 | ||
![]() |
bb9cb03c8a | ||
![]() |
d8a4d39849 | ||
![]() |
0d6966726e | ||
![]() |
bb26725c67 | ||
![]() |
103fe5718b | ||
![]() |
a0d1bf1788 | ||
![]() |
37f35281b4 | ||
![]() |
306fb3cb33 | ||
![]() |
24a6107171 | ||
![]() |
e495fbfd99 | ||
![]() |
da6fbc4995 | ||
![]() |
8d673e1229 | ||
![]() |
d8c7d02f5e | ||
![]() |
8331c4b305 | ||
![]() |
5a06f4788b | ||
![]() |
827171e206 | ||
![]() |
c3f3fa9876 | ||
![]() |
825ef84da5 | ||
![]() |
4445d46f61 | ||
![]() |
c583057d45 | ||
![]() |
982f3ee2a1 | ||
![]() |
cd1e4e9f91 | ||
![]() |
33f8e6d144 | ||
![]() |
af70cc83ba | ||
![]() |
8b731fd9e5 | ||
![]() |
a0c697b982 | ||
![]() |
21a6fe407b | ||
![]() |
1c1a7b3e27 | ||
![]() |
61fd5481cc | ||
![]() |
d400b27545 | ||
![]() |
ec9d455d18 | ||
![]() |
8a75f303bb | ||
![]() |
0ce706e02e | ||
![]() |
3076593021 | ||
![]() |
a83389bb7d | ||
![]() |
ee1cde8a04 | ||
![]() |
a9efe88461 | ||
![]() |
c8200f2972 | ||
![]() |
80b43d3e0c | ||
![]() |
a7e235441b | ||
![]() |
bb80637e72 | ||
![]() |
8fb5de4e87 | ||
![]() |
1c03f0a07a | ||
![]() |
b4ac96ccaf | ||
![]() |
5723fbdea5 | ||
![]() |
260bc15577 | ||
![]() |
59bfe3e835 | ||
![]() |
086fac4120 | ||
![]() |
46bc36ad4a | ||
![]() |
ed477e8cc6 | ||
![]() |
8b878149d4 | ||
![]() |
db512419ea | ||
![]() |
491bc65a38 | ||
![]() |
94ec286de6 | ||
![]() |
108c83588c | ||
![]() |
abbb4950a5 | ||
![]() |
0fe0b34898 | ||
![]() |
8ad7d4be0a | ||
![]() |
ac7f3805d4 | ||
![]() |
e524dd111e | ||
![]() |
4a14709f13 | ||
![]() |
85b8ee493e | ||
![]() |
e546488aeb | ||
![]() |
0702e0abd3 | ||
![]() |
29318005b2 | ||
![]() |
b4b61c94d7 | ||
![]() |
f18620d890 | ||
![]() |
eb86c3fb79 | ||
![]() |
f89fc2ee1d | ||
![]() |
495eaa0a37 | ||
![]() |
b44b7b80ff | ||
![]() |
c8ff5362a3 | ||
![]() |
d575a0a29f | ||
![]() |
2e1134fdfb | ||
![]() |
ab6ae15836 | ||
![]() |
a4840c7365 | ||
![]() |
dbcc13a5cf | ||
![]() |
8d2639b349 | ||
![]() |
889b034a79 | ||
![]() |
e49145023f | ||
![]() |
ae1fb05607 | ||
![]() |
80a957bc5b | ||
![]() |
d4b1d240b9 | ||
![]() |
7f667e2d1c | ||
![]() |
a1afdb0c63 | ||
![]() |
8adad18198 | ||
![]() |
49a0758aae | ||
![]() |
e3bdc73013 | ||
![]() |
1922e1e895 | ||
![]() |
7ee8e7c8d2 | ||
![]() |
e5106c0704 | ||
![]() |
80331aa217 | ||
![]() |
84537e6884 | ||
![]() |
f7062115bc | ||
![]() |
24f9f20eb7 | ||
![]() |
217c6f2ed0 | ||
![]() |
82490ef5dd | ||
![]() |
fcda017c39 | ||
![]() |
0c36925783 | ||
![]() |
fcb67e62c5 | ||
![]() |
0651a2886e | ||
![]() |
d876f4cb39 | ||
![]() |
b5259d1a98 | ||
![]() |
ffe053ffc5 | ||
![]() |
1785af4749 | ||
![]() |
98b4b7330e | ||
![]() |
f68e5ae9ef | ||
![]() |
b3e73ce86a | ||
![]() |
02cbb5f812 | ||
![]() |
1d9ae72c81 | ||
![]() |
d6470ae814 | ||
![]() |
a816d1a913 | ||
![]() |
2c03316f86 | ||
![]() |
d19214a390 | ||
![]() |
03035d1a2d | ||
![]() |
6886716e67 | ||
![]() |
6165308c23 | ||
![]() |
77a9469ff8 | ||
![]() |
2dce9b4c48 | ||
![]() |
74e05763f7 | ||
![]() |
a4185fde07 | ||
![]() |
ebb519e6d8 | ||
![]() |
90fbba600f | ||
![]() |
4bd4921131 | ||
![]() |
44ee813c6f | ||
![]() |
3e467c5021 | ||
![]() |
2b0dd3ab99 | ||
![]() |
2618346a32 | ||
![]() |
eacbb14279 | ||
![]() |
c8570d07ef | ||
![]() |
7e345dd1f9 | ||
![]() |
afe6ad6bfc | ||
![]() |
af4c1e8514 | ||
![]() |
d4fecd1dd8 | ||
![]() |
79779eb721 | ||
![]() |
6af666b58c | ||
![]() |
42872136a0 | ||
![]() |
aca7b2ac5f | ||
![]() |
a646f19b3f | ||
![]() |
9c5298aebf | ||
![]() |
c6b07852fe | ||
![]() |
40d3053df9 | ||
![]() |
5e576a58e9 | ||
![]() |
4d63b5dda1 | ||
![]() |
c1efd3f68b | ||
![]() |
2841cc67e8 | ||
![]() |
becf34b0d9 | ||
![]() |
1bba915aef | ||
![]() |
f7239137d6 | ||
![]() |
d656986413 | ||
![]() |
d0c4e9d846 | ||
![]() |
bacb607d90 | ||
![]() |
3914e24b3d | ||
![]() |
4582be1da5 | ||
![]() |
c3c7f8b14f | ||
![]() |
863d4f117b | ||
![]() |
35038ed56b | ||
![]() |
c4149f5d94 | ||
![]() |
de2db71e54 | ||
![]() |
2e3ed896cc | ||
![]() |
f6f352e9e0 | ||
![]() |
33dede8abc | ||
![]() |
1e2e66ecf7 | ||
![]() |
6e5140d859 | ||
![]() |
84a7f90129 | ||
![]() |
aff02c2def | ||
![]() |
2dd5b29303 | ||
![]() |
bc8f17393a | ||
![]() |
72f78f1c8d | ||
![]() |
201b4ca1e8 | ||
![]() |
04811a0658 | ||
![]() |
ee94d6aa89 | ||
![]() |
3382cd6208 | ||
![]() |
56f9ec1d10 | ||
![]() |
1b42312d02 | ||
![]() |
8a41a9f506 | ||
![]() |
18bb2d7501 | ||
![]() |
6f8e576c9b | ||
![]() |
e5bb102ab1 | ||
![]() |
13700fe2b2 | ||
![]() |
3cfbe65855 | ||
![]() |
ab0b11cc92 | ||
![]() |
17c8071de9 | ||
![]() |
1fe7f61ec8 | ||
![]() |
ea3b8124fc | ||
![]() |
c41cfadb19 | ||
![]() |
e434fe0847 | ||
![]() |
8a8aaa752e | ||
![]() |
4420d1df2c |
|
@ -5,6 +5,7 @@
|
|||
# Only add necessary files to the Docker build context (Dockerfiles are always included implicitly)
|
||||
!/build/
|
||||
!/internal/
|
||||
!/pkg/
|
||||
!/go.mod
|
||||
!/go.sum
|
||||
!main.go
|
||||
|
|
37
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
|
@ -0,0 +1,37 @@
|
|||
name: Bug report
|
||||
description: Let us know if something isn't working as expected
|
||||
labels: ["bug report"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!NOTE]
|
||||
>
|
||||
> Do not prefix your title with "[BUG]", "[Bug report]", etc., a label will be added automatically.
|
||||
|
||||
If you're unsure whether you're experiencing a bug or not, consider using the [Discussions](https://github.com/glanceapp/glance/discussions) or [Discord](https://discord.com/invite/7KQ7Xa9kJd) to ask for help.
|
||||
|
||||
Please include only the information you think is relevant to the bug:
|
||||
|
||||
* How did you install Glance? (Docker container, manual binary install, etc)
|
||||
* Which version of Glance are you using?
|
||||
* Include the relevant parts of your `glance.yml` if applicable (widget, data source, properties used, etc)
|
||||
* Include any relevant logs or screenshots if applicable
|
||||
* Is the issue specific to a certain browser or OS?
|
||||
* Steps to reliably reproduce the issue
|
||||
* Are you hosting Glance on a VPS?
|
||||
* Anything else you think might be relevant
|
||||
|
||||
**No need to copy the above list into your description, it's just a guide to help you provide the most useful information.**
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Description
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to submit a bug report.
|
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discussions
|
||||
url: https://github.com/glanceapp/glance/discussions
|
||||
about: For help, feedback, guides, resources and more
|
||||
- name: Discord
|
||||
url: https://discord.com/invite/7KQ7Xa9kJd
|
||||
about: Much like the discussions but more chatty
|
33
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
|
@ -0,0 +1,33 @@
|
|||
name: Feature request
|
||||
description: Share your ideas for new features or improvements
|
||||
labels: ["feature request"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!NOTE]
|
||||
>
|
||||
> Do not prefix your title with "[REQUEST]", "[Feature request]", etc., a label will be added automatically.
|
||||
|
||||
Please provide a detailed description of what the feature would do and what it would look like:
|
||||
|
||||
* What problem would this feature solve?
|
||||
* Are there any potential downsides to this feature?
|
||||
* If applicable, what would the configuration for this feature look like?
|
||||
* Are there any existing examples of this feature in other software?
|
||||
* If applicable, include any external documentation required to implement this feature
|
||||
* Anything else you think might be relevant
|
||||
|
||||
**No need to copy the above list into your description, it's just a guide to help you provide the most useful information.**
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Description
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to submit your idea.
|
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
@ -1,7 +1 @@
|
|||
<!--
|
||||
|
||||
If your pull request adds new features or changes existing ones please use the latest release/* branch as the base.
|
||||
|
||||
Documentation updates (including new themes) can be submitted to the main branch.
|
||||
|
||||
-->
|
||||
<!-- If your pull request adds new features, changes existing ones or fixes any bugs, please use the dev branch as the base, otherwise use the main branch -->
|
||||
|
|
3
.gitignore
vendored
|
@ -1,4 +1,5 @@
|
|||
/assets
|
||||
/build
|
||||
/playground
|
||||
glance*.yml
|
||||
/.idea
|
||||
/glance*.yml
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
FROM golang:1.22.5-alpine3.20 AS builder
|
||||
FROM golang:1.23.6-alpine3.21 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
RUN CGO_ENABLED=0 go build .
|
||||
|
||||
FROM alpine:3.20
|
||||
FROM alpine:3.21
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/glance .
|
||||
|
||||
HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
|
||||
CMD wget --spider -q http://localhost:8080/api/healthz
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
ENTRYPOINT ["/app/glance"]
|
||||
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
FROM alpine:3.20
|
||||
FROM alpine:3.21
|
||||
|
||||
WORKDIR /app
|
||||
COPY glance .
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
|
||||
CMD wget --spider -q http://localhost:8080/api/healthz
|
||||
|
||||
ENTRYPOINT ["/app/glance"]
|
||||
EXPOSE 8080/tcp
|
||||
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
||||
|
|
422
README.md
|
@ -2,110 +2,404 @@
|
|||
<h1 align="center">Glance</h1>
|
||||
<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>
|
||||
|
||||

|
||||

|
||||
|
||||
### Features
|
||||
#### Various widgets
|
||||
## Features
|
||||
### Various widgets
|
||||
* RSS feeds
|
||||
* Subreddit posts
|
||||
* Weather
|
||||
* Bookmarks
|
||||
* Hacker News
|
||||
* Lobsters
|
||||
* Latest YouTube videos from specific channels
|
||||
* Clock
|
||||
* Calendar
|
||||
* Stocks
|
||||
* iframe
|
||||
* Twitch channels & top games
|
||||
* GitHub releases
|
||||
* Repository overview
|
||||
* Site monitor
|
||||
* Search box
|
||||
* Hacker News posts
|
||||
* Weather forecasts
|
||||
* YouTube channel uploads
|
||||
* Twitch channels
|
||||
* Market prices
|
||||
* Docker containers status
|
||||
* Server stats
|
||||
* Custom widgets
|
||||
* [and many more...](docs/configuration.md)
|
||||
|
||||
#### Themeable
|
||||

|
||||
### Fast and lightweight
|
||||
* Low memory usage
|
||||
* Few dependencies
|
||||
* Minimal vanilla JS
|
||||
* Single <20mb binary available for multiple OSs & architectures and just as small Docker container
|
||||
* Uncached pages usually load within ~1s (depending on internet speed and number of widgets)
|
||||
|
||||
#### Optimized for mobile devices
|
||||

|
||||
### Tons of customizability
|
||||
* Different layouts
|
||||
* As many pages/tabs as you need
|
||||
* Numerous configuration options for each widget
|
||||
* Multiple styles for some widgets
|
||||
* Custom CSS
|
||||
|
||||
#### Fast and lightweight
|
||||
* Minimal JS, no bloated frameworks
|
||||
* Very few dependencies
|
||||
* Single, easily distributed <15mb binary and just as small docker container
|
||||
* All requests are parallelized, uncached pages usually load within ~1s (depending on internet speed and number of widgets)
|
||||
### Optimized for mobile devices
|
||||
Because you'll want to take it with you on the go.
|
||||
|
||||
### Configuration
|
||||
Checkout the [configuration docs](docs/configuration.md) to learn more. A [preconfigured page](docs/configuration.md#preconfigured-page) is also available to get you started quickly.
|
||||

|
||||
|
||||
### Installation
|
||||
> [!CAUTION]
|
||||
>
|
||||
> The project is under active development, expect things to break every once in a while.
|
||||
### Themeable
|
||||
Easily create your own theme by tweaking a few numbers or choose from one of the [already available themes](docs/themes.md).
|
||||
|
||||
#### Manual
|
||||
Checkout the [releases page](https://github.com/glanceapp/glance/releases) for available binaries. You can place the binary inside `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). To specify a different path for the config file use the `--config` option:
|
||||

|
||||
|
||||
<br>
|
||||
|
||||
## Configuration
|
||||
Configuration is done through YAML files, to learn more about how the layout works, how to add more pages and how to configure widgets, visit the [configuration documentation](docs/configuration.md).
|
||||
|
||||
<details>
|
||||
<summary><strong>Preview example configuration file</strong></summary>
|
||||
<br>
|
||||
|
||||
```yaml
|
||||
pages:
|
||||
- name: Home
|
||||
columns:
|
||||
- size: small
|
||||
widgets:
|
||||
- type: calendar
|
||||
first-day-of-week: monday
|
||||
|
||||
- type: rss
|
||||
limit: 10
|
||||
collapse-after: 3
|
||||
cache: 12h
|
||||
feeds:
|
||||
- url: https://selfh.st/rss/
|
||||
title: selfh.st
|
||||
limit: 4
|
||||
- url: https://ciechanow.ski/atom.xml
|
||||
- url: https://www.joshwcomeau.com/rss.xml
|
||||
title: Josh Comeau
|
||||
- url: https://samwho.dev/rss.xml
|
||||
- url: https://ishadeed.com/feed.xml
|
||||
title: Ahmad Shadeed
|
||||
|
||||
- type: twitch-channels
|
||||
channels:
|
||||
- theprimeagen
|
||||
- j_blow
|
||||
- piratesoftware
|
||||
- cohhcarnage
|
||||
- christitustech
|
||||
- EJ_SA
|
||||
|
||||
- size: full
|
||||
widgets:
|
||||
- type: group
|
||||
widgets:
|
||||
- type: hacker-news
|
||||
- type: lobsters
|
||||
|
||||
- type: videos
|
||||
channels:
|
||||
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
|
||||
- UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
|
||||
- UCsBjURrPoezykLs9EqgamOA # Fireship
|
||||
- UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
|
||||
- UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
|
||||
|
||||
- type: group
|
||||
widgets:
|
||||
- type: reddit
|
||||
subreddit: technology
|
||||
show-thumbnails: true
|
||||
- type: reddit
|
||||
subreddit: selfhosted
|
||||
show-thumbnails: true
|
||||
|
||||
- size: small
|
||||
widgets:
|
||||
- type: weather
|
||||
location: London, United Kingdom
|
||||
units: metric
|
||||
hour-format: 12h
|
||||
|
||||
- type: markets
|
||||
markets:
|
||||
- symbol: SPY
|
||||
name: S&P 500
|
||||
- symbol: BTC-USD
|
||||
name: Bitcoin
|
||||
- symbol: NVDA
|
||||
name: NVIDIA
|
||||
- symbol: AAPL
|
||||
name: Apple
|
||||
- symbol: MSFT
|
||||
name: Microsoft
|
||||
|
||||
- type: releases
|
||||
cache: 1d
|
||||
repositories:
|
||||
- glanceapp/glance
|
||||
- go-gitea/gitea
|
||||
- immich-app/immich
|
||||
- syncthing/syncthing
|
||||
```
|
||||
</details>
|
||||
|
||||
<br>
|
||||
|
||||
## Installation
|
||||
|
||||
Choose one of the following methods:
|
||||
|
||||
<details>
|
||||
<summary><strong>Docker compose using provided directory structure (recommended)</strong></summary>
|
||||
<br>
|
||||
|
||||
Create a new directory called `glance` as well as the template files within it by running:
|
||||
|
||||
```bash
|
||||
mkdir glance && cd glance && curl -sL https://github.com/glanceapp/docker-compose-template/archive/refs/heads/main.tar.gz | tar -xzf - --strip-components 2
|
||||
```
|
||||
|
||||
*[click here to view the files that will be created](https://github.com/glanceapp/docker-compose-template/tree/main/root)*
|
||||
|
||||
Then, edit the following files as desired:
|
||||
* `docker-compose.yml` to configure the port, volumes and other containery things
|
||||
* `config/home.yml` to configure the widgets or layout of the home page
|
||||
* `config/glance.yml` if you want to change the theme or add more pages
|
||||
|
||||
<details>
|
||||
<summary>Other files you may want to edit</summary>
|
||||
|
||||
* `.env` to configure environment variables that will be available inside configuration files
|
||||
* `assets/user.css` to add custom CSS
|
||||
</details>
|
||||
|
||||
When ready, run:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
If you encounter any issues, you can check the logs by running:
|
||||
|
||||
```bash
|
||||
docker compose logs
|
||||
```
|
||||
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Docker compose manual</strong></summary>
|
||||
<br>
|
||||
|
||||
Create a `docker-compose.yml` file with the following contents:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
glance:
|
||||
container_name: glance
|
||||
image: glanceapp/glance
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
ports:
|
||||
- 8080:8080
|
||||
```
|
||||
|
||||
Then, create a new directory called `config` and download the example starting [`glance.yml`](https://github.com/glanceapp/glance/blob/main/docs/glance.yml) file into it by running:
|
||||
|
||||
```bash
|
||||
mkdir config && wget -O config/glance.yml https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
|
||||
```
|
||||
|
||||
Feel free to edit the `glance.yml` file to your liking, and when ready run:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
If you encounter any issues, you can check the logs by running:
|
||||
|
||||
```bash
|
||||
docker logs glance
|
||||
```
|
||||
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Manual binary installation</strong></summary>
|
||||
<br>
|
||||
|
||||
Precompiled binaries are available for Linux, Windows and macOS (x86, x86_64, ARM and ARM64 architectures).
|
||||
|
||||
### Linux
|
||||
|
||||
Visit the [latest release page](https://github.com/glanceapp/glance/releases/latest) for available binaries. You can place the binary in `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). By default, when running the binary, it will look for a `glance.yml` file in the directory it's placed in. To specify a different path for the config file, use the `--config` option:
|
||||
|
||||
```bash
|
||||
/opt/glance/glance --config /etc/glance.yml
|
||||
```
|
||||
|
||||
#### Docker
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> Make sure you have a valid `glance.yml` file in the same directory before running the container.
|
||||
To grab a starting template for the config file, run:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8080:8080 \
|
||||
-v ./glance.yml:/app/glance.yml \
|
||||
-v /etc/timezone:/etc/timezone:ro \
|
||||
-v /etc/localtime:/etc/localtime:ro \
|
||||
glanceapp/glance
|
||||
wget https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
|
||||
```
|
||||
|
||||
Or if you prefer docker compose:
|
||||
### Windows
|
||||
|
||||
```yaml
|
||||
services:
|
||||
glance:
|
||||
image: glanceapp/glance
|
||||
volumes:
|
||||
- ./glance.yml:/app/glance.yml
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- 8080:8080
|
||||
restart: unless-stopped
|
||||
```
|
||||
Download and extract the executable from the [latest release](https://github.com/glanceapp/glance/releases/latest) (most likely the file called `glance-windows-amd64.zip` if you're on a 64-bit system) and place it in a folder of your choice. Then, create a new text file called `glance.yml` in the same folder and paste the content from [here](https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml) in it. You should then be able to run the executable and access the dashboard by visiting `http://localhost:8080` in your browser.
|
||||
|
||||
### Building from source
|
||||
|
||||
Requirements: [Go](https://go.dev/dl/) >= v1.22
|
||||
|
||||
To build:
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Other</strong></summary>
|
||||
<br>
|
||||
|
||||
Glance can also be installed through the following 3rd party channels:
|
||||
* [Proxmox VE Helper Script](https://community-scripts.github.io/ProxmoxVE/scripts?id=glance)
|
||||
* [NixOS package](https://search.nixos.org/packages?channel=unstable&show=glance)
|
||||
* [Coolify.io](https://coolify.io/docs/services/glance/)
|
||||
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<br>
|
||||
|
||||
## Building from source
|
||||
|
||||
Choose one of the following methods:
|
||||
|
||||
<details>
|
||||
<summary><strong>Build binary with Go</strong></summary>
|
||||
<br>
|
||||
|
||||
Requirements: [Go](https://go.dev/dl/) >= v1.23
|
||||
|
||||
To build the project for your current OS and architecture, run:
|
||||
|
||||
```bash
|
||||
go build -o build/glance .
|
||||
```
|
||||
|
||||
To run:
|
||||
To build for a specific OS and architecture, run:
|
||||
|
||||
```bash
|
||||
GOOS=linux GOARCH=amd64 go build -o build/glance .
|
||||
```
|
||||
|
||||
[*click here for a full list of GOOS and GOARCH combinations*](https://go.dev/doc/install/source#:~:text=$GOOS%20and%20$GOARCH)
|
||||
|
||||
Alternatively, if you just want to run the app without creating a binary, like when you're testing out changes, you can run:
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
### Building Docker image
|
||||
<details>
|
||||
<summary><strong>Build project and Docker image with Docker</strong></summary>
|
||||
<br>
|
||||
|
||||
Build the image:
|
||||
Requirements: [Docker](https://docs.docker.com/engine/install/)
|
||||
|
||||
**Make sure to replace "owner" with your name or organization.**
|
||||
To build the project and image using just Docker, run:
|
||||
|
||||
*(replace `owner` with your name or organization)*
|
||||
|
||||
```bash
|
||||
docker build -t owner/glance:latest .
|
||||
```
|
||||
|
||||
Push the image to your registry:
|
||||
If you wish to push the image to a registry (by default Docker Hub), run:
|
||||
|
||||
```bash
|
||||
docker push owner/glance:latest
|
||||
```
|
||||
|
||||
<hr>
|
||||
</details>
|
||||
|
||||
<br>
|
||||
|
||||
## FAQ
|
||||
<details>
|
||||
<summary><strong>Does the information on the page update automatically?</strong></summary>
|
||||
No, a page refresh is required to update the information. Some things do dynamically update where it makes sense, like the clock widget and the relative time showing how long ago something happened.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>How frequently do widgets update?</strong></summary>
|
||||
No requests are made periodically in the background, information is only fetched upon loading the page and then cached. The default cache lifetime is different for each widget and can be configured.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Can I create my own widgets?</strong></summary>
|
||||
|
||||
Yes, there are multiple ways to create custom widgets:
|
||||
* `iframe` widget - allows you to embed things from other websites
|
||||
* `html` widget - allows you to insert your own static HTML
|
||||
* `extension` widget - fetch HTML from a URL
|
||||
* `custom-api` widget - fetch JSON from a URL and render it using custom HTML
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Can I change the title of a widget?</strong></summary>
|
||||
|
||||
Yes, the title of all widgets can be changed by specifying the `title` property in the widget's configuration:
|
||||
|
||||
```yaml
|
||||
- type: rss
|
||||
title: My custom title
|
||||
|
||||
- type: markets
|
||||
title: My custom title
|
||||
|
||||
- type: videos
|
||||
title: My custom title
|
||||
|
||||
# and so on for all widgets...
|
||||
```
|
||||
</details>
|
||||
|
||||
<br>
|
||||
|
||||
## Feature requests
|
||||
|
||||
New feature suggestions are always welcome and will be considered, though please keep in mind that some of them may be out of scope for what the project is trying to achieve (or is reasonably capable of). If you have an idea for a new feature and would like to share it, you can do so [here](https://github.com/glanceapp/glance/issues/new?template=feature_request.yml).
|
||||
|
||||
Feature requests are tagged with one of the following:
|
||||
|
||||
* [Roadmap](https://github.com/glanceapp/glance/labels/roadmap) - will be implemented in a future release
|
||||
* [Backlog](https://github.com/glanceapp/glance/labels/backlog) - may be implemented in the future but needs further feedback or interest from the community
|
||||
* [Icebox](https://github.com/glanceapp/glance/labels/icebox) - no plans to implement as it doesn't currently align with the project's goals or capabilities, may be revised at a later date
|
||||
|
||||
<br>
|
||||
|
||||
## Contributing guidelines
|
||||
|
||||
* Before working on a new feature it's preferable to submit a feature request first and state that you'd like to implement it yourself
|
||||
* Please don't submit PRs for feature requests that are either in the roadmap<sup>[1]</sup>, backlog<sup>[2]</sup> or icebox<sup>[3]</sup>
|
||||
* Use `dev` for the base branch if you're adding new features or fixing bugs, otherwise use `main`
|
||||
* Avoid introducing new dependencies
|
||||
* Avoid making backwards-incompatible configuration changes
|
||||
* Avoid introducing new colors or hard-coding colors, use the standard `primary`, `positive` and `negative`
|
||||
* For icons, try to use [heroicons](https://heroicons.com/) where applicable
|
||||
* Provide a screenshot of the changes if UI related where possible
|
||||
* No `package.json`
|
||||
|
||||
<details>
|
||||
<summary><strong><sup>[1] [2] [3]</sup></strong></summary>
|
||||
|
||||
[1] The feature likely already has work put into it that may conflict with your implementation
|
||||
|
||||
[2] The demand, implementation or functionality for this feature is not yet clear
|
||||
|
||||
[3] No plans to add this feature for the time being
|
||||
|
||||
</details>
|
||||
|
||||
<br>
|
||||
|
||||
## Thank you
|
||||
|
||||
To all the people who were generous enough to [sponsor](https://github.com/sponsors/glanceapp) the project and to everyone who has contributed in any way, be it PRs, submitting issues, helping others in the discussions or Discord server, creating guides and tools or just mentioning Glance on social media. Your support is greatly appreciated and helps keep the project going.
|
||||
|
|
296
docs/custom-api.md
Normal file
|
@ -0,0 +1,296 @@
|
|||
[Jump to function definitions](#functions)
|
||||
|
||||
## Examples
|
||||
|
||||
The best way to get an idea of how the templates work would be with a bunch examples. Here are the most common use cases:
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "My Title",
|
||||
"content": "My Content",
|
||||
}
|
||||
```
|
||||
|
||||
To access the two fields in the JSON response, you would use the following:
|
||||
|
||||
```html
|
||||
<div>{{ .JSON.String "title" }}</div>
|
||||
<div>{{ .JSON.String "content" }}</div>
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>My Title</div>
|
||||
<div>My Content</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"author": "John Doe",
|
||||
"posts": [
|
||||
{
|
||||
"title": "My Title",
|
||||
"content": "My Content"
|
||||
},
|
||||
{
|
||||
"title": "My Title 2",
|
||||
"content": "My Content 2"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
To loop through the array of posts, you would use the following:
|
||||
|
||||
```html
|
||||
{{ range .JSON.Array "posts" }}
|
||||
<div>{{ .String "title" }}</div>
|
||||
<div>{{ .String "content" }}</div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>My Title</div>
|
||||
<div>My Content</div>
|
||||
<div>My Title 2</div>
|
||||
<div>My Content 2</div>
|
||||
```
|
||||
|
||||
Notice the missing `.JSON` when accessing the title and content, this is because the range function sets the context to the current array element.
|
||||
|
||||
If you want to access the top-level context within the range, you can use the following:
|
||||
|
||||
```html
|
||||
{{ range .JSON.Array "posts" }}
|
||||
<div>{{ .String "title" }}</div>
|
||||
<div>{{ .String "content" }}</div>
|
||||
<div>{{ $.JSON.String "author" }}</div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>My Title</div>
|
||||
<div>My Content</div>
|
||||
<div>John Doe</div>
|
||||
<div>My Title 2</div>
|
||||
<div>My Content 2</div>
|
||||
<div>John Doe</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
[
|
||||
"Apple",
|
||||
"Banana",
|
||||
"Cherry",
|
||||
"Watermelon"
|
||||
]
|
||||
```
|
||||
|
||||
Somewhat awkwardly, when the current context is a basic type that isn't an object, the way you specify its type is to use an empty string as the key. So, to loop through the array of strings, you would use the following:
|
||||
|
||||
```html
|
||||
{{ range .JSON.Array "" }}
|
||||
<div>{{ .String "" }}</div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>Apple</div>
|
||||
<div>Banana</div>
|
||||
<div>Cherry</div>
|
||||
<div>Watermelon</div>
|
||||
```
|
||||
|
||||
To access an item at a specific index, you could use the following:
|
||||
|
||||
```html
|
||||
<div>{{ .JSON.String "0" }}</div>
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>Apple</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"address": {
|
||||
"city": "New York",
|
||||
"state": "NY"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To easily access deeply nested objects, you can use the following dot notation:
|
||||
|
||||
```html
|
||||
<div>{{ .JSON.String "user.address.city" }}</div>
|
||||
<div>{{ .JSON.String "user.address.state" }}</div>
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>New York</div>
|
||||
<div>NY</div>
|
||||
```
|
||||
|
||||
Using indexes anywhere in the path is also supported:
|
||||
|
||||
```json
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"name": "John Doe"
|
||||
},
|
||||
{
|
||||
"name": "Jane Doe"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<div>{{ .JSON.String "users.0.name" }}</div>
|
||||
<div>{{ .JSON.String "users.1.name" }}</div>
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>John Doe</div>
|
||||
<div>Jane Doe</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"name": "John Doe",
|
||||
"age": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To check if a field exists, you can use the following:
|
||||
|
||||
```html
|
||||
{{ if .JSON.Exists "user.age" }}
|
||||
<div>{{ .JSON.Int "user.age" }}</div>
|
||||
{{ else }}
|
||||
<div>Age not provided</div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>30</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"price": 100,
|
||||
"discount": 10
|
||||
}
|
||||
```
|
||||
|
||||
Calculations can be performed, however all numbers must be converted to floats first if they are not already:
|
||||
|
||||
```html
|
||||
<div>{{ sub (.JSON.Int "price" | toFloat) (.JSON.Int "discount" | toFloat) }}</div>
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>90</div>
|
||||
```
|
||||
|
||||
Other operations include `add`, `mul`, and `div`.
|
||||
|
||||
<hr>
|
||||
|
||||
In some instances, you may want to know the status code of the response. This can be done using the following:
|
||||
|
||||
```html
|
||||
{{ if eq .Response.StatusCode 200 }}
|
||||
<p>Success!</p>
|
||||
{{ else }}
|
||||
<p>Failed to fetch data</p>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
You can also access the response headers:
|
||||
|
||||
```html
|
||||
<div>{{ .Response.Header.Get "Content-Type" }}</div>
|
||||
```
|
||||
|
||||
## Functions
|
||||
|
||||
The following functions are available on the `JSON` object:
|
||||
|
||||
- `String(key string) string`: Returns the value of the key as a string.
|
||||
- `Int(key string) int`: Returns the value of the key as an integer.
|
||||
- `Float(key string) float`: Returns the value of the key as a float.
|
||||
- `Bool(key string) bool`: Returns the value of the key as a boolean.
|
||||
- `Array(key string) []JSON`: Returns the value of the key as an array of `JSON` objects.
|
||||
- `Exists(key string) bool`: Returns true if the key exists in the JSON object.
|
||||
|
||||
The following helper functions provided by Glance are available:
|
||||
|
||||
- `toFloat(i int) float`: Converts an integer to a float.
|
||||
- `toInt(f float) int`: Converts a float to an integer.
|
||||
- `add(a, b float) float`: Adds two numbers.
|
||||
- `sub(a, b float) float`: Subtracts two numbers.
|
||||
- `mul(a, b float) float`: Multiplies two numbers.
|
||||
- `div(a, b float) float`: Divides two numbers.
|
||||
- `formatApproxNumber(n int) string`: Formats a number to be more human-readable, e.g. 1000 -> 1k.
|
||||
- `formatNumber(n float|int) string`: Formats a number with commas, e.g. 1000 -> 1,000.
|
||||
|
||||
The following helper functions provided by Go's `text/template` are available:
|
||||
|
||||
- `eq(a, b any) bool`: Compares two values for equality.
|
||||
- `ne(a, b any) bool`: Compares two values for inequality.
|
||||
- `lt(a, b any) bool`: Compares two values for less than.
|
||||
- `lte(a, b any) bool`: Compares two values for less than or equal to.
|
||||
- `gt(a, b any) bool`: Compares two values for greater than.
|
||||
- `gte(a, b any) bool`: Compares two values for greater than or equal to.
|
||||
- `and(a, b bool) bool`: Returns true if both values are true.
|
||||
- `or(a, b bool) bool`: Returns true if either value is true.
|
||||
- `not(a bool) bool`: Returns the opposite of the value.
|
||||
- `index(a any, b int) any`: Returns the value at the specified index of an array.
|
||||
- `len(a any) int`: Returns the length of an array.
|
||||
- `printf(format string, a ...any) string`: Returns a formatted string.
|
|
@ -29,6 +29,9 @@ Used to specify the title of the widget. If not provided, the widget's title wil
|
|||
### `Widget-Content-Type`
|
||||
Used to specify the content type that will be returned by the extension. If not provided, the content will be shown as plain text.
|
||||
|
||||
### `Widget-Content-Frameless`
|
||||
When set to `true`, the widget's content will be displayed without the default background or "frame".
|
||||
|
||||
## Content Types
|
||||
|
||||
> [!NOTE]
|
||||
|
|
105
docs/glance.yml
Normal file
|
@ -0,0 +1,105 @@
|
|||
pages:
|
||||
- name: Home
|
||||
# Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look
|
||||
# hide-desktop-navigation: true
|
||||
columns:
|
||||
- size: small
|
||||
widgets:
|
||||
- type: calendar
|
||||
first-day-of-week: monday
|
||||
|
||||
- type: rss
|
||||
limit: 10
|
||||
collapse-after: 3
|
||||
cache: 12h
|
||||
feeds:
|
||||
- url: https://selfh.st/rss/
|
||||
title: selfh.st
|
||||
limit: 4
|
||||
- url: https://ciechanow.ski/atom.xml
|
||||
- url: https://www.joshwcomeau.com/rss.xml
|
||||
title: Josh Comeau
|
||||
- url: https://samwho.dev/rss.xml
|
||||
- url: https://ishadeed.com/feed.xml
|
||||
title: Ahmad Shadeed
|
||||
|
||||
- type: twitch-channels
|
||||
channels:
|
||||
- theprimeagen
|
||||
- j_blow
|
||||
- piratesoftware
|
||||
- cohhcarnage
|
||||
- christitustech
|
||||
- EJ_SA
|
||||
|
||||
- size: full
|
||||
widgets:
|
||||
- type: group
|
||||
widgets:
|
||||
- type: hacker-news
|
||||
- type: lobsters
|
||||
|
||||
- type: videos
|
||||
channels:
|
||||
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
|
||||
- UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
|
||||
- UCsBjURrPoezykLs9EqgamOA # Fireship
|
||||
- UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
|
||||
- UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
|
||||
|
||||
- type: group
|
||||
widgets:
|
||||
- type: reddit
|
||||
subreddit: technology
|
||||
show-thumbnails: true
|
||||
- type: reddit
|
||||
subreddit: selfhosted
|
||||
show-thumbnails: true
|
||||
|
||||
- size: small
|
||||
widgets:
|
||||
- type: weather
|
||||
location: London, United Kingdom
|
||||
units: metric # alternatively "imperial"
|
||||
hour-format: 12h # alternatively "24h"
|
||||
# Optionally hide the location from being displayed in the widget
|
||||
# hide-location: true
|
||||
|
||||
- type: markets
|
||||
markets:
|
||||
- symbol: SPY
|
||||
name: S&P 500
|
||||
- symbol: BTC-USD
|
||||
name: Bitcoin
|
||||
- symbol: NVDA
|
||||
name: NVIDIA
|
||||
- symbol: AAPL
|
||||
name: Apple
|
||||
- symbol: MSFT
|
||||
name: Microsoft
|
||||
|
||||
- type: releases
|
||||
cache: 1d
|
||||
# Without authentication the Github API allows for up to 60 requests per hour. You can create a
|
||||
# read-only token from your Github account settings and use it here to increase the limit.
|
||||
# token: ...
|
||||
repositories:
|
||||
- glanceapp/glance
|
||||
- go-gitea/gitea
|
||||
- immich-app/immich
|
||||
- syncthing/syncthing
|
||||
|
||||
# Add more pages here:
|
||||
# - name: Your page name
|
||||
# columns:
|
||||
# - size: small
|
||||
# widgets:
|
||||
# # Add widgets here
|
||||
|
||||
# - size: full
|
||||
# widgets:
|
||||
# # Add widgets here
|
||||
|
||||
# - size: small
|
||||
# widgets:
|
||||
# # Add widgets here
|
BIN
docs/images/calendar-legacy-widget-preview.png
Normal file
After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
BIN
docs/images/custom-api-preview-1.png
Normal file
After Width: | Height: | Size: 7.9 KiB |
BIN
docs/images/custom-api-preview-2.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
docs/images/custom-api-preview-3.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
docs/images/docker-container-parent.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
docs/images/docker-container-parent2.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
docs/images/docker-containers-preview.png
Normal file
After Width: | Height: | Size: 33 KiB |
BIN
docs/images/docker-widget-preview.png
Normal file
After Width: | Height: | Size: 36 KiB |
BIN
docs/images/monitor-widget-compact-preview.png
Normal file
After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 362 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 42 KiB |
BIN
docs/images/server-stats-flame-icon.png
Normal file
After Width: | Height: | Size: 9.6 KiB |
BIN
docs/images/server-stats-preview.gif
Normal file
After Width: | Height: | Size: 200 KiB |
BIN
docs/images/split-column-widget-3-columns.png
Normal file
After Width: | Height: | Size: 146 KiB |
BIN
docs/images/split-column-widget-4-columns.png
Normal file
After Width: | Height: | Size: 181 KiB |
BIN
docs/images/split-column-widget-masonry.png
Normal file
After Width: | Height: | Size: 325 KiB |
BIN
docs/images/split-column-widget-preview.png
Normal file
After Width: | Height: | Size: 339 KiB |
BIN
docs/images/themes/gruvbox.png
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
docs/images/videos-widget-vertical-list-preview.png
Normal file
After Width: | Height: | Size: 77 KiB |
|
@ -53,6 +53,16 @@ theme:
|
|||
primary-color: 97 13 80
|
||||
```
|
||||
|
||||
### Gruvbox Dark
|
||||

|
||||
```yaml
|
||||
theme:
|
||||
background-color: 0 0 16
|
||||
primary-color: 43 59 81
|
||||
positive-color: 61 66 44
|
||||
negative-color: 6 96 59
|
||||
```
|
||||
|
||||
### Kanagawa Dark
|
||||

|
||||
```yaml
|
||||
|
|
57
docs/v0.7.0-upgrade.md
Normal file
|
@ -0,0 +1,57 @@
|
|||
## Upgrading to v0.7.0 from previous versions
|
||||
|
||||
In essence, the `glance.yml` file has been moved from the root of the project to a `config/` directory and you now need to mount that directory to `/app/config` in the container.
|
||||
|
||||
### Before
|
||||
|
||||
Versions before v0.7.0 used a `docker-compose.yml` that looked like the following:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
glance:
|
||||
image: glanceapp/glance
|
||||
volumes:
|
||||
- ./glance.yml:/app/glance.yml
|
||||
ports:
|
||||
- 8080:8080
|
||||
```
|
||||
|
||||
And expected you to have the following directory structure:
|
||||
|
||||
```plaintext
|
||||
glance/
|
||||
docker-compose.yml
|
||||
glance.yml
|
||||
```
|
||||
|
||||
### After
|
||||
|
||||
With the release of v0.7.0, the recommended `docker-compose.yml` looks like the following:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
glance:
|
||||
container_name: glance
|
||||
image: glanceapp/glance
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
ports:
|
||||
- 8080:8080
|
||||
```
|
||||
|
||||
And expects you to have the following directory structure:
|
||||
|
||||
```plaintext
|
||||
glance/
|
||||
docker-compose.yml
|
||||
config/
|
||||
glance.yml
|
||||
```
|
||||
|
||||
## Why this change was necessary
|
||||
|
||||
1. Mounting a file rather than a directory is not common practice and leads to some issues, such as creating a directory if the file is not present, which has tripped up multiple people and caused unnecessary confusion
|
||||
2. v0.7.0 added automatic reloads when the configuration file changes, which based on testing didn't work when mounting a single file
|
||||
3. v0.7.0 added the ability to include config files, so you'd have to make this change anyways if you wanted to take advantage of that feature
|
||||
|
||||
Taking all of these into account, it felt like the right time to implement the change.
|
23
go.mod
|
@ -1,19 +1,32 @@
|
|||
module github.com/glanceapp/glance
|
||||
|
||||
go 1.22.5
|
||||
go 1.23.6
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.8.0
|
||||
github.com/mmcdole/gofeed v1.3.0
|
||||
golang.org/x/text v0.16.0
|
||||
github.com/shirou/gopsutil/v4 v4.25.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
golang.org/x/text v0.22.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.9.2 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.10.1 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/ebitengine/purego v0.8.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
|
||||
github.com/mmcdole/goxpp v1.1.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
||||
github.com/tklauser/numcpus v0.9.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
)
|
||||
|
|
89
go.sum
|
@ -1,13 +1,24 @@
|
|||
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
||||
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
|
||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
||||
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
||||
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
||||
github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
|
||||
|
@ -19,47 +30,99 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
|||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
|
||||
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
|
||||
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
|
||||
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
|
||||
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
|
|
@ -1,56 +0,0 @@
|
|||
package assets
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var _publicFS embed.FS
|
||||
|
||||
//go:embed templates
|
||||
var _templateFS embed.FS
|
||||
|
||||
var PublicFS, _ = fs.Sub(_publicFS, "static")
|
||||
var TemplateFS, _ = fs.Sub(_templateFS, "templates")
|
||||
|
||||
func getFSHash(files fs.FS) string {
|
||||
hash := md5.New()
|
||||
|
||||
err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := files.Open(path)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
return hex.EncodeToString(hash.Sum(nil))[:10]
|
||||
}
|
||||
|
||||
slog.Warn("Could not compute assets cache", "err", err)
|
||||
return strconv.FormatInt(time.Now().Unix(), 10)
|
||||
}
|
||||
|
||||
var PublicFSHash = getFSHash(PublicFS)
|
|
@ -1,109 +0,0 @@
|
|||
package assets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
var (
|
||||
PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
|
||||
PageContentTemplate = compileTemplate("content.html")
|
||||
CalendarTemplate = compileTemplate("calendar.html", "widget-base.html")
|
||||
ClockTemplate = compileTemplate("clock.html", "widget-base.html")
|
||||
BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html")
|
||||
IFrameTemplate = compileTemplate("iframe.html", "widget-base.html")
|
||||
WeatherTemplate = compileTemplate("weather.html", "widget-base.html")
|
||||
ForumPostsTemplate = compileTemplate("forum-posts.html", "widget-base.html")
|
||||
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
|
||||
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
|
||||
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
|
||||
ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html")
|
||||
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
|
||||
VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
|
||||
MarketsTemplate = compileTemplate("markets.html", "widget-base.html")
|
||||
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
|
||||
RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html")
|
||||
RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
|
||||
RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
|
||||
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
|
||||
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
|
||||
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
|
||||
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
|
||||
SearchTemplate = compileTemplate("search.html", "widget-base.html")
|
||||
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
|
||||
GroupTemplate = compileTemplate("group.html", "widget-base.html")
|
||||
DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html")
|
||||
)
|
||||
|
||||
var globalTemplateFunctions = template.FuncMap{
|
||||
"relativeTime": relativeTimeSince,
|
||||
"formatViewerCount": formatViewerCount,
|
||||
"formatNumber": intl.Sprint,
|
||||
"absInt": func(i int) int {
|
||||
return int(math.Abs(float64(i)))
|
||||
},
|
||||
"formatPrice": func(price float64) string {
|
||||
return intl.Sprintf("%.2f", price)
|
||||
},
|
||||
"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
|
||||
return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix()))
|
||||
},
|
||||
}
|
||||
|
||||
func compileTemplate(primary string, dependencies ...string) *template.Template {
|
||||
t, err := template.New(primary).
|
||||
Funcs(globalTemplateFunctions).
|
||||
ParseFS(TemplateFS, append([]string{primary}, dependencies...)...)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
var intl = message.NewPrinter(language.English)
|
||||
|
||||
func formatViewerCount(count int) string {
|
||||
if count < 1_000 {
|
||||
return strconv.Itoa(count)
|
||||
}
|
||||
|
||||
if count < 10_000 {
|
||||
return fmt.Sprintf("%.1fk", float64(count)/1_000)
|
||||
}
|
||||
|
||||
if count < 1_000_000 {
|
||||
return fmt.Sprintf("%dk", count/1_000)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%.1fm", float64(count)/1_000_000)
|
||||
}
|
||||
|
||||
func relativeTimeSince(t time.Time) string {
|
||||
delta := time.Since(t)
|
||||
|
||||
if delta < time.Minute {
|
||||
return "1m"
|
||||
}
|
||||
if delta < time.Hour {
|
||||
return fmt.Sprintf("%dm", delta/time.Minute)
|
||||
}
|
||||
if delta < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh", delta/time.Hour)
|
||||
}
|
||||
if delta < 30*24*time.Hour {
|
||||
return fmt.Sprintf("%dd", delta/(24*time.Hour))
|
||||
}
|
||||
if delta < 12*30*24*time.Hour {
|
||||
return fmt.Sprintf("%dmo", delta/(30*24*time.Hour))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%dy", delta/(365*24*time.Hour))
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
{{ .Extension.Content }}
|
||||
{{ end }}
|
|
@ -1,49 +0,0 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||
{{ range .Posts }}
|
||||
<li>
|
||||
<div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
|
||||
{{ if $.ShowThumbnails }}
|
||||
{{ if .IsCrosspost }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
{{ else if ne .ThumbnailUrl "" }}
|
||||
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
|
||||
{{ else if .HasTargetUrl }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
{{ else }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
</svg>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<div class="grow min-width-0">
|
||||
<a href="{{ .DiscussionUrl }}" class="size-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">
|
||||
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||
<li>{{ .Score | formatNumber }} points</li>
|
||||
<li>{{ .CommentCount | formatNumber }} comments</li>
|
||||
{{ if .HasTargetUrl }}
|
||||
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
|
@ -1,20 +0,0 @@
|
|||
{{ 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 }}
|
|
@ -1,14 +0,0 @@
|
|||
<style>
|
||||
:root {
|
||||
{{ if .App.Config.Theme.BackgroundColor }}
|
||||
--bgh: {{ .App.Config.Theme.BackgroundColor.Hue }};
|
||||
--bgs: {{ .App.Config.Theme.BackgroundColor.Saturation }}%;
|
||||
--bgl: {{ .App.Config.Theme.BackgroundColor.Lightness }}%;
|
||||
{{ end }}
|
||||
{{ if ne 0.0 .App.Config.Theme.ContrastMultiplier }}--cm: {{ .App.Config.Theme.ContrastMultiplier }};{{ end }}
|
||||
{{ if ne 0.0 .App.Config.Theme.TextSaturationMultiplier }}--tsm: {{ .App.Config.Theme.TextSaturationMultiplier }};{{ end }}
|
||||
{{ if .App.Config.Theme.PrimaryColor }}--color-primary: {{ .App.Config.Theme.PrimaryColor.AsCSSValue }};{{ end }}
|
||||
{{ if .App.Config.Theme.PositiveColor }}--color-positive: {{ .App.Config.Theme.PositiveColor.AsCSSValue }};{{ end }}
|
||||
{{ if .App.Config.Theme.NegativeColor }}--color-negative: {{ .App.Config.Theme.NegativeColor.AsCSSValue }};{{ end }}
|
||||
}
|
||||
</style>
|
|
@ -1,61 +0,0 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<a class="size-h4 color-highlight" href="https://github.com/{{ $.RepositoryDetails.Name }}" target="_blank" rel="noreferrer">{{ .RepositoryDetails.Name }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li>{{ .RepositoryDetails.Stars | formatNumber }} stars</li>
|
||||
<li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
|
||||
</ul>
|
||||
|
||||
{{ if gt (len .RepositoryDetails.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 }}
|
|
@ -1,23 +0,0 @@
|
|||
<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
|
||||
{{ if not .HideHeader}}
|
||||
<div class="widget-header">
|
||||
{{ if ne "" .TitleURL}}<a href="{{ .TitleURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>{{ else }}<div class="uppercase">{{ .Title }}</div>{{ end }}
|
||||
{{ if and .Error .ContentAvailable }}
|
||||
<div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
|
||||
{{ else if .Notice }}
|
||||
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}">
|
||||
{{ if .ContentAvailable }}
|
||||
{{ block "widget-content" . }}{{ end }}
|
||||
{{ else }}
|
||||
<div class="widget-error-header">
|
||||
<div class="color-negative size-h3">ERROR</div>
|
||||
<div class="widget-error-icon"></div>
|
||||
</div>
|
||||
<p class="break-all">{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}</p>
|
||||
{{ end}}
|
||||
</div>
|
||||
</div>
|
|
@ -1,120 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type adguardStatsResponse struct {
|
||||
TotalQueries int `json:"num_dns_queries"`
|
||||
QueriesSeries []int `json:"dns_queries"`
|
||||
BlockedQueries int `json:"num_blocked_filtering"`
|
||||
BlockedSeries []int `json:"blocked_filtering"`
|
||||
ResponseTime float64 `json:"avg_processing_time"`
|
||||
TopBlockedDomains []map[string]int `json:"top_blocked_domains"`
|
||||
}
|
||||
|
||||
func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error) {
|
||||
requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats"
|
||||
|
||||
request, err := http.NewRequest("GET", requestURL, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.SetBasicAuth(username, password)
|
||||
|
||||
responseJson, err := decodeJsonFromRequest[adguardStatsResponse](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5)
|
||||
|
||||
stats := &DNSStats{
|
||||
TotalQueries: responseJson.TotalQueries,
|
||||
BlockedQueries: responseJson.BlockedQueries,
|
||||
ResponseTime: int(responseJson.ResponseTime * 1000),
|
||||
TopBlockedDomains: make([]DNSStatsBlockedDomain, 0, topBlockedDomainsCount),
|
||||
}
|
||||
|
||||
if stats.TotalQueries <= 0 {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100)
|
||||
|
||||
for i := 0; i < topBlockedDomainsCount; i++ {
|
||||
domain := responseJson.TopBlockedDomains[i]
|
||||
var firstDomain string
|
||||
|
||||
for k := range domain {
|
||||
firstDomain = k
|
||||
break
|
||||
}
|
||||
|
||||
if firstDomain == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
stats.TopBlockedDomains = append(stats.TopBlockedDomains, DNSStatsBlockedDomain{
|
||||
Domain: firstDomain,
|
||||
})
|
||||
|
||||
if stats.BlockedQueries > 0 {
|
||||
stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
queriesSeries := responseJson.QueriesSeries
|
||||
blockedSeries := responseJson.BlockedSeries
|
||||
|
||||
const bars = 8
|
||||
const hoursSpan = 24
|
||||
const hoursPerBar int = hoursSpan / bars
|
||||
|
||||
if len(queriesSeries) > hoursSpan {
|
||||
queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:]
|
||||
} else if len(queriesSeries) < hoursSpan {
|
||||
queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...)
|
||||
}
|
||||
|
||||
if len(blockedSeries) > hoursSpan {
|
||||
blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:]
|
||||
} else if len(blockedSeries) < hoursSpan {
|
||||
blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...)
|
||||
}
|
||||
|
||||
maxQueriesInSeries := 0
|
||||
|
||||
for i := 0; i < bars; i++ {
|
||||
queries := 0
|
||||
blocked := 0
|
||||
|
||||
for j := 0; j < hoursPerBar; j++ {
|
||||
queries += queriesSeries[i*hoursPerBar+j]
|
||||
blocked += blockedSeries[i*hoursPerBar+j]
|
||||
}
|
||||
|
||||
stats.Series[i] = DNSStatsSeries{
|
||||
Queries: queries,
|
||||
Blocked: blocked,
|
||||
}
|
||||
|
||||
if queries > 0 {
|
||||
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
|
||||
}
|
||||
|
||||
if queries > maxQueriesInSeries {
|
||||
maxQueriesInSeries = queries
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < bars; i++ {
|
||||
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
package feed
|
||||
|
||||
import "time"
|
||||
|
||||
// TODO: very inflexible, refactor to allow more customizability
|
||||
// TODO: allow changing first day of week
|
||||
// TODO: allow changing between showing the previous and next week and the entire month
|
||||
func NewCalendar(now time.Time) *Calendar {
|
||||
year, week := now.ISOWeek()
|
||||
weekday := now.Weekday()
|
||||
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
|
||||
currentMonthDays := daysInMonth(now.Month(), year)
|
||||
|
||||
var previousMonthDays int
|
||||
|
||||
if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
|
||||
previousMonthDays = daysInMonth(12, year-1)
|
||||
} else {
|
||||
previousMonthDays = daysInMonth(previousMonthNumber, year)
|
||||
}
|
||||
|
||||
startDaysFrom := now.Day() - int(weekday+6)
|
||||
|
||||
days := make([]int, 21)
|
||||
|
||||
for i := 0; i < 21; i++ {
|
||||
day := startDaysFrom + i
|
||||
|
||||
if day < 1 {
|
||||
day = previousMonthDays + day
|
||||
} else if day > currentMonthDays {
|
||||
day = day - currentMonthDays
|
||||
}
|
||||
|
||||
days[i] = day
|
||||
}
|
||||
|
||||
return &Calendar{
|
||||
CurrentDay: now.Day(),
|
||||
CurrentWeekNumber: week,
|
||||
CurrentMonthName: now.Month().String(),
|
||||
CurrentYear: year,
|
||||
Days: days,
|
||||
}
|
||||
}
|
||||
|
||||
func daysInMonth(m time.Month, year int) int {
|
||||
return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type codebergReleaseResponseJson struct {
|
||||
TagName string `json:"tag_name"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
HtmlUrl string `json:"html_url"`
|
||||
}
|
||||
|
||||
func fetchLatestCodebergRelease(request *ReleaseRequest) (*AppRelease, error) {
|
||||
httpRequest, err := http.NewRequest(
|
||||
"GET",
|
||||
fmt.Sprintf(
|
||||
"https://codeberg.org/api/v1/repos/%s/releases/latest",
|
||||
request.Repository,
|
||||
),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultClient, httpRequest)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AppRelease{
|
||||
Source: ReleaseSourceCodeberg,
|
||||
Name: request.Repository,
|
||||
Version: normalizeVersionFormat(response.TagName),
|
||||
NotesUrl: response.HtmlUrl,
|
||||
TimeReleased: parseRFC3339Time(response.PublishedAt),
|
||||
}, nil
|
||||
}
|
|
@ -1,102 +0,0 @@
|
|||
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
|
||||
}
|
|
@ -1,97 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type ExtensionType int
|
||||
|
||||
const (
|
||||
ExtensionContentHTML ExtensionType = iota
|
||||
ExtensionContentUnknown = iota
|
||||
)
|
||||
|
||||
var ExtensionStringToType = map[string]ExtensionType{
|
||||
"html": ExtensionContentHTML,
|
||||
}
|
||||
|
||||
const (
|
||||
ExtensionHeaderTitle = "Widget-Title"
|
||||
ExtensionHeaderContentType = "Widget-Content-Type"
|
||||
)
|
||||
|
||||
type ExtensionRequestOptions struct {
|
||||
URL string `yaml:"url"`
|
||||
Parameters map[string]string `yaml:"parameters"`
|
||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||
}
|
||||
|
||||
type Extension struct {
|
||||
Title string
|
||||
Content template.HTML
|
||||
}
|
||||
|
||||
func convertExtensionContent(options ExtensionRequestOptions, content []byte, contentType ExtensionType) template.HTML {
|
||||
switch contentType {
|
||||
case ExtensionContentHTML:
|
||||
if options.AllowHtml {
|
||||
return template.HTML(content)
|
||||
}
|
||||
|
||||
fallthrough
|
||||
default:
|
||||
return template.HTML(html.EscapeString(string(content)))
|
||||
}
|
||||
}
|
||||
|
||||
func FetchExtension(options ExtensionRequestOptions) (Extension, error) {
|
||||
request, _ := http.NewRequest("GET", options.URL, nil)
|
||||
|
||||
query := url.Values{}
|
||||
|
||||
for key, value := range options.Parameters {
|
||||
query.Set(key, value)
|
||||
}
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
|
||||
if err != nil {
|
||||
slog.Error("failed fetching extension", "error", err, "url", options.URL)
|
||||
return Extension{}, fmt.Errorf("%w: request failed: %w", ErrNoContent, err)
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
|
||||
if err != nil {
|
||||
slog.Error("failed reading response body of extension", "error", err, "url", options.URL)
|
||||
return Extension{}, fmt.Errorf("%w: could not read body: %w", ErrNoContent, err)
|
||||
}
|
||||
|
||||
extension := Extension{}
|
||||
|
||||
if response.Header.Get(ExtensionHeaderTitle) == "" {
|
||||
extension.Title = "Extension"
|
||||
} else {
|
||||
extension.Title = response.Header.Get(ExtensionHeaderTitle)
|
||||
}
|
||||
|
||||
contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)]
|
||||
|
||||
if !ok {
|
||||
contentType = ExtensionContentUnknown
|
||||
}
|
||||
|
||||
extension.Content = convertExtensionContent(options, body, contentType)
|
||||
|
||||
return extension, nil
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
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
|
||||
}
|
|
@ -1,98 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type hackerNewsPostResponseJson struct {
|
||||
Id int `json:"id"`
|
||||
Score int `json:"score"`
|
||||
Title string `json:"title"`
|
||||
TargetUrl string `json:"url,omitempty"`
|
||||
CommentCount int `json:"descendants"`
|
||||
TimePosted int64 `json:"time"`
|
||||
}
|
||||
|
||||
func getHackerNewsPostIds(sort string) ([]int, error) {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
|
||||
response, err := decodeJsonFromRequest[[]int](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: could not fetch list of post IDs", ErrNoContent)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (ForumPosts, error) {
|
||||
requests := make([]*http.Request, len(postIds))
|
||||
|
||||
for i, id := range postIds {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil)
|
||||
requests[i] = request
|
||||
}
|
||||
|
||||
task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultClient)
|
||||
job := newJob(task, requests).withWorkers(30)
|
||||
results, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
posts := make(ForumPosts, 0, len(postIds))
|
||||
|
||||
for i := range results {
|
||||
if errs[i] != nil {
|
||||
slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL)
|
||||
continue
|
||||
}
|
||||
|
||||
var commentsUrl string
|
||||
|
||||
if commentsUrlTemplate == "" {
|
||||
commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
|
||||
} else {
|
||||
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
|
||||
}
|
||||
|
||||
posts = append(posts, ForumPost{
|
||||
Title: results[i].Title,
|
||||
DiscussionUrl: commentsUrl,
|
||||
TargetUrl: results[i].TargetUrl,
|
||||
TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
|
||||
CommentCount: results[i].CommentCount,
|
||||
Score: results[i].Score,
|
||||
TimePosted: time.Unix(results[i].TimePosted, 0),
|
||||
})
|
||||
}
|
||||
|
||||
if len(posts) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
if len(posts) != len(postIds) {
|
||||
return posts, fmt.Errorf("%w could not fetch some hacker news posts", ErrPartialContent)
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func FetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (ForumPosts, error) {
|
||||
postIds, err := getHackerNewsPostIds(sort)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(postIds) > limit {
|
||||
postIds = postIds[:limit]
|
||||
}
|
||||
|
||||
return getHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
|
||||
}
|
|
@ -1,91 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type lobstersPostResponseJson struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Score int `json:"score"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
CommentsURL string `json:"comments_url"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type lobstersFeedResponseJson []lobstersPostResponseJson
|
||||
|
||||
func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) {
|
||||
request, err := http.NewRequest("GET", feedUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
posts := make(ForumPosts, 0, len(feed))
|
||||
|
||||
for i := range feed {
|
||||
createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt)
|
||||
|
||||
posts = append(posts, ForumPost{
|
||||
Title: feed[i].Title,
|
||||
DiscussionUrl: feed[i].CommentsURL,
|
||||
TargetUrl: feed[i].URL,
|
||||
TargetUrlDomain: extractDomainFromUrl(feed[i].URL),
|
||||
CommentCount: feed[i].CommentCount,
|
||||
Score: feed[i].Score,
|
||||
TimePosted: createdAt,
|
||||
Tags: feed[i].Tags,
|
||||
})
|
||||
}
|
||||
|
||||
if len(posts) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) {
|
||||
var feedUrl string
|
||||
|
||||
if customURL != "" {
|
||||
feedUrl = customURL
|
||||
} else {
|
||||
if instanceURL != "" {
|
||||
instanceURL = strings.TrimRight(instanceURL, "/") + "/"
|
||||
} else {
|
||||
instanceURL = "https://lobste.rs/"
|
||||
}
|
||||
|
||||
if sortBy == "hot" {
|
||||
sortBy = "hottest"
|
||||
} else if sortBy == "new" {
|
||||
sortBy = "newest"
|
||||
}
|
||||
|
||||
if len(tags) == 0 {
|
||||
feedUrl = instanceURL + sortBy + ".json"
|
||||
} else {
|
||||
tags := strings.Join(tags, ",")
|
||||
feedUrl = instanceURL + "t/" + tags + ".json"
|
||||
}
|
||||
}
|
||||
|
||||
posts, err := getLobstersPostsFromFeed(feedUrl)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SiteStatusRequest struct {
|
||||
URL string `yaml:"url"`
|
||||
CheckURL string `yaml:"check-url"`
|
||||
AllowInsecure bool `yaml:"allow-insecure"`
|
||||
}
|
||||
|
||||
type SiteStatus struct {
|
||||
Code int
|
||||
TimedOut bool
|
||||
ResponseTime time.Duration
|
||||
Error 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)
|
||||
defer cancel()
|
||||
request = request.WithContext(ctx)
|
||||
requestSentAt := time.Now()
|
||||
var response *http.Response
|
||||
|
||||
if !statusRequest.AllowInsecure {
|
||||
response, err = defaultClient.Do(request)
|
||||
} else {
|
||||
response, err = defaultInsecureClient.Do(request)
|
||||
}
|
||||
|
||||
status := SiteStatus{ResponseTime: time.Since(requestSentAt)}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
status.TimedOut = true
|
||||
}
|
||||
|
||||
status.Error = err
|
||||
return status, nil
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
status.Code = response.StatusCode
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func FetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) {
|
||||
job := newJob(getSiteStatusTask, requests).withWorkers(20)
|
||||
results, _, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
|
@ -1,136 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type piholeStatsResponse struct {
|
||||
TotalQueries int `json:"dns_queries_today"`
|
||||
QueriesSeries map[int64]int `json:"domains_over_time"`
|
||||
BlockedQueries int `json:"ads_blocked_today"`
|
||||
BlockedSeries map[int64]int `json:"ads_over_time"`
|
||||
BlockedPercentage float64 `json:"ads_percentage_today"`
|
||||
TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"`
|
||||
DomainsBlocked int `json:"domains_being_blocked"`
|
||||
}
|
||||
|
||||
// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array
|
||||
// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling
|
||||
type piholeTopBlockedDomains map[string]int
|
||||
|
||||
func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error {
|
||||
// NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow
|
||||
// because of the UnmarshalJSON method getting called recursively
|
||||
temp := make(map[string]int)
|
||||
|
||||
err := json.Unmarshal(data, &temp)
|
||||
|
||||
if err != nil {
|
||||
*p = make(piholeTopBlockedDomains)
|
||||
} else {
|
||||
*p = temp
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func FetchPiholeStats(instanceURL, token string) (*DNSStats, error) {
|
||||
if token == "" {
|
||||
return nil, errors.New("missing API token")
|
||||
}
|
||||
|
||||
requestURL := strings.TrimRight(instanceURL, "/") +
|
||||
"/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token
|
||||
|
||||
request, err := http.NewRequest("GET", requestURL, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responseJson, err := decodeJsonFromRequest[piholeStatsResponse](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats := &DNSStats{
|
||||
TotalQueries: responseJson.TotalQueries,
|
||||
BlockedQueries: responseJson.BlockedQueries,
|
||||
BlockedPercent: int(responseJson.BlockedPercentage),
|
||||
DomainsBlocked: responseJson.DomainsBlocked,
|
||||
}
|
||||
|
||||
if len(responseJson.TopBlockedDomains) > 0 {
|
||||
domains := make([]DNSStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains))
|
||||
|
||||
for domain, count := range responseJson.TopBlockedDomains {
|
||||
domains = append(domains, DNSStatsBlockedDomain{
|
||||
Domain: domain,
|
||||
PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(domains, func(a, b int) bool {
|
||||
return domains[a].PercentBlocked > domains[b].PercentBlocked
|
||||
})
|
||||
|
||||
stats.TopBlockedDomains = domains[:min(len(domains), 5)]
|
||||
}
|
||||
|
||||
// Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144
|
||||
if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 {
|
||||
slog.Warn(
|
||||
"DNS stats for pihole: did not get expected 144 data points",
|
||||
"len(queries)", len(responseJson.QueriesSeries),
|
||||
"len(blocked)", len(responseJson.BlockedSeries),
|
||||
)
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
var lowestTimestamp int64 = 0
|
||||
|
||||
for timestamp := range responseJson.QueriesSeries {
|
||||
if lowestTimestamp == 0 || timestamp < lowestTimestamp {
|
||||
lowestTimestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
maxQueriesInSeries := 0
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
queries := 0
|
||||
blocked := 0
|
||||
|
||||
for j := 0; j < 18; j++ {
|
||||
index := lowestTimestamp + int64(i*10800+j*600)
|
||||
|
||||
queries += responseJson.QueriesSeries[index]
|
||||
blocked += responseJson.BlockedSeries[index]
|
||||
}
|
||||
|
||||
if queries > maxQueriesInSeries {
|
||||
maxQueriesInSeries = queries
|
||||
}
|
||||
|
||||
stats.Series[i] = DNSStatsSeries{
|
||||
Queries: queries,
|
||||
Blocked: blocked,
|
||||
}
|
||||
|
||||
if queries > 0 {
|
||||
stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
|
@ -1,241 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ForumPost struct {
|
||||
Title string
|
||||
DiscussionUrl string
|
||||
TargetUrl string
|
||||
TargetUrlDomain string
|
||||
ThumbnailUrl string
|
||||
CommentCount int
|
||||
Score int
|
||||
Engagement float64
|
||||
TimePosted time.Time
|
||||
Tags []string
|
||||
IsCrosspost bool
|
||||
}
|
||||
|
||||
type ForumPosts []ForumPost
|
||||
|
||||
type Calendar struct {
|
||||
CurrentDay int
|
||||
CurrentWeekNumber int
|
||||
CurrentMonthName string
|
||||
CurrentYear int
|
||||
Days []int
|
||||
}
|
||||
|
||||
type Weather struct {
|
||||
Temperature int
|
||||
ApparentTemperature int
|
||||
WeatherCode int
|
||||
CurrentColumn int
|
||||
SunriseColumn int
|
||||
SunsetColumn int
|
||||
Columns []weatherColumn
|
||||
}
|
||||
|
||||
type AppRelease struct {
|
||||
Source ReleaseSource
|
||||
SourceIconURL string
|
||||
Name string
|
||||
Version string
|
||||
NotesUrl string
|
||||
TimeReleased time.Time
|
||||
Downvotes int
|
||||
}
|
||||
|
||||
type AppReleases []AppRelease
|
||||
|
||||
type Video struct {
|
||||
ThumbnailUrl string
|
||||
Title string
|
||||
Url string
|
||||
Author string
|
||||
AuthorUrl string
|
||||
TimePosted time.Time
|
||||
}
|
||||
|
||||
type Videos []Video
|
||||
|
||||
var currencyToSymbol = map[string]string{
|
||||
"USD": "$",
|
||||
"EUR": "€",
|
||||
"JPY": "¥",
|
||||
"CAD": "C$",
|
||||
"AUD": "A$",
|
||||
"GBP": "£",
|
||||
"CHF": "Fr",
|
||||
"NZD": "N$",
|
||||
"INR": "₹",
|
||||
"BRL": "R$",
|
||||
"RUB": "₽",
|
||||
"TRY": "₺",
|
||||
"ZAR": "R",
|
||||
"CNY": "¥",
|
||||
"KRW": "₩",
|
||||
"HKD": "HK$",
|
||||
"SGD": "S$",
|
||||
"SEK": "kr",
|
||||
"NOK": "kr",
|
||||
"DKK": "kr",
|
||||
"PLN": "zł",
|
||||
"PHP": "₱",
|
||||
}
|
||||
|
||||
type DNSStats struct {
|
||||
TotalQueries int
|
||||
BlockedQueries int
|
||||
BlockedPercent int
|
||||
ResponseTime int
|
||||
DomainsBlocked int
|
||||
Series [8]DNSStatsSeries
|
||||
TopBlockedDomains []DNSStatsBlockedDomain
|
||||
}
|
||||
|
||||
type DNSStatsSeries struct {
|
||||
Queries int
|
||||
Blocked int
|
||||
PercentTotal int
|
||||
PercentBlocked int
|
||||
}
|
||||
|
||||
type DNSStatsBlockedDomain struct {
|
||||
Domain string
|
||||
PercentBlocked int
|
||||
}
|
||||
|
||||
type MarketRequest struct {
|
||||
Name string `yaml:"name"`
|
||||
Symbol string `yaml:"symbol"`
|
||||
ChartLink string `yaml:"chart-link"`
|
||||
SymbolLink string `yaml:"symbol-link"`
|
||||
}
|
||||
|
||||
type Market struct {
|
||||
MarketRequest
|
||||
Currency string `yaml:"-"`
|
||||
Price float64 `yaml:"-"`
|
||||
PercentChange float64 `yaml:"-"`
|
||||
SvgChartPoints string `yaml:"-"`
|
||||
}
|
||||
|
||||
type Markets []Market
|
||||
|
||||
func (t Markets) SortByAbsChange() {
|
||||
sort.Slice(t, func(i, j int) bool {
|
||||
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
|
||||
})
|
||||
}
|
||||
|
||||
var weatherCodeTable = map[int]string{
|
||||
0: "Clear Sky",
|
||||
1: "Mainly Clear",
|
||||
2: "Partly Cloudy",
|
||||
3: "Overcast",
|
||||
45: "Fog",
|
||||
48: "Rime Fog",
|
||||
51: "Drizzle",
|
||||
53: "Drizzle",
|
||||
55: "Drizzle",
|
||||
56: "Drizzle",
|
||||
57: "Drizzle",
|
||||
61: "Rain",
|
||||
63: "Moderate Rain",
|
||||
65: "Heavy Rain",
|
||||
66: "Freezing Rain",
|
||||
67: "Freezing Rain",
|
||||
71: "Snow",
|
||||
73: "Moderate Snow",
|
||||
75: "Heavy Snow",
|
||||
77: "Snow Grains",
|
||||
80: "Rain",
|
||||
81: "Moderate Rain",
|
||||
82: "Heavy Rain",
|
||||
85: "Snow",
|
||||
86: "Snow",
|
||||
95: "Thunderstorm",
|
||||
96: "Thunderstorm",
|
||||
99: "Thunderstorm",
|
||||
}
|
||||
|
||||
func (w *Weather) WeatherCodeAsString() string {
|
||||
if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok {
|
||||
return weatherCode
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
const depreciatePostsOlderThanHours = 7
|
||||
const maxDepreciation = 0.9
|
||||
const maxDepreciationAfterHours = 24
|
||||
|
||||
func (p ForumPosts) CalculateEngagement() {
|
||||
var totalComments int
|
||||
var totalScore int
|
||||
|
||||
for i := range p {
|
||||
totalComments += p[i].CommentCount
|
||||
totalScore += p[i].Score
|
||||
}
|
||||
|
||||
numberOfPosts := float64(len(p))
|
||||
averageComments := float64(totalComments) / numberOfPosts
|
||||
averageScore := float64(totalScore) / numberOfPosts
|
||||
|
||||
for i := range p {
|
||||
p[i].Engagement = (float64(p[i].CommentCount)/averageComments + float64(p[i].Score)/averageScore) / 2
|
||||
|
||||
elapsed := time.Since(p[i].TimePosted)
|
||||
|
||||
if elapsed < time.Hour*depreciatePostsOlderThanHours {
|
||||
continue
|
||||
}
|
||||
|
||||
p[i].Engagement *= 1.0 - (math.Max(elapsed.Hours()-depreciatePostsOlderThanHours, maxDepreciationAfterHours)/maxDepreciationAfterHours)*maxDepreciation
|
||||
}
|
||||
}
|
||||
|
||||
func (p ForumPosts) SortByEngagement() {
|
||||
sort.Slice(p, func(i, j int) bool {
|
||||
return p[i].Engagement > p[j].Engagement
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ForumPost) HasTargetUrl() bool {
|
||||
return s.TargetUrl != ""
|
||||
}
|
||||
|
||||
func (p ForumPosts) FilterPostedBefore(postedBefore time.Duration) []ForumPost {
|
||||
recent := make([]ForumPost, 0, len(p))
|
||||
|
||||
for i := range p {
|
||||
if time.Since(p[i].TimePosted) < postedBefore {
|
||||
recent = append(recent, p[i])
|
||||
}
|
||||
}
|
||||
|
||||
return recent
|
||||
}
|
||||
|
||||
func (r AppReleases) SortByNewest() AppReleases {
|
||||
sort.Slice(r, func(i, j int) bool {
|
||||
return r[i].TimeReleased.After(r[j].TimeReleased)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (v Videos) SortByNewest() Videos {
|
||||
sort.Slice(v, func(i, j int) bool {
|
||||
return v[i].TimePosted.After(v[j].TimePosted)
|
||||
})
|
||||
|
||||
return v
|
||||
}
|
|
@ -1,146 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type subredditResponseJson struct {
|
||||
Data struct {
|
||||
Children []struct {
|
||||
Data struct {
|
||||
Id string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Upvotes int `json:"ups"`
|
||||
Url string `json:"url"`
|
||||
Time float64 `json:"created"`
|
||||
CommentsCount int `json:"num_comments"`
|
||||
Domain string `json:"domain"`
|
||||
Permalink string `json:"permalink"`
|
||||
Stickied bool `json:"stickied"`
|
||||
Pinned bool `json:"pinned"`
|
||||
IsSelf bool `json:"is_self"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
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:"children"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func templateRedditCommentsURL(template, subreddit, postId, postPath string) string {
|
||||
template = strings.ReplaceAll(template, "{SUBREDDIT}", subreddit)
|
||||
template = strings.ReplaceAll(template, "{POST-ID}", postId)
|
||||
template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/"))
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (ForumPosts, error) {
|
||||
query := url.Values{}
|
||||
var requestUrl string
|
||||
|
||||
if search != "" {
|
||||
query.Set("q", search+" subreddit:"+subreddit)
|
||||
query.Set("sort", sort)
|
||||
}
|
||||
|
||||
if sort == "top" {
|
||||
query.Set("t", topPeriod)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
requestUrl = fmt.Sprintf("https://www.reddit.com/search.json?%s", query.Encode())
|
||||
} else {
|
||||
requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode())
|
||||
}
|
||||
|
||||
if requestUrlTemplate != "" {
|
||||
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest("GET", requestUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
|
||||
addBrowserUserAgentHeader(request)
|
||||
responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(responseJson.Data.Children) == 0 {
|
||||
return nil, fmt.Errorf("no posts found")
|
||||
}
|
||||
|
||||
posts := make(ForumPosts, 0, len(responseJson.Data.Children))
|
||||
|
||||
for i := range responseJson.Data.Children {
|
||||
post := &responseJson.Data.Children[i].Data
|
||||
|
||||
if post.Stickied || post.Pinned {
|
||||
continue
|
||||
}
|
||||
|
||||
var commentsUrl string
|
||||
|
||||
if commentsUrlTemplate == "" {
|
||||
commentsUrl = "https://www.reddit.com" + post.Permalink
|
||||
} else {
|
||||
commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
|
||||
}
|
||||
|
||||
forumPost := ForumPost{
|
||||
Title: html.UnescapeString(post.Title),
|
||||
DiscussionUrl: commentsUrl,
|
||||
TargetUrlDomain: post.Domain,
|
||||
CommentCount: post.CommentsCount,
|
||||
Score: post.Upvotes,
|
||||
TimePosted: time.Unix(int64(post.Time), 0),
|
||||
}
|
||||
|
||||
if post.Thumbnail != "" && post.Thumbnail != "self" && post.Thumbnail != "default" {
|
||||
forumPost.ThumbnailUrl = post.Thumbnail
|
||||
}
|
||||
|
||||
if !post.IsSelf {
|
||||
forumPost.TargetUrl = post.Url
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type ReleaseSource string
|
||||
|
||||
const (
|
||||
ReleaseSourceCodeberg ReleaseSource = "codeberg"
|
||||
ReleaseSourceGithub ReleaseSource = "github"
|
||||
ReleaseSourceGitlab ReleaseSource = "gitlab"
|
||||
ReleaseSourceDockerHub ReleaseSource = "dockerhub"
|
||||
)
|
||||
|
||||
type ReleaseRequest struct {
|
||||
Source ReleaseSource
|
||||
Repository string
|
||||
Token *string
|
||||
}
|
||||
|
||||
func FetchLatestReleases(requests []*ReleaseRequest) (AppReleases, error) {
|
||||
job := newJob(fetchLatestReleaseTask, requests).withWorkers(20)
|
||||
results, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var failed int
|
||||
|
||||
releases := make(AppReleases, 0, len(requests))
|
||||
|
||||
for i := range results {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Error("Failed to fetch release", "source", requests[i].Source, "repository", requests[i].Repository, "error", errs[i])
|
||||
continue
|
||||
}
|
||||
|
||||
releases = append(releases, *results[i])
|
||||
}
|
||||
|
||||
if failed == len(requests) {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
releases.SortByNewest()
|
||||
|
||||
if failed > 0 {
|
||||
return releases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
|
||||
}
|
||||
|
||||
return releases, nil
|
||||
}
|
||||
|
||||
func fetchLatestReleaseTask(request *ReleaseRequest) (*AppRelease, error) {
|
||||
switch request.Source {
|
||||
case ReleaseSourceCodeberg:
|
||||
return fetchLatestCodebergRelease(request)
|
||||
case ReleaseSourceGithub:
|
||||
return fetchLatestGithubRelease(request)
|
||||
case ReleaseSourceGitlab:
|
||||
return fetchLatestGitLabRelease(request)
|
||||
case ReleaseSourceDockerHub:
|
||||
return fetchLatestDockerHubRelease(request)
|
||||
}
|
||||
|
||||
return nil, errors.New("unsupported source")
|
||||
}
|
|
@ -1,104 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type marketResponseJson struct {
|
||||
Chart struct {
|
||||
Result []struct {
|
||||
Meta struct {
|
||||
Currency string `json:"currency"`
|
||||
Symbol string `json:"symbol"`
|
||||
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||
ChartPreviousClose float64 `json:"chartPreviousClose"`
|
||||
} `json:"meta"`
|
||||
Indicators struct {
|
||||
Quote []struct {
|
||||
Close []float64 `json:"close,omitempty"`
|
||||
} `json:"quote"`
|
||||
} `json:"indicators"`
|
||||
} `json:"result"`
|
||||
} `json:"chart"`
|
||||
}
|
||||
|
||||
// TODO: allow changing chart time frame
|
||||
const marketChartDays = 21
|
||||
|
||||
func FetchMarketsDataFromYahoo(marketRequests []MarketRequest) (Markets, error) {
|
||||
requests := make([]*http.Request, 0, len(marketRequests))
|
||||
|
||||
for i := range marketRequests {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil)
|
||||
requests = append(requests, request)
|
||||
}
|
||||
|
||||
job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests)
|
||||
responses, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
||||
}
|
||||
|
||||
markets := make(Markets, 0, len(responses))
|
||||
var failed int
|
||||
|
||||
for i := range responses {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i])
|
||||
continue
|
||||
}
|
||||
|
||||
response := responses[i]
|
||||
|
||||
if len(response.Chart.Result) == 0 {
|
||||
failed++
|
||||
slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
|
||||
continue
|
||||
}
|
||||
|
||||
prices := response.Chart.Result[0].Indicators.Quote[0].Close
|
||||
|
||||
if len(prices) > marketChartDays {
|
||||
prices = prices[len(prices)-marketChartDays:]
|
||||
}
|
||||
|
||||
previous := response.Chart.Result[0].Meta.RegularMarketPrice
|
||||
|
||||
if len(prices) >= 2 && prices[len(prices)-2] != 0 {
|
||||
previous = prices[len(prices)-2]
|
||||
}
|
||||
|
||||
points := SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
|
||||
|
||||
currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency]
|
||||
|
||||
if !exists {
|
||||
currency = response.Chart.Result[0].Meta.Currency
|
||||
}
|
||||
|
||||
markets = append(markets, Market{
|
||||
MarketRequest: marketRequests[i],
|
||||
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
|
||||
Currency: currency,
|
||||
PercentChange: percentChange(
|
||||
response.Chart.Result[0].Meta.RegularMarketPrice,
|
||||
previous,
|
||||
),
|
||||
SvgChartPoints: points,
|
||||
})
|
||||
}
|
||||
|
||||
if len(markets) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", ErrPartialContent, failed)
|
||||
}
|
||||
|
||||
return markets, nil
|
||||
}
|
|
@ -1,115 +0,0 @@
|
|||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type youtubeFeedResponseXml struct {
|
||||
Channel string `xml:"author>name"`
|
||||
ChannelLink string `xml:"author>uri"`
|
||||
Videos []struct {
|
||||
Title string `xml:"title"`
|
||||
Published string `xml:"published"`
|
||||
Link struct {
|
||||
Href string `xml:"href,attr"`
|
||||
} `xml:"link"`
|
||||
|
||||
Group struct {
|
||||
Thumbnail struct {
|
||||
Url string `xml:"url,attr"`
|
||||
} `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||
} `xml:"http://search.yahoo.com/mrss/ group"`
|
||||
} `xml:"entry"`
|
||||
}
|
||||
|
||||
func parseYoutubeFeedTime(t string) time.Time {
|
||||
parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t)
|
||||
|
||||
if err != nil {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
return parsedTime
|
||||
}
|
||||
|
||||
func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, includeShorts bool) (Videos, error) {
|
||||
requests := make([]*http.Request, 0, len(channelIds))
|
||||
|
||||
for i := range channelIds {
|
||||
var feedUrl string
|
||||
if !includeShorts && strings.HasPrefix(channelIds[i], "UC") {
|
||||
playlistId := strings.Replace(channelIds[i], "UC", "UULF", 1)
|
||||
feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId
|
||||
} else {
|
||||
feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelIds[i]
|
||||
}
|
||||
|
||||
request, _ := http.NewRequest("GET", feedUrl, nil)
|
||||
requests = append(requests, request)
|
||||
}
|
||||
|
||||
job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultClient), requests).withWorkers(30)
|
||||
|
||||
responses, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
||||
}
|
||||
|
||||
videos := make(Videos, 0, len(channelIds)*15)
|
||||
|
||||
var failed int
|
||||
|
||||
for i := range responses {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Error("Failed to fetch youtube feed", "channel", channelIds[i], "error", errs[i])
|
||||
continue
|
||||
}
|
||||
|
||||
response := responses[i]
|
||||
|
||||
for j := range response.Videos {
|
||||
video := &response.Videos[j]
|
||||
var videoUrl string
|
||||
|
||||
if videoUrlTemplate == "" {
|
||||
videoUrl = video.Link.Href
|
||||
} else {
|
||||
parsedUrl, err := url.Parse(video.Link.Href)
|
||||
|
||||
if err == nil {
|
||||
videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v"))
|
||||
} else {
|
||||
videoUrl = "#"
|
||||
}
|
||||
}
|
||||
|
||||
videos = append(videos, Video{
|
||||
ThumbnailUrl: video.Group.Thumbnail.Url,
|
||||
Title: video.Title,
|
||||
Url: videoUrl,
|
||||
Author: response.Channel,
|
||||
AuthorUrl: response.ChannelLink + "/videos",
|
||||
TimePosted: parseYoutubeFeedTime(video.Published),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(videos) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
videos.SortByNewest()
|
||||
|
||||
if failed > 0 {
|
||||
return videos, fmt.Errorf("%w: missing videos from %d channels", ErrPartialContent, failed)
|
||||
}
|
||||
|
||||
return videos, nil
|
||||
}
|
|
@ -2,41 +2,66 @@ package glance
|
|||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CliIntent uint8
|
||||
type cliIntent uint8
|
||||
|
||||
const (
|
||||
CliIntentServe CliIntent = iota
|
||||
CliIntentCheckConfig = iota
|
||||
cliIntentServe cliIntent = iota
|
||||
cliIntentConfigValidate = iota
|
||||
cliIntentConfigPrint = iota
|
||||
cliIntentDiagnose = iota
|
||||
)
|
||||
|
||||
type CliOptions struct {
|
||||
Intent CliIntent
|
||||
ConfigPath string
|
||||
type cliOptions struct {
|
||||
intent cliIntent
|
||||
configPath string
|
||||
}
|
||||
|
||||
func ParseCliOptions() (*CliOptions, error) {
|
||||
func parseCliOptions() (*cliOptions, error) {
|
||||
flags := flag.NewFlagSet("", flag.ExitOnError)
|
||||
flags.Usage = func() {
|
||||
fmt.Println("Usage: glance [options] command")
|
||||
|
||||
checkConfig := flags.Bool("check-config", false, "Check whether the config is valid")
|
||||
fmt.Println("\nOptions:")
|
||||
flags.PrintDefaults()
|
||||
|
||||
fmt.Println("\nCommands:")
|
||||
fmt.Println(" config:validate Validate the config file")
|
||||
fmt.Println(" config:print Print the parsed config file with embedded includes")
|
||||
fmt.Println(" diagnose Run diagnostic checks")
|
||||
}
|
||||
configPath := flags.String("config", "glance.yml", "Set config path")
|
||||
|
||||
err := flags.Parse(os.Args[1:])
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
intent := CliIntentServe
|
||||
var intent cliIntent
|
||||
var args = flags.Args()
|
||||
unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
|
||||
|
||||
if *checkConfig {
|
||||
intent = CliIntentCheckConfig
|
||||
if len(args) == 0 {
|
||||
intent = cliIntentServe
|
||||
} else if len(args) == 1 {
|
||||
if args[0] == "config:validate" {
|
||||
intent = cliIntentConfigValidate
|
||||
} else if args[0] == "config:print" {
|
||||
intent = cliIntentConfigPrint
|
||||
} else if args[0] == "diagnose" {
|
||||
intent = cliIntentDiagnose
|
||||
} else {
|
||||
return nil, unknownCommandErr
|
||||
}
|
||||
} else {
|
||||
return nil, unknownCommandErr
|
||||
}
|
||||
|
||||
return &CliOptions{
|
||||
Intent: intent,
|
||||
ConfigPath: *configPath,
|
||||
return &cliOptions{
|
||||
intent: intent,
|
||||
configPath: *configPath,
|
||||
}, nil
|
||||
}
|
||||
|
|
221
internal/glance/config-fields.go
Normal file
|
@ -0,0 +1,221 @@
|
|||
package glance
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
|
||||
|
||||
const (
|
||||
hslHueMax = 360
|
||||
hslSaturationMax = 100
|
||||
hslLightnessMax = 100
|
||||
)
|
||||
|
||||
type hslColorField struct {
|
||||
Hue uint16
|
||||
Saturation uint8
|
||||
Lightness uint8
|
||||
}
|
||||
|
||||
func (c *hslColorField) String() string {
|
||||
return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness)
|
||||
}
|
||||
|
||||
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
||||
var value string
|
||||
|
||||
if err := node.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
matches := hslColorFieldPattern.FindStringSubmatch(value)
|
||||
|
||||
if len(matches) != 4 {
|
||||
return fmt.Errorf("invalid HSL color format: %s", value)
|
||||
}
|
||||
|
||||
hue, err := strconv.ParseUint(matches[1], 10, 16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hue > hslHueMax {
|
||||
return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax)
|
||||
}
|
||||
|
||||
saturation, err := strconv.ParseUint(matches[2], 10, 8)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if saturation > hslSaturationMax {
|
||||
return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax)
|
||||
}
|
||||
|
||||
lightness, err := strconv.ParseUint(matches[3], 10, 8)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if lightness > hslLightnessMax {
|
||||
return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
|
||||
}
|
||||
|
||||
c.Hue = uint16(hue)
|
||||
c.Saturation = uint8(saturation)
|
||||
c.Lightness = uint8(lightness)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
|
||||
|
||||
type durationField time.Duration
|
||||
|
||||
func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
|
||||
var value string
|
||||
|
||||
if err := node.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
matches := durationFieldPattern.FindStringSubmatch(value)
|
||||
|
||||
if len(matches) != 3 {
|
||||
return fmt.Errorf("invalid duration format: %s", value)
|
||||
}
|
||||
|
||||
duration, err := strconv.Atoi(matches[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch matches[2] {
|
||||
case "s":
|
||||
*d = durationField(time.Duration(duration) * time.Second)
|
||||
case "m":
|
||||
*d = durationField(time.Duration(duration) * time.Minute)
|
||||
case "h":
|
||||
*d = durationField(time.Duration(duration) * time.Hour)
|
||||
case "d":
|
||||
*d = durationField(time.Duration(duration) * 24 * time.Hour)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type customIconField struct {
|
||||
URL string
|
||||
IsFlatIcon bool
|
||||
// TODO: along with whether the icon is flat, we also need to know
|
||||
// whether the icon is black or white by default in order to properly
|
||||
// invert the color based on the theme being light or dark
|
||||
}
|
||||
|
||||
func newCustomIconField(value string) customIconField {
|
||||
field := customIconField{}
|
||||
|
||||
prefix, icon, found := strings.Cut(value, ":")
|
||||
if !found {
|
||||
field.URL = value
|
||||
return field
|
||||
}
|
||||
|
||||
switch prefix {
|
||||
case "si":
|
||||
field.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg"
|
||||
field.IsFlatIcon = true
|
||||
case "di", "sh":
|
||||
// syntax: di:<icon_name>[.svg|.png]
|
||||
// syntax: sh:<icon_name>[.svg|.png]
|
||||
// if the icon name is specified without extension, it is assumed to be wanting the SVG icon
|
||||
// otherwise, specify the extension of either .svg or .png to use either of the CDN offerings
|
||||
// any other extension will be interpreted as .svg
|
||||
basename, ext, found := strings.Cut(icon, ".")
|
||||
if !found {
|
||||
ext = "svg"
|
||||
basename = icon
|
||||
}
|
||||
|
||||
if ext != "svg" && ext != "png" {
|
||||
ext = "svg"
|
||||
}
|
||||
|
||||
if prefix == "di" {
|
||||
field.URL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext
|
||||
} else {
|
||||
field.URL = "https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext
|
||||
}
|
||||
default:
|
||||
field.URL = value
|
||||
}
|
||||
|
||||
return field
|
||||
}
|
||||
|
||||
func (i *customIconField) UnmarshalYAML(node *yaml.Node) error {
|
||||
var value string
|
||||
if err := node.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*i = newCustomIconField(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
type proxyOptionsField struct {
|
||||
URL string `yaml:"url"`
|
||||
AllowInsecure bool `yaml:"allow-insecure"`
|
||||
Timeout durationField `yaml:"timeout"`
|
||||
client *http.Client `yaml:"-"`
|
||||
}
|
||||
|
||||
func (p *proxyOptionsField) UnmarshalYAML(node *yaml.Node) error {
|
||||
type proxyOptionsFieldAlias proxyOptionsField
|
||||
alias := (*proxyOptionsFieldAlias)(p)
|
||||
var proxyURL string
|
||||
|
||||
if err := node.Decode(&proxyURL); err != nil {
|
||||
if err := node.Decode(alias); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if proxyURL == "" && p.URL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.URL != "" {
|
||||
proxyURL = p.URL
|
||||
}
|
||||
|
||||
parsedUrl, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing proxy URL: %v", err)
|
||||
}
|
||||
|
||||
var timeout = defaultClientTimeout
|
||||
if p.Timeout > 0 {
|
||||
timeout = time.Duration(p.Timeout)
|
||||
}
|
||||
|
||||
p.client = &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(parsedUrl),
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: p.AllowInsecure},
|
||||
},
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -1,43 +1,96 @@
|
|||
package glance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"html/template"
|
||||
"log"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server Server `yaml:"server"`
|
||||
Theme Theme `yaml:"theme"`
|
||||
Branding Branding `yaml:"branding"`
|
||||
Pages []Page `yaml:"pages"`
|
||||
type config struct {
|
||||
Server struct {
|
||||
Host string `yaml:"host"`
|
||||
Port uint16 `yaml:"port"`
|
||||
AssetsPath string `yaml:"assets-path"`
|
||||
BaseURL string `yaml:"base-url"`
|
||||
StartedAt time.Time `yaml:"-"` // used in custom css file
|
||||
} `yaml:"server"`
|
||||
|
||||
Document struct {
|
||||
Head template.HTML `yaml:"head"`
|
||||
} `yaml:"document"`
|
||||
|
||||
Theme struct {
|
||||
BackgroundColor *hslColorField `yaml:"background-color"`
|
||||
PrimaryColor *hslColorField `yaml:"primary-color"`
|
||||
PositiveColor *hslColorField `yaml:"positive-color"`
|
||||
NegativeColor *hslColorField `yaml:"negative-color"`
|
||||
Light bool `yaml:"light"`
|
||||
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
||||
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
||||
CustomCSSFile string `yaml:"custom-css-file"`
|
||||
} `yaml:"theme"`
|
||||
|
||||
Branding struct {
|
||||
HideFooter bool `yaml:"hide-footer"`
|
||||
CustomFooter template.HTML `yaml:"custom-footer"`
|
||||
LogoText string `yaml:"logo-text"`
|
||||
LogoURL string `yaml:"logo-url"`
|
||||
FaviconURL string `yaml:"favicon-url"`
|
||||
} `yaml:"branding"`
|
||||
|
||||
Pages []page `yaml:"pages"`
|
||||
}
|
||||
|
||||
func NewConfigFromYml(contents io.Reader) (*Config, error) {
|
||||
config := NewConfig()
|
||||
|
||||
contentBytes, err := io.ReadAll(contents)
|
||||
type page struct {
|
||||
Title string `yaml:"name"`
|
||||
Slug string `yaml:"slug"`
|
||||
Width string `yaml:"width"`
|
||||
ShowMobileHeader bool `yaml:"show-mobile-header"`
|
||||
ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"`
|
||||
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
||||
CenterVertically bool `yaml:"center-vertically"`
|
||||
Columns []struct {
|
||||
Size string `yaml:"size"`
|
||||
Widgets widgets `yaml:"widgets"`
|
||||
} `yaml:"columns"`
|
||||
PrimaryColumnIndex int8 `yaml:"-"`
|
||||
mu sync.Mutex `yaml:"-"`
|
||||
}
|
||||
|
||||
func newConfigFromYAML(contents []byte) (*config, error) {
|
||||
contents, err := parseConfigEnvVariables(contents)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = yaml.Unmarshal(contentBytes, config)
|
||||
config := &config{}
|
||||
config.Server.Port = 8080
|
||||
|
||||
err = yaml.Unmarshal(contents, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = configIsValid(config); err != nil {
|
||||
if err = isConfigStateValid(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for p := range config.Pages {
|
||||
for c := range config.Pages[p].Columns {
|
||||
for w := range config.Pages[p].Columns[c].Widgets {
|
||||
if err := config.Pages[p].Columns[c].Widgets[w].Initialize(); err != nil {
|
||||
return nil, err
|
||||
if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil {
|
||||
return nil, formatWidgetInitError(err, config.Pages[p].Columns[c].Widgets[w])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -46,36 +99,278 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
|
|||
return config, nil
|
||||
}
|
||||
|
||||
func NewConfig() *Config {
|
||||
config := &Config{}
|
||||
// TODO: change the pattern so that it doesn't match commented out lines
|
||||
var configEnvVariablePattern = regexp.MustCompile(`(^|.)\$\{([A-Z0-9_]+)\}`)
|
||||
|
||||
config.Server.Host = ""
|
||||
config.Server.Port = 8080
|
||||
func parseConfigEnvVariables(contents []byte) ([]byte, error) {
|
||||
var err error
|
||||
|
||||
return config
|
||||
replaced := configEnvVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
groups := configEnvVariablePattern.FindSubmatch(match)
|
||||
if len(groups) != 3 {
|
||||
return match
|
||||
}
|
||||
|
||||
prefix, key := string(groups[1]), string(groups[2])
|
||||
if prefix == `\` {
|
||||
if len(match) >= 2 {
|
||||
return match[1:]
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
value, found := os.LookupEnv(key)
|
||||
if !found {
|
||||
err = fmt.Errorf("environment variable %s not found", key)
|
||||
return nil
|
||||
}
|
||||
|
||||
return []byte(prefix + value)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return replaced, nil
|
||||
}
|
||||
|
||||
func configIsValid(config *Config) error {
|
||||
func formatWidgetInitError(err error, w widget) error {
|
||||
return fmt.Errorf("%s widget: %v", w.GetType(), err)
|
||||
}
|
||||
|
||||
var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`)
|
||||
|
||||
func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
|
||||
mainFileContents, err := os.ReadFile(mainFilePath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("reading main YAML file: %w", err)
|
||||
}
|
||||
|
||||
mainFileAbsPath, err := filepath.Abs(mainFilePath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("getting absolute path of main YAML file: %w", err)
|
||||
}
|
||||
mainFileDir := filepath.Dir(mainFileAbsPath)
|
||||
|
||||
includes := make(map[string]struct{})
|
||||
var includesLastErr error
|
||||
|
||||
mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
|
||||
if includesLastErr != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
matches := includePattern.FindSubmatch(match)
|
||||
if len(matches) != 3 {
|
||||
includesLastErr = fmt.Errorf("invalid include match: %v", matches)
|
||||
return nil
|
||||
}
|
||||
|
||||
indent := string(matches[1])
|
||||
includeFilePath := strings.TrimSpace(string(matches[2]))
|
||||
if !filepath.IsAbs(includeFilePath) {
|
||||
includeFilePath = filepath.Join(mainFileDir, includeFilePath)
|
||||
}
|
||||
|
||||
var fileContents []byte
|
||||
var err error
|
||||
|
||||
fileContents, err = os.ReadFile(includeFilePath)
|
||||
if err != nil {
|
||||
includesLastErr = fmt.Errorf("reading included file %s: %w", includeFilePath, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
includes[includeFilePath] = struct{}{}
|
||||
return []byte(prefixStringLines(indent, string(fileContents)))
|
||||
})
|
||||
|
||||
if includesLastErr != nil {
|
||||
return nil, nil, includesLastErr
|
||||
}
|
||||
|
||||
return mainFileContents, includes, nil
|
||||
}
|
||||
|
||||
func configFilesWatcher(
|
||||
mainFilePath string,
|
||||
lastContents []byte,
|
||||
lastIncludes map[string]struct{},
|
||||
onChange func(newContents []byte),
|
||||
onErr func(error),
|
||||
) (func() error, error) {
|
||||
mainFileAbsPath, err := filepath.Abs(mainFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting absolute path of main file: %w", err)
|
||||
}
|
||||
|
||||
// TODO: refactor, flaky
|
||||
lastIncludes[mainFileAbsPath] = struct{}{}
|
||||
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating watcher: %w", err)
|
||||
}
|
||||
|
||||
updateWatchedFiles := func(previousWatched map[string]struct{}, newWatched map[string]struct{}) {
|
||||
for filePath := range previousWatched {
|
||||
if _, ok := newWatched[filePath]; !ok {
|
||||
watcher.Remove(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
for filePath := range newWatched {
|
||||
if _, ok := previousWatched[filePath]; !ok {
|
||||
if err := watcher.Add(filePath); err != nil {
|
||||
log.Printf(
|
||||
"Could not add file to watcher, changes to this file will not trigger a reload. path: %s, error: %v",
|
||||
filePath, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateWatchedFiles(nil, lastIncludes)
|
||||
|
||||
// needed for lastContents and lastIncludes because they get updated in multiple goroutines
|
||||
mu := sync.Mutex{}
|
||||
|
||||
parseAndCompareBeforeCallback := func() {
|
||||
currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath)
|
||||
if err != nil {
|
||||
onErr(fmt.Errorf("parsing main file contents for comparison: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: refactor, flaky
|
||||
currentIncludes[mainFileAbsPath] = struct{}{}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if !maps.Equal(currentIncludes, lastIncludes) {
|
||||
updateWatchedFiles(lastIncludes, currentIncludes)
|
||||
lastIncludes = currentIncludes
|
||||
}
|
||||
|
||||
if !bytes.Equal(lastContents, currentContents) {
|
||||
lastContents = currentContents
|
||||
onChange(currentContents)
|
||||
}
|
||||
}
|
||||
|
||||
const debounceDuration = 500 * time.Millisecond
|
||||
var debounceTimer *time.Timer
|
||||
debouncedParseAndCompareBeforeCallback := func() {
|
||||
if debounceTimer != nil {
|
||||
debounceTimer.Stop()
|
||||
debounceTimer.Reset(debounceDuration)
|
||||
} else {
|
||||
debounceTimer = time.AfterFunc(debounceDuration, parseAndCompareBeforeCallback)
|
||||
}
|
||||
}
|
||||
|
||||
deleteLastInclude := func(filePath string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
fileAbsPath, _ := filepath.Abs(filePath)
|
||||
delete(lastIncludes, fileAbsPath)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case event, isOpen := <-watcher.Events:
|
||||
if !isOpen {
|
||||
return
|
||||
}
|
||||
if event.Has(fsnotify.Write) {
|
||||
debouncedParseAndCompareBeforeCallback()
|
||||
} else if event.Has(fsnotify.Rename) {
|
||||
// on linux the file will no longer be watched after a rename, on windows
|
||||
// it will continue to be watched with the new name but we have no access to
|
||||
// the new name in this event in order to stop watching it manually and match the
|
||||
// behavior in linux, may lead to weird unintended behaviors on windows as we're
|
||||
// only handling renames from linux's perspective
|
||||
// see https://github.com/fsnotify/fsnotify/issues/255
|
||||
|
||||
// remove the old file from our manually tracked includes, calling
|
||||
// debouncedParseAndCompareBeforeCallback will re-add it if it's still
|
||||
// required after it triggers
|
||||
deleteLastInclude(event.Name)
|
||||
|
||||
// wait for file to maybe get created again
|
||||
// see https://github.com/glanceapp/glance/pull/358
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := os.Stat(event.Name); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
debouncedParseAndCompareBeforeCallback()
|
||||
} else if event.Has(fsnotify.Remove) {
|
||||
deleteLastInclude(event.Name)
|
||||
debouncedParseAndCompareBeforeCallback()
|
||||
}
|
||||
case err, isOpen := <-watcher.Errors:
|
||||
if !isOpen {
|
||||
return
|
||||
}
|
||||
onErr(fmt.Errorf("watcher error: %w", err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
onChange(lastContents)
|
||||
|
||||
return func() error {
|
||||
if debounceTimer != nil {
|
||||
debounceTimer.Stop()
|
||||
}
|
||||
|
||||
return watcher.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isConfigStateValid(config *config) error {
|
||||
if len(config.Pages) == 0 {
|
||||
return fmt.Errorf("no pages configured")
|
||||
}
|
||||
|
||||
if config.Server.AssetsPath != "" {
|
||||
if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range config.Pages {
|
||||
if config.Pages[i].Title == "" {
|
||||
return fmt.Errorf("Page %d has no title", i+1)
|
||||
return fmt.Errorf("page %d has no name", i+1)
|
||||
}
|
||||
|
||||
if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") {
|
||||
return fmt.Errorf("Page %d: width can only be either wide or slim", i+1)
|
||||
return fmt.Errorf("page %d: width can only be either wide or slim", i+1)
|
||||
}
|
||||
|
||||
if len(config.Pages[i].Columns) == 0 {
|
||||
return fmt.Errorf("Page %d has no columns", i+1)
|
||||
return fmt.Errorf("page %d has no columns", i+1)
|
||||
}
|
||||
|
||||
if config.Pages[i].Width == "slim" {
|
||||
if len(config.Pages[i].Columns) > 2 {
|
||||
return fmt.Errorf("Page %d is slim and cannot have more than 2 columns", i+1)
|
||||
return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1)
|
||||
}
|
||||
} else {
|
||||
if len(config.Pages[i].Columns) > 3 {
|
||||
return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns))
|
||||
return fmt.Errorf("page %d has more than 3 columns", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -83,7 +378,7 @@ func configIsValid(config *Config) error {
|
|||
|
||||
for j := range config.Pages[i].Columns {
|
||||
if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" {
|
||||
return fmt.Errorf("Column %d of page %d: size can only be either small or full", j+1, i+1)
|
||||
return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1)
|
||||
}
|
||||
|
||||
columnSizesCount[config.Pages[i].Columns[j].Size]++
|
||||
|
@ -92,7 +387,7 @@ func configIsValid(config *Config) error {
|
|||
full := columnSizesCount["full"]
|
||||
|
||||
if full > 2 || full == 0 {
|
||||
return fmt.Errorf("Page %d must have either 1 or 2 full width columns", i+1)
|
||||
return fmt.Errorf("page %d must have either 1 or 2 full width columns", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
205
internal/glance/diagnose.go
Normal file
|
@ -0,0 +1,205 @@
|
|||
package glance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const httpTestRequestTimeout = 10 * time.Second
|
||||
|
||||
var diagnosticSteps = []diagnosticStep{
|
||||
{
|
||||
name: "resolve cloudflare.com through Cloudflare DoH",
|
||||
fn: func() (string, error) {
|
||||
return testHttpRequestWithHeaders("GET", "https://1.1.1.1/dns-query?name=cloudflare.com", map[string]string{
|
||||
"accept": "application/dns-json",
|
||||
}, 200)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolve cloudflare.com through Google DoH",
|
||||
fn: func() (string, error) {
|
||||
return testHttpRequest("GET", "https://8.8.8.8/resolve?name=cloudflare.com", 200)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolve github.com",
|
||||
fn: func() (string, error) {
|
||||
return testDNSResolution("github.com")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolve reddit.com",
|
||||
fn: func() (string, error) {
|
||||
return testDNSResolution("reddit.com")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolve twitch.tv",
|
||||
fn: func() (string, error) {
|
||||
return testDNSResolution("twitch.tv")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fetch data from YouTube RSS feed",
|
||||
fn: func() (string, error) {
|
||||
return testHttpRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id=UCZU9T1ceaOgwfLRq7OKFU4Q", 200)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fetch data from Twitch.tv GQL",
|
||||
fn: func() (string, error) {
|
||||
// this should always return 0 bytes, we're mainly looking for a 200 status code
|
||||
return testHttpRequest("OPTIONS", "https://gql.twitch.tv/gql", 200)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fetch data from GitHub API",
|
||||
fn: func() (string, error) {
|
||||
return testHttpRequest("GET", "https://api.github.com", 200)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fetch data from Open-Meteo API",
|
||||
fn: func() (string, error) {
|
||||
return testHttpRequest("GET", "https://geocoding-api.open-meteo.com/v1/search?name=London", 200)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fetch data from Reddit API",
|
||||
fn: func() (string, error) {
|
||||
return testHttpRequest("GET", "https://www.reddit.com/search.json", 200)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fetch data from Yahoo finance API",
|
||||
fn: func() (string, error) {
|
||||
return testHttpRequest("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", 200)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fetch data from Hacker News Firebase API",
|
||||
fn: func() (string, error) {
|
||||
return testHttpRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", 200)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fetch data from Docker Hub API",
|
||||
fn: func() (string, error) {
|
||||
return testHttpRequest("GET", "https://hub.docker.com/v2/namespaces/library/repositories/ubuntu/tags/latest", 200)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func runDiagnostic() {
|
||||
fmt.Println("```")
|
||||
fmt.Println("Glance version: " + buildVersion)
|
||||
fmt.Println("Go version: " + runtime.Version())
|
||||
fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU())
|
||||
fmt.Println("In Docker container: " + boolToString(isRunningInsideDockerContainer(), "yes", "no"))
|
||||
|
||||
fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds()))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := range diagnosticSteps {
|
||||
step := &diagnosticSteps[i]
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
start := time.Now()
|
||||
step.extraInfo, step.err = step.fn()
|
||||
step.elapsed = time.Since(start)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, step := range diagnosticSteps {
|
||||
var extraInfo string
|
||||
|
||||
if step.extraInfo != "" {
|
||||
extraInfo = "| " + step.extraInfo + " "
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"%s %s %s| %dms\n",
|
||||
boolToString(step.err == nil, "✓ Can", "✗ Can't"),
|
||||
step.name,
|
||||
extraInfo,
|
||||
step.elapsed.Milliseconds(),
|
||||
)
|
||||
|
||||
if step.err != nil {
|
||||
fmt.Printf("└╴ error: %v\n", step.err)
|
||||
}
|
||||
}
|
||||
fmt.Println("```")
|
||||
}
|
||||
|
||||
type diagnosticStep struct {
|
||||
name string
|
||||
fn func() (string, error)
|
||||
extraInfo string
|
||||
err error
|
||||
elapsed time.Duration
|
||||
}
|
||||
|
||||
func testHttpRequest(method, url string, expectedStatusCode int) (string, error) {
|
||||
return testHttpRequestWithHeaders(method, url, nil, expectedStatusCode)
|
||||
}
|
||||
|
||||
func testHttpRequestWithHeaders(method, url string, headers map[string]string, expectedStatusCode int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), httpTestRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
request, _ := http.NewRequestWithContext(ctx, method, url, nil)
|
||||
for key, value := range headers {
|
||||
request.Header.Add(key, value)
|
||||
}
|
||||
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
printableBody := strings.ReplaceAll(string(body), "\n", "")
|
||||
if len(printableBody) > 50 {
|
||||
printableBody = printableBody[:50] + "..."
|
||||
}
|
||||
if len(printableBody) > 0 {
|
||||
printableBody = ", " + printableBody
|
||||
}
|
||||
|
||||
extraInfo := fmt.Sprintf("%d bytes%s", len(body), printableBody)
|
||||
|
||||
if response.StatusCode != expectedStatusCode {
|
||||
return extraInfo, fmt.Errorf("expected status code %d, got %d", expectedStatusCode, response.StatusCode)
|
||||
}
|
||||
|
||||
return extraInfo, nil
|
||||
}
|
||||
|
||||
func testDNSResolution(domain string) (string, error) {
|
||||
ips, err := net.LookupIP(domain)
|
||||
|
||||
var ipStrings []string
|
||||
if err == nil {
|
||||
for i := range ips {
|
||||
ipStrings = append(ipStrings, ips[i].String())
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(ipStrings, ", "), err
|
||||
}
|
62
internal/glance/embed.go
Normal file
|
@ -0,0 +1,62 @@
|
|||
package glance
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var _staticFS embed.FS
|
||||
|
||||
//go:embed templates
|
||||
var _templateFS embed.FS
|
||||
|
||||
var staticFS, _ = fs.Sub(_staticFS, "static")
|
||||
var templateFS, _ = fs.Sub(_templateFS, "templates")
|
||||
|
||||
var staticFSHash = func() string {
|
||||
hash, err := computeFSHash(staticFS)
|
||||
if err != nil {
|
||||
log.Printf("Could not compute static assets cache key: %v", err)
|
||||
return strconv.FormatInt(time.Now().Unix(), 10)
|
||||
}
|
||||
|
||||
return hash
|
||||
}()
|
||||
|
||||
func computeFSHash(files fs.FS) (string, error) {
|
||||
hash := md5.New()
|
||||
|
||||
err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := files.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(hash.Sum(nil))[:10], nil
|
||||
}
|
|
@ -5,80 +5,93 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
"github.com/glanceapp/glance/internal/widget"
|
||||
)
|
||||
|
||||
var buildVersion = "dev"
|
||||
var (
|
||||
pageTemplate = mustParseTemplate("page.html", "document.html")
|
||||
pageContentTemplate = mustParseTemplate("page-content.html")
|
||||
pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
|
||||
)
|
||||
|
||||
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
|
||||
type application struct {
|
||||
Version string
|
||||
Config config
|
||||
ParsedThemeStyle template.HTML
|
||||
|
||||
type Application struct {
|
||||
Version string
|
||||
Config Config
|
||||
slugToPage map[string]*Page
|
||||
widgetByID map[uint64]widget.Widget
|
||||
slugToPage map[string]*page
|
||||
widgetByID map[uint64]widget
|
||||
}
|
||||
|
||||
type Theme struct {
|
||||
BackgroundColor *widget.HSLColorField `yaml:"background-color"`
|
||||
PrimaryColor *widget.HSLColorField `yaml:"primary-color"`
|
||||
PositiveColor *widget.HSLColorField `yaml:"positive-color"`
|
||||
NegativeColor *widget.HSLColorField `yaml:"negative-color"`
|
||||
Light bool `yaml:"light"`
|
||||
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
||||
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
||||
CustomCSSFile string `yaml:"custom-css-file"`
|
||||
func newApplication(config *config) (*application, error) {
|
||||
app := &application{
|
||||
Version: buildVersion,
|
||||
Config: *config,
|
||||
slugToPage: make(map[string]*page),
|
||||
widgetByID: make(map[uint64]widget),
|
||||
}
|
||||
|
||||
app.slugToPage[""] = &config.Pages[0]
|
||||
|
||||
providers := &widgetProviders{
|
||||
assetResolver: app.AssetPath,
|
||||
}
|
||||
|
||||
var err error
|
||||
app.ParsedThemeStyle, err = executeTemplateToHTML(pageThemeStyleTemplate, &app.Config.Theme)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing theme style: %v", err)
|
||||
}
|
||||
|
||||
for p := range config.Pages {
|
||||
page := &config.Pages[p]
|
||||
page.PrimaryColumnIndex = -1
|
||||
|
||||
if page.Slug == "" {
|
||||
page.Slug = titleToSlug(page.Title)
|
||||
}
|
||||
|
||||
app.slugToPage[page.Slug] = page
|
||||
|
||||
for c := range page.Columns {
|
||||
column := &page.Columns[c]
|
||||
|
||||
if page.PrimaryColumnIndex == -1 && column.Size == "full" {
|
||||
page.PrimaryColumnIndex = int8(c)
|
||||
}
|
||||
|
||||
for w := range column.Widgets {
|
||||
widget := column.Widgets[w]
|
||||
app.widgetByID[widget.GetID()] = widget
|
||||
|
||||
widget.setProviders(providers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config = &app.Config
|
||||
|
||||
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
|
||||
config.Theme.CustomCSSFile = app.transformUserDefinedAssetPath(config.Theme.CustomCSSFile)
|
||||
|
||||
if config.Branding.FaviconURL == "" {
|
||||
config.Branding.FaviconURL = app.AssetPath("favicon.png")
|
||||
} else {
|
||||
config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL)
|
||||
}
|
||||
|
||||
config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL)
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Host string `yaml:"host"`
|
||||
Port uint16 `yaml:"port"`
|
||||
AssetsPath string `yaml:"assets-path"`
|
||||
BaseURL string `yaml:"base-url"`
|
||||
AssetsHash string `yaml:"-"`
|
||||
StartedAt time.Time `yaml:"-"` // used in custom css file
|
||||
}
|
||||
|
||||
type Branding struct {
|
||||
HideFooter bool `yaml:"hide-footer"`
|
||||
CustomFooter template.HTML `yaml:"custom-footer"`
|
||||
LogoText string `yaml:"logo-text"`
|
||||
LogoURL string `yaml:"logo-url"`
|
||||
FaviconURL string `yaml:"favicon-url"`
|
||||
}
|
||||
|
||||
type Column struct {
|
||||
Size string `yaml:"size"`
|
||||
Widgets widget.Widgets `yaml:"widgets"`
|
||||
}
|
||||
|
||||
type templateData struct {
|
||||
App *Application
|
||||
Page *Page
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
Title string `yaml:"name"`
|
||||
Slug string `yaml:"slug"`
|
||||
Width string `yaml:"width"`
|
||||
ShowMobileHeader bool `yaml:"show-mobile-header"`
|
||||
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
||||
CenterVertically bool `yaml:"center-vertically"`
|
||||
Columns []Column `yaml:"columns"`
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (p *Page) UpdateOutdatedWidgets() {
|
||||
func (p *page) updateOutdatedWidgets() {
|
||||
now := time.Now()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
@ -88,14 +101,14 @@ func (p *Page) UpdateOutdatedWidgets() {
|
|||
for w := range p.Columns[c].Widgets {
|
||||
widget := p.Columns[c].Widgets[w]
|
||||
|
||||
if !widget.RequiresUpdate(&now) {
|
||||
if !widget.requiresUpdate(&now) {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
widget.Update(context)
|
||||
widget.update(context)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
@ -103,16 +116,7 @@ func (p *Page) UpdateOutdatedWidgets() {
|
|||
wg.Wait()
|
||||
}
|
||||
|
||||
// TODO: fix, currently very simple, lots of uncovered edge cases
|
||||
func titleToSlug(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
s = sequentialWhitespacePattern.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (a *Application) TransformUserDefinedAssetPath(path string) string {
|
||||
func (a *application) transformUserDefinedAssetPath(path string) string {
|
||||
if strings.HasPrefix(path, "/assets/") {
|
||||
return a.Config.Server.BaseURL + path
|
||||
}
|
||||
|
@ -120,74 +124,26 @@ func (a *Application) TransformUserDefinedAssetPath(path string) string {
|
|||
return path
|
||||
}
|
||||
|
||||
func NewApplication(config *Config) (*Application, error) {
|
||||
if len(config.Pages) == 0 {
|
||||
return nil, fmt.Errorf("no pages configured")
|
||||
}
|
||||
|
||||
app := &Application{
|
||||
Version: buildVersion,
|
||||
Config: *config,
|
||||
slugToPage: make(map[string]*Page),
|
||||
widgetByID: make(map[uint64]widget.Widget),
|
||||
}
|
||||
|
||||
app.Config.Server.AssetsHash = assets.PublicFSHash
|
||||
app.slugToPage[""] = &config.Pages[0]
|
||||
|
||||
providers := &widget.Providers{
|
||||
AssetResolver: app.AssetPath,
|
||||
}
|
||||
|
||||
for p := range config.Pages {
|
||||
if config.Pages[p].Slug == "" {
|
||||
config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
|
||||
}
|
||||
|
||||
app.slugToPage[config.Pages[p].Slug] = &config.Pages[p]
|
||||
|
||||
for c := range config.Pages[p].Columns {
|
||||
for w := range config.Pages[p].Columns[c].Widgets {
|
||||
widget := config.Pages[p].Columns[c].Widgets[w]
|
||||
app.widgetByID[widget.GetID()] = widget
|
||||
|
||||
widget.SetProviders(providers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config = &app.Config
|
||||
|
||||
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
|
||||
config.Theme.CustomCSSFile = app.TransformUserDefinedAssetPath(config.Theme.CustomCSSFile)
|
||||
|
||||
if config.Branding.FaviconURL == "" {
|
||||
config.Branding.FaviconURL = app.AssetPath("favicon.png")
|
||||
} else {
|
||||
config.Branding.FaviconURL = app.TransformUserDefinedAssetPath(config.Branding.FaviconURL)
|
||||
}
|
||||
|
||||
config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL)
|
||||
|
||||
return app, nil
|
||||
type pageTemplateData struct {
|
||||
App *application
|
||||
Page *page
|
||||
}
|
||||
|
||||
func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) {
|
||||
page, exists := a.slugToPage[r.PathValue("page")]
|
||||
|
||||
if !exists {
|
||||
a.HandleNotFound(w, r)
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
pageData := templateData{
|
||||
pageData := pageTemplateData{
|
||||
Page: page,
|
||||
App: a,
|
||||
}
|
||||
|
||||
var responseBytes bytes.Buffer
|
||||
err := assets.PageTemplate.Execute(&responseBytes, pageData)
|
||||
|
||||
err := pageTemplate.Execute(&responseBytes, pageData)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
|
@ -197,24 +153,28 @@ func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request)
|
|||
w.Write(responseBytes.Bytes())
|
||||
}
|
||||
|
||||
func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) {
|
||||
page, exists := a.slugToPage[r.PathValue("page")]
|
||||
|
||||
if !exists {
|
||||
a.HandleNotFound(w, r)
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
pageData := templateData{
|
||||
pageData := pageTemplateData{
|
||||
Page: page,
|
||||
}
|
||||
|
||||
page.mu.Lock()
|
||||
defer page.mu.Unlock()
|
||||
page.UpdateOutdatedWidgets()
|
||||
|
||||
var err error
|
||||
var responseBytes bytes.Buffer
|
||||
err := assets.PageContentTemplate.Execute(&responseBytes, pageData)
|
||||
|
||||
func() {
|
||||
page.mu.Lock()
|
||||
defer page.mu.Unlock()
|
||||
|
||||
page.updateOutdatedWidgets()
|
||||
err = pageContentTemplate.Execute(&responseBytes, pageData)
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
@ -225,74 +185,58 @@ func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Re
|
|||
w.Write(responseBytes.Bytes())
|
||||
}
|
||||
|
||||
func (a *Application) HandleNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
|
||||
// TODO: add proper not found page
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("Page not found"))
|
||||
}
|
||||
|
||||
func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler {
|
||||
server := http.FileServer(fs)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: fix always setting cache control even if the file doesn't exist
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds())))
|
||||
server.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Application) HandleWidgetRequest(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) {
|
||||
widgetValue := r.PathValue("widget")
|
||||
|
||||
widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
|
||||
|
||||
if err != nil {
|
||||
a.HandleNotFound(w, r)
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
widget, exists := a.widgetByID[widgetID]
|
||||
|
||||
if !exists {
|
||||
a.HandleNotFound(w, r)
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
widget.HandleRequest(w, r)
|
||||
widget.handleRequest(w, r)
|
||||
}
|
||||
|
||||
func (a *Application) AssetPath(asset string) string {
|
||||
return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset
|
||||
func (a *application) AssetPath(asset string) string {
|
||||
return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset
|
||||
}
|
||||
|
||||
func (a *Application) Serve() error {
|
||||
func (a *application) server() (func() error, func() error) {
|
||||
// TODO: add gzip support, static files must have their gzipped contents cached
|
||||
// TODO: add HTTPS support
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /{$}", a.HandlePageRequest)
|
||||
mux.HandleFunc("GET /{page}", a.HandlePageRequest)
|
||||
mux.HandleFunc("GET /{$}", a.handlePageRequest)
|
||||
mux.HandleFunc("GET /{page}", a.handlePageRequest)
|
||||
|
||||
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
|
||||
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest)
|
||||
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest)
|
||||
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
|
||||
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
mux.Handle(
|
||||
fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
|
||||
http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)),
|
||||
fmt.Sprintf("GET /static/%s/{path...}", staticFSHash),
|
||||
http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)),
|
||||
)
|
||||
|
||||
var absAssetsPath string
|
||||
if a.Config.Server.AssetsPath != "" {
|
||||
absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid assets path: %s", a.Config.Server.AssetsPath)
|
||||
}
|
||||
|
||||
slog.Info("Serving assets", "path", absAssetsPath)
|
||||
assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
|
||||
absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
|
||||
assetsFS := fileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
|
||||
mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS))
|
||||
}
|
||||
|
||||
|
@ -301,8 +245,25 @@ func (a *Application) Serve() error {
|
|||
Handler: mux,
|
||||
}
|
||||
|
||||
a.Config.Server.StartedAt = time.Now()
|
||||
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL)
|
||||
start := func() error {
|
||||
a.Config.Server.StartedAt = time.Now()
|
||||
log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n",
|
||||
a.Config.Server.Host,
|
||||
a.Config.Server.Port,
|
||||
a.Config.Server.BaseURL,
|
||||
absAssetsPath,
|
||||
)
|
||||
|
||||
return server.ListenAndServe()
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
stop := func() error {
|
||||
return server.Close()
|
||||
}
|
||||
|
||||
return start, stop
|
||||
}
|
||||
|
|
|
@ -2,45 +2,172 @@ package glance
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func Main() int {
|
||||
options, err := ParseCliOptions()
|
||||
var buildVersion = "dev"
|
||||
|
||||
func Main() int {
|
||||
options, err := parseCliOptions()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return 1
|
||||
}
|
||||
|
||||
configFile, err := os.Open(options.ConfigPath)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("failed opening config file: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
config, err := NewConfigFromYml(configFile)
|
||||
configFile.Close()
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("failed parsing config file: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if options.Intent == CliIntentServe {
|
||||
app, err := NewApplication(config)
|
||||
switch options.intent {
|
||||
case cliIntentServe:
|
||||
// remove in v0.10.0
|
||||
if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) {
|
||||
return 1
|
||||
}
|
||||
|
||||
if err := serveApp(options.configPath); err != nil {
|
||||
fmt.Println(err)
|
||||
return 1
|
||||
}
|
||||
case cliIntentConfigValidate:
|
||||
contents, _, err := parseYAMLIncludes(options.configPath)
|
||||
if err != nil {
|
||||
fmt.Printf("failed creating application: %v\n", err)
|
||||
fmt.Printf("Could not parse config file: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if err := app.Serve(); err != nil {
|
||||
fmt.Printf("http server error: %v\n", err)
|
||||
if _, err := newConfigFromYAML(contents); err != nil {
|
||||
fmt.Printf("Config file is invalid: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
case cliIntentConfigPrint:
|
||||
contents, _, err := parseYAMLIncludes(options.configPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Could not parse config file: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
fmt.Println(string(contents))
|
||||
case cliIntentDiagnose:
|
||||
runDiagnostic()
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func serveApp(configPath string) error {
|
||||
exitChannel := make(chan struct{})
|
||||
hadValidConfigOnStartup := false
|
||||
var stopServer func() error
|
||||
|
||||
onChange := func(newContents []byte) {
|
||||
if stopServer != nil {
|
||||
log.Println("Config file changed, reloading...")
|
||||
}
|
||||
|
||||
config, err := newConfigFromYAML(newContents)
|
||||
if err != nil {
|
||||
log.Printf("Config has errors: %v", err)
|
||||
|
||||
if !hadValidConfigOnStartup {
|
||||
close(exitChannel)
|
||||
}
|
||||
|
||||
return
|
||||
} else if !hadValidConfigOnStartup {
|
||||
hadValidConfigOnStartup = true
|
||||
}
|
||||
|
||||
app, err := newApplication(config)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create application: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if stopServer != nil {
|
||||
if err := stopServer(); err != nil {
|
||||
log.Printf("Error while trying to stop server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
var startServer func() error
|
||||
startServer, stopServer = app.server()
|
||||
|
||||
if err := startServer(); err != nil {
|
||||
log.Printf("Failed to start server: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
onErr := func(err error) {
|
||||
log.Printf("Error watching config files: %v", err)
|
||||
}
|
||||
|
||||
configContents, configIncludes, err := parseYAMLIncludes(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing config: %w", err)
|
||||
}
|
||||
|
||||
stopWatching, err := configFilesWatcher(configPath, configContents, configIncludes, onChange, onErr)
|
||||
if err == nil {
|
||||
defer stopWatching()
|
||||
} else {
|
||||
log.Printf("Error starting file watcher, config file changes will require a manual restart. (%v)", err)
|
||||
|
||||
config, err := newConfigFromYAML(configContents)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validating config file: %w", err)
|
||||
}
|
||||
|
||||
app, err := newApplication(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating application: %w", err)
|
||||
}
|
||||
|
||||
startServer, _ := app.server()
|
||||
if err := startServer(); err != nil {
|
||||
return fmt.Errorf("starting server: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
<-exitChannel
|
||||
return nil
|
||||
}
|
||||
|
||||
func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool {
|
||||
if !isRunningInsideDockerContainer() {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// glance.yml wasn't mounted to begin with or was incorrectly mounted as a directory
|
||||
if stat, err := os.Stat("glance.yml"); err != nil || stat.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
templateFile, _ := templateFS.Open("v0.7-update-notice-page.html")
|
||||
bodyContents, _ := io.ReadAll(templateFile)
|
||||
|
||||
fmt.Println("!!! WARNING !!!")
|
||||
fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0.")
|
||||
fmt.Println("Please see https://github.com/glanceapp/glance/blob/main/docs/v0.7.0-upgrade.md for more information.")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(bodyContents))
|
||||
})
|
||||
|
||||
server := http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: mux,
|
||||
}
|
||||
server.ListenAndServe()
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 300 B After Width: | Height: | Size: 300 B |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 802 B After Width: | Height: | Size: 802 B |
Before Width: | Height: | Size: 553 B After Width: | Height: | Size: 553 B |
33
internal/glance/static/js/animations.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
export const easeOutQuint = 'cubic-bezier(0.22, 1, 0.36, 1)';
|
||||
|
||||
export function directions(anim, opt, ...dirs) {
|
||||
return dirs.map(dir => anim({ direction: dir, ...opt }));
|
||||
}
|
||||
|
||||
export function slideFade({
|
||||
direction = 'left',
|
||||
fill = 'backwards',
|
||||
duration = 200,
|
||||
distance = '1rem',
|
||||
easing = 'ease',
|
||||
offset = 0,
|
||||
}) {
|
||||
const axis = direction === 'left' || direction === 'right' ? 'X' : 'Y';
|
||||
const negative = direction === 'left' || direction === 'up' ? '-' : '';
|
||||
const amount = negative + distance;
|
||||
|
||||
return {
|
||||
keyframes: [
|
||||
{
|
||||
offset: offset,
|
||||
opacity: 0,
|
||||
transform: `translate${axis}(${amount})`,
|
||||
}
|
||||
],
|
||||
options: {
|
||||
duration: duration,
|
||||
easing: easing,
|
||||
fill: fill,
|
||||
},
|
||||
};
|
||||
}
|
212
internal/glance/static/js/calendar.js
Normal file
|
@ -0,0 +1,212 @@
|
|||
import { directions, easeOutQuint, slideFade } from "./animations.js";
|
||||
import { elem, repeat, text } from "./templating.js";
|
||||
|
||||
const FULL_MONTH_SLOTS = 7*6;
|
||||
const WEEKDAY_ABBRS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
|
||||
const leftArrowSvg = `<svg stroke="var(--color-text-base)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>`;
|
||||
|
||||
const rightArrowSvg = `<svg stroke="var(--color-text-base)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>`;
|
||||
|
||||
const undoArrowSvg = `<svg stroke="var(--color-text-base)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
|
||||
</svg>`;
|
||||
|
||||
const [datesExitLeft, datesExitRight] = directions(
|
||||
slideFade, { distance: "2rem", duration: 120, offset: 1 },
|
||||
"left", "right"
|
||||
);
|
||||
|
||||
const [datesEntranceLeft, datesEntranceRight] = directions(
|
||||
slideFade, { distance: "0.8rem", duration: 500, easing: easeOutQuint },
|
||||
"left", "right"
|
||||
);
|
||||
|
||||
const undoEntrance = slideFade({ direction: "left", distance: "100%", duration: 300 });
|
||||
|
||||
export default function(element) {
|
||||
element.swap(Calendar(
|
||||
Number(element.dataset.firstDayOfWeek ?? 1)
|
||||
));
|
||||
}
|
||||
|
||||
// TODO: when viewing the previous/next month, display the current date if it's within the spill-over days
|
||||
function Calendar(firstDay) {
|
||||
let header, dates;
|
||||
let advanceTimeTicker;
|
||||
let now = new Date();
|
||||
let activeDate;
|
||||
|
||||
const update = (newDate) => {
|
||||
header.component.update(now, newDate);
|
||||
dates.component.update(now, newDate);
|
||||
activeDate = newDate;
|
||||
};
|
||||
|
||||
const autoAdvanceNow = () => {
|
||||
advanceTimeTicker = setTimeout(() => {
|
||||
// TODO: don't auto advance if looking at a different month
|
||||
update(now = new Date());
|
||||
autoAdvanceNow();
|
||||
}, msTillNextDay());
|
||||
};
|
||||
|
||||
const adjacentMonth = (dir) => new Date(activeDate.getFullYear(), activeDate.getMonth() + dir, 1);
|
||||
const nextClicked = () => update(adjacentMonth(1));
|
||||
const prevClicked = () => update(adjacentMonth(-1));
|
||||
const undoClicked = () => update(now);
|
||||
|
||||
const calendar = elem().classes("calendar").append(
|
||||
header = Header(nextClicked, prevClicked, undoClicked),
|
||||
dates = Dates(firstDay)
|
||||
);
|
||||
|
||||
update(now);
|
||||
autoAdvanceNow();
|
||||
|
||||
return calendar.component({
|
||||
suspend: () => clearTimeout(advanceTimeTicker)
|
||||
});
|
||||
}
|
||||
|
||||
function Header(nextClicked, prevClicked, undoClicked) {
|
||||
let month, monthNumber, year, undo;
|
||||
const button = () => elem("button").classes("calendar-header-button");
|
||||
|
||||
const monthAndYear = elem().classes("size-h2", "color-highlight").append(
|
||||
month = text(),
|
||||
" ",
|
||||
year = elem("span").classes("size-h3"),
|
||||
undo = button()
|
||||
.hide()
|
||||
.classes("calendar-undo-button")
|
||||
.attr("title", "Back to current month")
|
||||
.on("click", undoClicked)
|
||||
.html(undoArrowSvg)
|
||||
);
|
||||
|
||||
const monthSwitcher = elem()
|
||||
.classes("flex", "gap-7", "items-center")
|
||||
.append(
|
||||
button()
|
||||
.attr("title", "Previous month")
|
||||
.on("click", prevClicked)
|
||||
.html(leftArrowSvg),
|
||||
monthNumber = elem()
|
||||
.classes("color-highlight")
|
||||
.styles({ marginTop: "0.1rem" }),
|
||||
button()
|
||||
.attr("title", "Next month")
|
||||
.on("click", nextClicked)
|
||||
.html(rightArrowSvg),
|
||||
);
|
||||
|
||||
return elem().classes("flex", "justify-between", "items-center").append(
|
||||
monthAndYear,
|
||||
monthSwitcher
|
||||
).component({
|
||||
update: function (now, newDate) {
|
||||
month.text(MONTH_NAMES[newDate.getMonth()]);
|
||||
year.text(newDate.getFullYear());
|
||||
const m = newDate.getMonth() + 1;
|
||||
monthNumber.text((m < 10 ? "0" : "") + m);
|
||||
|
||||
if (!datesWithinSameMonth(now, newDate)) {
|
||||
if (undo.isHidden()) undo.show().animate(undoEntrance);
|
||||
} else {
|
||||
undo.hide();
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function Dates(firstDay) {
|
||||
let dates, lastRenderedDate;
|
||||
|
||||
const updateFullMonth = function(now, newDate) {
|
||||
const firstWeekday = new Date(newDate.getFullYear(), newDate.getMonth(), 1).getDay();
|
||||
const previousMonthSpilloverDays = (firstWeekday - firstDay + 7) % 7 || 7;
|
||||
const currentMonthDays = daysInMonth(newDate.getFullYear(), newDate.getMonth());
|
||||
const nextMonthSpilloverDays = FULL_MONTH_SLOTS - (previousMonthSpilloverDays + currentMonthDays);
|
||||
const previousMonthDays = daysInMonth(newDate.getFullYear(), newDate.getMonth() - 1)
|
||||
const isCurrentMonth = datesWithinSameMonth(now, newDate);
|
||||
const currentDate = now.getDate();
|
||||
|
||||
let children = dates.children;
|
||||
let index = 0;
|
||||
|
||||
for (let i = 0; i < FULL_MONTH_SLOTS; i++) {
|
||||
children[i].clearClasses("calendar-spillover-date", "calendar-current-date");
|
||||
}
|
||||
|
||||
for (let i = 0; i < previousMonthSpilloverDays; i++, index++) {
|
||||
children[index].classes("calendar-spillover-date").text(
|
||||
previousMonthDays - previousMonthSpilloverDays + i + 1
|
||||
)
|
||||
}
|
||||
|
||||
for (let i = 1; i <= currentMonthDays; i++, index++) {
|
||||
children[index]
|
||||
.classesIf(isCurrentMonth && i === currentDate, "calendar-current-date")
|
||||
.text(i);
|
||||
}
|
||||
|
||||
for (let i = 0; i < nextMonthSpilloverDays; i++, index++) {
|
||||
children[index].classes("calendar-spillover-date").text(i + 1);
|
||||
}
|
||||
|
||||
lastRenderedDate = newDate;
|
||||
};
|
||||
|
||||
const update = function(now, newDate) {
|
||||
if (lastRenderedDate === undefined || datesWithinSameMonth(newDate, lastRenderedDate)) {
|
||||
updateFullMonth(now, newDate);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = newDate > lastRenderedDate;
|
||||
dates.animateUpdate(
|
||||
() => updateFullMonth(now, newDate),
|
||||
next ? datesExitLeft : datesExitRight,
|
||||
next ? datesEntranceRight : datesEntranceLeft,
|
||||
);
|
||||
}
|
||||
|
||||
return elem().append(
|
||||
elem().classes("calendar-dates", "margin-top-15").append(
|
||||
...repeat(7, (i) => elem().classes("size-h6", "color-subdue").text(
|
||||
WEEKDAY_ABBRS[(firstDay + i) % 7]
|
||||
))
|
||||
),
|
||||
|
||||
dates = elem().classes("calendar-dates", "margin-top-3").append(
|
||||
...elem().classes("calendar-date").duplicate(FULL_MONTH_SLOTS)
|
||||
)
|
||||
).component({ update });
|
||||
}
|
||||
|
||||
function datesWithinSameMonth(d1, d2) {
|
||||
return d1.getFullYear() === d2.getFullYear() && d1.getMonth() === d2.getMonth();
|
||||
}
|
||||
|
||||
function daysInMonth(year, month) {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
function msTillNextDay(now) {
|
||||
now = now || new Date();
|
||||
|
||||
return 86_400_000 - (
|
||||
now.getMilliseconds() +
|
||||
now.getSeconds() * 1000 +
|
||||
now.getMinutes() * 60_000 +
|
||||
now.getHours() * 3_600_000
|
||||
);
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import { setupPopovers } from './popover.js';
|
||||
import { throttledDebounce, isElementVisible } from './utils.js';
|
||||
import { setupMasonries } from './masonry.js';
|
||||
import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js';
|
||||
|
||||
async function fetchPageContent(pageData) {
|
||||
// TODO: handle non 200 status codes/time outs
|
||||
|
@ -48,29 +49,35 @@ function setupCarousels() {
|
|||
const minuteInSeconds = 60;
|
||||
const hourInSeconds = minuteInSeconds * 60;
|
||||
const dayInSeconds = hourInSeconds * 24;
|
||||
const monthInSeconds = dayInSeconds * 30;
|
||||
const yearInSeconds = monthInSeconds * 12;
|
||||
const monthInSeconds = dayInSeconds * 30.4;
|
||||
const yearInSeconds = dayInSeconds * 365;
|
||||
|
||||
function relativeTimeSince(timestamp) {
|
||||
const delta = Math.round((Date.now() / 1000) - timestamp);
|
||||
function timestampToRelativeTime(timestamp) {
|
||||
let delta = Math.round((Date.now() / 1000) - timestamp);
|
||||
let prefix = "";
|
||||
|
||||
if (delta < 0) {
|
||||
delta = -delta;
|
||||
prefix = "in ";
|
||||
}
|
||||
|
||||
if (delta < minuteInSeconds) {
|
||||
return "1m";
|
||||
return prefix + "1m";
|
||||
}
|
||||
if (delta < hourInSeconds) {
|
||||
return Math.floor(delta / minuteInSeconds) + "m";
|
||||
return prefix + Math.floor(delta / minuteInSeconds) + "m";
|
||||
}
|
||||
if (delta < dayInSeconds) {
|
||||
return Math.floor(delta / hourInSeconds) + "h";
|
||||
return prefix + Math.floor(delta / hourInSeconds) + "h";
|
||||
}
|
||||
if (delta < monthInSeconds) {
|
||||
return Math.floor(delta / dayInSeconds) + "d";
|
||||
return prefix + Math.floor(delta / dayInSeconds) + "d";
|
||||
}
|
||||
if (delta < yearInSeconds) {
|
||||
return Math.floor(delta / monthInSeconds) + "mo";
|
||||
return prefix + Math.floor(delta / monthInSeconds) + "mo";
|
||||
}
|
||||
|
||||
return Math.floor(delta / yearInSeconds) + "y";
|
||||
return prefix + Math.floor(delta / yearInSeconds) + "y";
|
||||
}
|
||||
|
||||
function updateRelativeTimeForElements(elements)
|
||||
|
@ -83,7 +90,7 @@ function updateRelativeTimeForElements(elements)
|
|||
if (timestamp === undefined)
|
||||
continue
|
||||
|
||||
element.textContent = relativeTimeSince(timestamp);
|
||||
element.textContent = timestampToRelativeTime(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -104,6 +111,7 @@ function setupSearchBoxes() {
|
|||
const bangsMap = {};
|
||||
const kbdElement = widget.getElementsByTagName("kbd")[0];
|
||||
let currentBang = null;
|
||||
let lastQuery = "";
|
||||
|
||||
for (let j = 0; j < bangs.length; j++) {
|
||||
const bang = bangs[j];
|
||||
|
@ -140,6 +148,14 @@ function setupSearchBoxes() {
|
|||
window.location.href = url;
|
||||
}
|
||||
|
||||
lastQuery = query;
|
||||
inputElement.value = "";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key == "ArrowUp" && lastQuery.length > 0) {
|
||||
inputElement.value = lastQuery;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
@ -245,14 +261,32 @@ function setupGroups() {
|
|||
|
||||
for (let t = 0; t < titles.length; t++) {
|
||||
const title = titles[t];
|
||||
|
||||
if (title.dataset.titleUrl !== undefined) {
|
||||
title.addEventListener("mousedown", (event) => {
|
||||
if (event.button != 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
openURLInNewTab(title.dataset.titleUrl, false);
|
||||
event.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
title.addEventListener("click", () => {
|
||||
if (t == current) {
|
||||
if (title.dataset.titleUrl !== undefined) {
|
||||
openURLInNewTab(title.dataset.titleUrl);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < titles.length; i++) {
|
||||
titles[i].classList.remove("widget-group-title-current");
|
||||
titles[i].setAttribute("aria-selected", "false");
|
||||
tabs[i].classList.remove("widget-group-content-current");
|
||||
tabs[i].setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
if (current < t) {
|
||||
|
@ -264,7 +298,9 @@ function setupGroups() {
|
|||
current = t;
|
||||
|
||||
title.classList.add("widget-group-title-current");
|
||||
title.setAttribute("aria-selected", "true");
|
||||
tabs[t].classList.add("widget-group-content-current");
|
||||
tabs[t].setAttribute("aria-hidden", "false");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -407,7 +443,7 @@ function setupCollapsibleGrids() {
|
|||
|
||||
let cardsPerRow;
|
||||
|
||||
const resolveCollapsibleItems = () => {
|
||||
const resolveCollapsibleItems = () => requestAnimationFrame(() => {
|
||||
const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
|
||||
|
||||
if (hideItemsAfterIndex >= gridElement.children.length) {
|
||||
|
@ -433,7 +469,7 @@ function setupCollapsibleGrids() {
|
|||
child.style.removeProperty("animation-delay");
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!isElementVisible(gridElement)) {
|
||||
|
@ -502,9 +538,34 @@ function timeInZone(now, zone) {
|
|||
timeInZone = now
|
||||
}
|
||||
|
||||
const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
|
||||
const diffInMinutes = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60);
|
||||
|
||||
return { time: timeInZone, diffInHours: diffInHours };
|
||||
return { time: timeInZone, diffInMinutes: diffInMinutes };
|
||||
}
|
||||
|
||||
function zoneDiffText(diffInMinutes) {
|
||||
if (diffInMinutes == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const sign = diffInMinutes < 0 ? "-" : "+";
|
||||
const signText = diffInMinutes < 0 ? "behind" : "ahead";
|
||||
|
||||
diffInMinutes = Math.abs(diffInMinutes);
|
||||
|
||||
const hours = Math.floor(diffInMinutes / 60);
|
||||
const minutes = diffInMinutes % 60;
|
||||
const hourSuffix = hours == 1 ? "" : "s";
|
||||
|
||||
if (minutes == 0) {
|
||||
return { text: `${sign}${hours}h`, title: `${hours} hour${hourSuffix} ${signText}` };
|
||||
}
|
||||
|
||||
if (hours == 0) {
|
||||
return { text: `${sign}${minutes}m`, title: `${minutes} minutes ${signText}` };
|
||||
}
|
||||
|
||||
return { text: `${sign}${hours}h~`, title: `${hours} hour${hourSuffix} and ${minutes} minutes ${signText}` };
|
||||
}
|
||||
|
||||
function setupClocks() {
|
||||
|
@ -547,9 +608,11 @@ function setupClocks() {
|
|||
);
|
||||
|
||||
updateCallbacks.push((now) => {
|
||||
const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
|
||||
const { time, diffInMinutes } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
|
||||
setZoneTime(time);
|
||||
diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
|
||||
const { text, title } = zoneDiffText(diffInMinutes);
|
||||
diffElement.textContent = text;
|
||||
diffElement.title = title;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -566,6 +629,30 @@ function setupClocks() {
|
|||
updateClocks();
|
||||
}
|
||||
|
||||
async function setupCalendars() {
|
||||
const elems = document.getElementsByClassName("calendar");
|
||||
if (elems.length == 0) return;
|
||||
|
||||
// TODO: implement prefetching, currently loads as a nasty waterfall of requests
|
||||
const calendar = await import ('./calendar.js');
|
||||
|
||||
for (let i = 0; i < elems.length; i++)
|
||||
calendar.default(elems[i]);
|
||||
}
|
||||
|
||||
function setupTruncatedElementTitles() {
|
||||
const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines");
|
||||
|
||||
if (elements.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
if (element.title === "") element.title = element.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
async function setupPage() {
|
||||
const pageElement = document.getElementById("page");
|
||||
const pageContentElement = document.getElementById("page-content");
|
||||
|
@ -576,20 +663,27 @@ async function setupPage() {
|
|||
try {
|
||||
setupPopovers();
|
||||
setupClocks()
|
||||
await setupCalendars();
|
||||
setupCarousels();
|
||||
setupSearchBoxes();
|
||||
setupCollapsibleLists();
|
||||
setupCollapsibleGrids();
|
||||
setupGroups();
|
||||
setupMasonries();
|
||||
setupDynamicRelativeTime();
|
||||
setupLazyImages();
|
||||
} finally {
|
||||
pageElement.classList.add("content-ready");
|
||||
pageElement.setAttribute("aria-busy", "false");
|
||||
|
||||
for (let i = 0; i < contentReadyCallbacks.length; i++) {
|
||||
contentReadyCallbacks[i]();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setupTruncatedElementTitles();
|
||||
}, 50);
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.classList.add("page-columns-transitioned");
|
||||
}, 300);
|
53
internal/glance/static/js/masonry.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
|
||||
import { clamp } from "./utils.js";
|
||||
|
||||
export function setupMasonries() {
|
||||
const masonryContainers = document.getElementsByClassName("masonry");
|
||||
|
||||
for (let i = 0; i < masonryContainers.length; i++) {
|
||||
const container = masonryContainers[i];
|
||||
|
||||
const options = {
|
||||
minColumnWidth: container.dataset.minColumnWidth || 330,
|
||||
maxColumns: container.dataset.maxColumns || 6,
|
||||
};
|
||||
|
||||
const items = Array.from(container.children);
|
||||
let previousColumnsCount = 0;
|
||||
|
||||
const render = function() {
|
||||
const columnsCount = clamp(
|
||||
Math.floor(container.offsetWidth / options.minColumnWidth),
|
||||
1,
|
||||
Math.min(options.maxColumns, items.length)
|
||||
);
|
||||
|
||||
if (columnsCount === previousColumnsCount) {
|
||||
return;
|
||||
} else {
|
||||
container.textContent = "";
|
||||
previousColumnsCount = columnsCount;
|
||||
}
|
||||
|
||||
const columnsFragment = document.createDocumentFragment();
|
||||
|
||||
for (let i = 0; i < columnsCount; i++) {
|
||||
const column = document.createElement("div");
|
||||
column.className = "masonry-column";
|
||||
columnsFragment.append(column);
|
||||
}
|
||||
|
||||
// poor man's masonry
|
||||
// TODO: add an option that allows placing items in the
|
||||
// shortest column instead of iterating the columns in order
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
columnsFragment.children[i % columnsCount].appendChild(items[i]);
|
||||
}
|
||||
|
||||
container.append(columnsFragment);
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(() => requestAnimationFrame(render));
|
||||
observer.observe(container);
|
||||
}
|
||||
}
|
|
@ -25,7 +25,8 @@ frameElement.append(contentElement);
|
|||
containerElement.append(frameElement);
|
||||
document.body.append(containerElement);
|
||||
|
||||
const observer = new ResizeObserver(repositionContainer);
|
||||
const queueRepositionContainer = () => requestAnimationFrame(repositionContainer);
|
||||
const observer = new ResizeObserver(queueRepositionContainer);
|
||||
|
||||
function handleMouseEnter(event) {
|
||||
clearTogglePopoverTimeout();
|
||||
|
@ -56,6 +57,8 @@ function clearTogglePopoverTimeout() {
|
|||
}
|
||||
|
||||
function showPopover() {
|
||||
if (pendingTarget === null) return;
|
||||
|
||||
activeTarget = pendingTarget;
|
||||
pendingTarget = null;
|
||||
|
||||
|
@ -95,23 +98,25 @@ function showPopover() {
|
|||
}
|
||||
|
||||
contentElement.style.maxWidth = contentMaxWidth;
|
||||
containerElement.style.display = "block";
|
||||
activeTarget.classList.add("popover-active");
|
||||
document.addEventListener("keydown", handleHidePopoverOnEscape);
|
||||
window.addEventListener("resize", repositionContainer);
|
||||
window.addEventListener("resize", queueRepositionContainer);
|
||||
observer.observe(containerElement);
|
||||
}
|
||||
|
||||
function repositionContainer() {
|
||||
containerElement.style.display = "block";
|
||||
|
||||
const targetBounds = activeTarget.dataset.popoverAnchor !== undefined
|
||||
? activeTarget.querySelector(activeTarget.dataset.popoverAnchor).getBoundingClientRect()
|
||||
: activeTarget.getBoundingClientRect();
|
||||
|
||||
const containerBounds = containerElement.getBoundingClientRect();
|
||||
const containerInlinePadding = parseInt(containerComputedStyle.getPropertyValue("padding-inline"));
|
||||
const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverOffset || 0.5);
|
||||
const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverTargetOffset || 0.5);
|
||||
const position = activeTarget.dataset.popoverPosition || "below";
|
||||
const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width / 2));
|
||||
const popoverOffest = activeTarget.dataset.popoverOffset || 0.5;
|
||||
const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width * popoverOffest));
|
||||
|
||||
if (left < 0) {
|
||||
containerElement.style.left = 0;
|
||||
|
@ -120,11 +125,11 @@ function repositionContainer() {
|
|||
} 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");
|
||||
containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + -1 + "px");
|
||||
} else {
|
||||
containerElement.style.removeProperty("right");
|
||||
containerElement.style.left = left + "px";
|
||||
containerElement.style.removeProperty("--triangle-offset");
|
||||
containerElement.style.setProperty("--triangle-offset", ((targetBounds.left + targetBoundsWidthOffset) - left - containerInlinePadding) + -1 + "px");
|
||||
}
|
||||
|
||||
const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget;
|
||||
|
@ -153,7 +158,7 @@ function hidePopover() {
|
|||
activeTarget.classList.remove("popover-active");
|
||||
containerElement.style.display = "none";
|
||||
document.removeEventListener("keydown", handleHidePopoverOnEscape);
|
||||
window.removeEventListener("resize", repositionContainer);
|
||||
window.removeEventListener("resize", queueRepositionContainer);
|
||||
observer.unobserve(containerElement);
|
||||
|
||||
if (cleanupOnHidePopover !== null) {
|
190
internal/glance/static/js/templating.js
Normal file
|
@ -0,0 +1,190 @@
|
|||
export function elem(tag = "div") {
|
||||
return document.createElement(tag);
|
||||
}
|
||||
|
||||
export function fragment(...children) {
|
||||
const f = document.createDocumentFragment();
|
||||
if (children) f.append(...children);
|
||||
return f;
|
||||
}
|
||||
|
||||
export function text(str = "") {
|
||||
return document.createTextNode(str);
|
||||
}
|
||||
|
||||
export function repeat(n, fn) {
|
||||
const elems = Array(n);
|
||||
|
||||
for (let i = 0; i < n; i++)
|
||||
elems[i] = fn(i);
|
||||
|
||||
return elems;
|
||||
}
|
||||
|
||||
export function find(selector) {
|
||||
return document.querySelector(selector);
|
||||
}
|
||||
|
||||
export function findAll(selector) {
|
||||
return document.querySelectorAll(selector);
|
||||
}
|
||||
|
||||
const ep = HTMLElement.prototype;
|
||||
const fp = DocumentFragment.prototype;
|
||||
const tp = Text.prototype;
|
||||
|
||||
ep.classes = function(...classes) {
|
||||
this.classList.add(...classes);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.find = function(selector) {
|
||||
return this.querySelector(selector);
|
||||
}
|
||||
|
||||
ep.findAll = function(selector) {
|
||||
return this.querySelectorAll(selector);
|
||||
}
|
||||
|
||||
ep.classesIf = function(cond, ...classes) {
|
||||
cond ? this.classList.add(...classes) : this.classList.remove(...classes);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.hide = function() {
|
||||
this.style.display = "none";
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.show = function() {
|
||||
this.style.removeProperty("display");
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.showIf = function(cond) {
|
||||
cond ? this.show() : this.hide();
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.isHidden = function() {
|
||||
return this.style.display === "none";
|
||||
}
|
||||
|
||||
ep.clearClasses = function(...classes) {
|
||||
classes.length ? this.classList.remove(...classes) : this.className = "";
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.hasClass = function(className) {
|
||||
return this.classList.contains(className);
|
||||
}
|
||||
|
||||
ep.attr = function(name, value) {
|
||||
this.setAttribute(name, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.attrs = function(attrs) {
|
||||
for (const [name, value] of Object.entries(attrs))
|
||||
this.setAttribute(name, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.tap = function(fn) {
|
||||
fn(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.text = function(text) {
|
||||
this.innerText = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.html = function(html) {
|
||||
this.innerHTML = html;
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.appendTo = function(parent) {
|
||||
parent.appendChild(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.swap = function(element) {
|
||||
this.replaceWith(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
ep.on = function(event, callback, options) {
|
||||
if (typeof event === "string") {
|
||||
this.addEventListener(event, callback, options);
|
||||
return this;
|
||||
}
|
||||
|
||||
for (let i = 0; i < event.length; i++)
|
||||
this.addEventListener(event[i], callback, options);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
const epAppend = ep.append;
|
||||
ep.append = function(...children) {
|
||||
epAppend.apply(this, children);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.duplicate = function(n) {
|
||||
const elems = Array(n);
|
||||
|
||||
for (let i = 0; i < n; i++)
|
||||
elems[i] = this.cloneNode(true);
|
||||
|
||||
return elems;
|
||||
}
|
||||
|
||||
ep.styles = function(s) {
|
||||
Object.assign(this.style, s);
|
||||
return this;
|
||||
}
|
||||
|
||||
const epAnimate = ep.animate;
|
||||
ep.animate = function(anim, callback) {
|
||||
const a = epAnimate.call(this, anim.keyframes, anim.options);
|
||||
if (callback) a.onfinish = () => callback(this, a);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.animateUpdate = function(update, exit, entrance) {
|
||||
this.animate(exit, () => {
|
||||
update(this);
|
||||
this.animate(entrance);
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.styleVar = function(name, value) {
|
||||
this.style.setProperty(`--${name}`, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
ep.component = function (methods) {
|
||||
this.component = methods;
|
||||
return this;
|
||||
}
|
||||
|
||||
const fpAppend = fp.append;
|
||||
fp.append = function(...children) {
|
||||
fpAppend.apply(this, children);
|
||||
return this;
|
||||
}
|
||||
|
||||
fp.appendTo = function(parent) {
|
||||
parent.appendChild(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
tp.text = function(text) {
|
||||
this.nodeValue = text;
|
||||
return this;
|
||||
}
|
|
@ -23,3 +23,16 @@ export function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
|||
export function isElementVisible(element) {
|
||||
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
|
||||
}
|
||||
|
||||
export function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
// NOTE: inconsistent behavior between browsers when it comes to
|
||||
// whether the newly opened tab gets focused or not, potentially
|
||||
// depending on the event that this function is called from
|
||||
export function openURLInNewTab(url, focus = true) {
|
||||
const newWindow = window.open(url, '_blank', 'noopener,noreferrer');
|
||||
|
||||
if (focus && newWindow != null) newWindow.focus();
|
||||
}
|
|
@ -17,7 +17,7 @@
|
|||
--cm: 1;
|
||||
--tsm: 1;
|
||||
|
||||
--widget-gap: 25px;
|
||||
--widget-gap: 23px;
|
||||
--widget-content-vertical-padding: 15px;
|
||||
--widget-content-horizontal-padding: 17px;
|
||||
--widget-content-padding: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
|
||||
|
@ -37,13 +37,14 @@
|
|||
--color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 3%));
|
||||
--color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 12%)));
|
||||
--color-progress-border: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 10% * var(--cm))));
|
||||
--color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 27% * var(--cm))));
|
||||
--color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 30% * var(--cm))));
|
||||
--color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm))));
|
||||
|
||||
--ths: var(--bgh), calc(var(--bgs) * var(--tsm));
|
||||
--color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
|
||||
--color-text-paragraph: hsl(var(--ths), calc(var(--scheme) var(--cm) * 73%));
|
||||
--color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
|
||||
--color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%));
|
||||
--color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
|
||||
--color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
|
||||
|
||||
--font-size-h1: 1.7rem;
|
||||
|
@ -109,7 +110,7 @@
|
|||
.visited-indicator:not(.text-truncate)::after,
|
||||
.visited-indicator.text-truncate::before,
|
||||
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
|
||||
content: '↗';
|
||||
content: '↗' / "";
|
||||
margin-left: 0.5em;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
@ -188,7 +189,7 @@
|
|||
}
|
||||
|
||||
.expand-toggle-button-icon::before {
|
||||
content: '';
|
||||
content: '' / "";
|
||||
font-size: 0.8rem;
|
||||
transform: rotate(90deg);
|
||||
line-height: 1;
|
||||
|
@ -273,6 +274,10 @@
|
|||
background-color: var(--color-separator);
|
||||
}
|
||||
|
||||
pre {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
|
||||
color: var(--color-text-highlight);
|
||||
|
@ -289,6 +294,12 @@
|
|||
width: 10px;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 0.1rem;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
@ -326,10 +337,23 @@ html {
|
|||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
html, body {
|
||||
html, body, .body-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5 {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
@ -440,6 +464,17 @@ kbd:active {
|
|||
box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
|
||||
}
|
||||
|
||||
.masonry {
|
||||
display: flex;
|
||||
gap: var(--widget-gap);
|
||||
}
|
||||
|
||||
.masonry-column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.popover-container, [data-popover-html] {
|
||||
display: none;
|
||||
}
|
||||
|
@ -541,7 +576,7 @@ kbd:active {
|
|||
}
|
||||
|
||||
.summary::after {
|
||||
content: "◀";
|
||||
content: "◀" / "";
|
||||
font-size: 1.2em;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
@ -728,19 +763,11 @@ details[open] .summary::after {
|
|||
.widget-error-icon {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border: 0.2rem solid var(--color-negative);
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
line-height: 2rem;
|
||||
flex-shrink: 0;
|
||||
stroke: var(--color-negative);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.widget-error-icon::before {
|
||||
content: '!';
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
|
||||
.widget-content {
|
||||
container-type: inline-size;
|
||||
container-name: widget;
|
||||
|
@ -782,6 +809,20 @@ details[open] .summary::after {
|
|||
gap: 1rem;
|
||||
}
|
||||
|
||||
.widget-beta-icon {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
flex-shrink: 0;
|
||||
transition: transform .45s, opacity .45s, stroke .45s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.widget-beta-icon:hover, .widget-header .popover-active > .widget-beta-icon {
|
||||
fill: var(--color-text-highlight);
|
||||
transform: translateY(-10%) scale(1.3);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.widget + .widget {
|
||||
margin-top: var(--widget-gap);
|
||||
}
|
||||
|
@ -794,9 +835,9 @@ details[open] .summary::after {
|
|||
}
|
||||
|
||||
.list-horizontal-text > *:not(:last-child)::after {
|
||||
content: '•';
|
||||
content: '•' / "";
|
||||
color: var(--color-text-subdue);
|
||||
margin: 0 0.5rem;
|
||||
margin: 0 0.4rem;
|
||||
position: relative;
|
||||
top: 0.1rem;
|
||||
}
|
||||
|
@ -928,15 +969,11 @@ details[open] .summary::after {
|
|||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
margin-bottom: auto;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
text-overflow: ellipsis;
|
||||
line-clamp: 2;
|
||||
-webkit-line-clamp: 2;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
.video-horizontal-list-thumbnail {
|
||||
height: 4rem;
|
||||
aspect-ratio: 16 / 8.9;
|
||||
object-fit: cover;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
|
@ -1019,11 +1056,6 @@ details[open] .summary::after {
|
|||
display: none;
|
||||
}
|
||||
|
||||
.forum-post-list-item {
|
||||
display: flex;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.forum-post-list-thumbnail {
|
||||
flex-shrink: 0;
|
||||
width: 6rem;
|
||||
|
@ -1038,6 +1070,12 @@ details[open] .summary::after {
|
|||
transform: translateY(-0.15rem);
|
||||
}
|
||||
|
||||
@container widget (max-width: 550px) {
|
||||
.forum-post-autohide {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-group {
|
||||
--bookmarks-group-color: var(--color-primary);
|
||||
}
|
||||
|
@ -1065,23 +1103,82 @@ details[open] .summary::after {
|
|||
opacity: 0.8;
|
||||
}
|
||||
|
||||
:root:not(.light-scheme) .simple-icon {
|
||||
:root:not(.light-scheme) .flat-icon {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
.old-calendar-day {
|
||||
width: calc(100% / 7);
|
||||
text-align: center;
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
|
||||
|
||||
.calendar-day-today {
|
||||
.old-calendar-day-today {
|
||||
border-radius: var(--border-radius);
|
||||
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%)));
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
|
||||
.calendar-dates {
|
||||
text-align: center;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.calendar-date {
|
||||
padding: 0.4rem 0;
|
||||
color: var(--color-text-paragraph);
|
||||
position: relative;
|
||||
border-radius: var(--border-radius);
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.calendar-current-date {
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--color-popover-border);
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
|
||||
.calendar-spillover-date {
|
||||
color: var(--color-text-subdue);
|
||||
}
|
||||
|
||||
.calendar-header-button {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
z-index: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.calendar-header-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.2rem;
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--color-text-subdue);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.calendar-header-button:hover::before {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.calendar-undo-button {
|
||||
display: inline-block;
|
||||
vertical-align: text-top;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
margin-left: 0.7rem;
|
||||
}
|
||||
|
||||
.dns-stats-totals {
|
||||
transition: opacity .3s;
|
||||
transition-delay: 50ms;
|
||||
|
@ -1342,6 +1439,10 @@ details[open] .summary::after {
|
|||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.clock-time {
|
||||
min-width: 8ch;
|
||||
}
|
||||
|
||||
.clock-time span {
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
|
@ -1358,7 +1459,7 @@ details[open] .summary::after {
|
|||
transition: filter 0.3s, opacity 0.3s;
|
||||
}
|
||||
|
||||
.monitor-site-icon.simple-icon {
|
||||
.monitor-site-icon.flat-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
|
@ -1366,7 +1467,7 @@ details[open] .summary::after {
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
.monitor-site:hover .monitor-site-icon:not(.simple-icon) {
|
||||
.monitor-site:hover .monitor-site-icon:not(.flat-icon) {
|
||||
filter: grayscale(0);
|
||||
}
|
||||
|
||||
|
@ -1377,6 +1478,170 @@ details[open] .summary::after {
|
|||
height: 2rem;
|
||||
}
|
||||
|
||||
.monitor-site-status-icon-compact {
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.docker-container-icon {
|
||||
display: block;
|
||||
filter: grayscale(0.4);
|
||||
object-fit: contain;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 2.7rem;
|
||||
opacity: 0.8;
|
||||
transition: filter 0.3s, opacity 0.3s;
|
||||
}
|
||||
|
||||
.docker-container-icon.flat-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.docker-container:hover .docker-container-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.docker-container:hover .docker-container-icon:not(.flat-icon) {
|
||||
filter: grayscale(0);
|
||||
}
|
||||
|
||||
.docker-container-status-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.widget-type-server-info {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.server + .server {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.server {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-details {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
.server-spicy-cpu-icon {
|
||||
height: 1em;
|
||||
align-self: center;
|
||||
margin-left: 0.4em;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.server-stats {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.server-stat-unavailable {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
border: 1px solid var(--color-progress-border);
|
||||
border-radius: var(--border-radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
height: 1.5rem;
|
||||
margin-inline: -3px; /* naughty, but oh so beautiful */
|
||||
}
|
||||
|
||||
.progress-bar-combined {
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.popover-active > .progress-bar {
|
||||
transition: border-color .3s;
|
||||
border-color: var(--color-text-subdue);
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
--half-border-radius: calc(var(--border-radius) / 2);
|
||||
border-radius: 0 var(--half-border-radius) var(--half-border-radius) 0;
|
||||
background: var(--color-progress-value);
|
||||
width: calc(var(--percent) * 1%);
|
||||
min-width: 1px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.progress-value:first-child {
|
||||
border-top-left-radius: var(--half-border-radius);
|
||||
}
|
||||
|
||||
.progress-value:last-child {
|
||||
border-bottom-left-radius: var(--half-border-radius);
|
||||
}
|
||||
|
||||
.progress-value-notice {
|
||||
background: linear-gradient(to right, var(--color-progress-value) 65%, var(--color-negative));
|
||||
}
|
||||
|
||||
.value-separator {
|
||||
min-width: 2rem;
|
||||
margin-inline: 0.8rem;
|
||||
flex: 1;
|
||||
height: calc(1em * 1.1);
|
||||
border-bottom: 1px dotted var(--color-text-subdue);
|
||||
}
|
||||
|
||||
@container widget (min-width: 650px) {
|
||||
.server {
|
||||
gap: 2rem;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.server + .server {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: unset;
|
||||
margin-right: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.server-stats {
|
||||
flex-direction: row;
|
||||
justify-content: right;
|
||||
min-width: 450px;
|
||||
margin-top: 0;
|
||||
gap: 2rem;
|
||||
padding-bottom: 0.8rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.server-stats > * {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
filter: grayscale(0.2) contrast(0.9);
|
||||
opacity: 0.8;
|
||||
|
@ -1496,6 +1761,14 @@ details[open] .summary::after {
|
|||
border: 2px solid var(--color-widget-background);
|
||||
}
|
||||
|
||||
.twitch-stream-preview {
|
||||
max-width: 100%;
|
||||
width: 400px;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--border-radius);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.reddit-card-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
@ -1660,6 +1933,11 @@ details[open] .summary::after {
|
|||
.weather-column-rain::before {
|
||||
background-size: 7px 7px;
|
||||
}
|
||||
|
||||
.ios .search-input {
|
||||
/* so that iOS Safari does not zoom the page when the input is focused */
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1190px) and (display-mode: standalone) {
|
||||
|
@ -1667,7 +1945,11 @@ details[open] .summary::after {
|
|||
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
.list-collapsible-label:has(.list-collapsible-input:checked) {
|
||||
.ios .body-content {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.expand-toggle-button.container-expanded {
|
||||
bottom: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
|
@ -1681,6 +1963,10 @@ details[open] .summary::after {
|
|||
transition: padding-bottom .3s;
|
||||
}
|
||||
|
||||
.mobile-navigation-offset {
|
||||
height: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.mobile-navigation-icons:has(.mobile-navigation-page-links-input:checked) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
@ -1739,6 +2025,7 @@ details[open] .summary::after {
|
|||
.size-h6 { font-size: var(--font-size-h6); }
|
||||
|
||||
.color-highlight { color: var(--color-text-highlight); }
|
||||
.color-paragraph { color: var(--color-text-paragraph); }
|
||||
.color-base { color: var(--color-text-base); }
|
||||
.color-subdue { color: var(--color-text-subdue); }
|
||||
.color-negative { color: var(--color-negative); }
|
||||
|
@ -1752,17 +2039,18 @@ details[open] .summary::after {
|
|||
.text-center { text-align: center; }
|
||||
.text-elevate { margin-top: -0.2em; }
|
||||
.text-compact { word-spacing: -0.18em; }
|
||||
.text-very-compact { word-spacing: -0.35em; }
|
||||
.rtl { direction: rtl; }
|
||||
.shrink { flex-shrink: 1; }
|
||||
.shrink-0 { flex-shrink: 0; }
|
||||
.min-width-0 { min-width: 0; }
|
||||
.max-width-100 { max-width: 100%; }
|
||||
.height-100 { height: 100%; }
|
||||
.block { display: block; }
|
||||
.inline-block { display: inline-block; }
|
||||
.overflow-hidden { overflow: hidden; }
|
||||
.relative { position: relative; }
|
||||
.flex { display: flex; }
|
||||
.flex-1 { flex: 1; }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.flex-nowrap { flex-wrap: nowrap; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
|
@ -1775,15 +2063,18 @@ details[open] .summary::after {
|
|||
.flex-column { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.items-start { align-items: start; }
|
||||
.items-end { align-items: end; }
|
||||
.gap-5 { gap: 0.5rem; }
|
||||
.gap-7 { gap: 0.7rem; }
|
||||
.gap-10 { gap: 1rem; }
|
||||
.gap-12 { gap: 1.2rem; }
|
||||
.gap-15 { gap: 1.5rem; }
|
||||
.gap-20 { gap: 2rem; }
|
||||
.gap-25 { gap: 2.5rem; }
|
||||
.gap-35 { gap: 3.5rem; }
|
||||
.gap-45 { gap: 4.5rem; }
|
||||
.gap-55 { gap: 5.5rem; }
|
||||
.margin-left-auto { margin-left: auto; }
|
||||
.margin-top-3 { margin-top: 0.3rem; }
|
||||
.margin-top-5 { margin-top: 0.5rem; }
|
||||
.margin-top-7 { margin-top: 0.7rem; }
|
||||
|
@ -1797,6 +2088,7 @@ details[open] .summary::after {
|
|||
.margin-block-3 { margin-block: 0.3rem; }
|
||||
.margin-block-5 { margin-block: 0.5rem; }
|
||||
.margin-block-7 { margin-block: 0.7rem; }
|
||||
.margin-block-8 { margin-block: 0.8rem; }
|
||||
.margin-block-10 { margin-block: 1rem; }
|
||||
.margin-block-15 { margin-block: 1.5rem; }
|
||||
.margin-bottom-3 { margin-bottom: 0.3rem; }
|
||||
|
@ -1810,6 +2102,7 @@ details[open] .summary::after {
|
|||
.list { --list-half-gap: 0rem; }
|
||||
.list-gap-2 { --list-half-gap: 0.1rem; }
|
||||
.list-gap-4 { --list-half-gap: 0.2rem; }
|
||||
.list-gap-8 { --list-half-gap: 0.4rem; }
|
||||
.list-gap-10 { --list-half-gap: 0.5rem; }
|
||||
.list-gap-14 { --list-half-gap: 0.7rem; }
|
||||
.list-gap-20 { --list-half-gap: 1rem; }
|
83
internal/glance/templates.go
Normal file
|
@ -0,0 +1,83 @@
|
|||
package glance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
var intl = message.NewPrinter(language.English)
|
||||
|
||||
var globalTemplateFunctions = template.FuncMap{
|
||||
"formatApproxNumber": formatApproxNumber,
|
||||
"formatNumber": intl.Sprint,
|
||||
"safeCSS": func(str string) template.CSS {
|
||||
return template.CSS(str)
|
||||
},
|
||||
"safeURL": func(str string) template.URL {
|
||||
return template.URL(str)
|
||||
},
|
||||
"absInt": func(i int) int {
|
||||
return int(math.Abs(float64(i)))
|
||||
},
|
||||
"formatPrice": func(price float64) string {
|
||||
return intl.Sprintf("%.2f", price)
|
||||
},
|
||||
"dynamicRelativeTimeAttrs": func(t interface{ Unix() int64 }) template.HTMLAttr {
|
||||
return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`)
|
||||
},
|
||||
"formatServerMegabytes": func(mb uint64) template.HTML {
|
||||
var value string
|
||||
var label string
|
||||
|
||||
if mb < 1_000 {
|
||||
value = strconv.FormatUint(mb, 10)
|
||||
label = "MB"
|
||||
} else if mb < 1_000_000 {
|
||||
if mb < 10_000 {
|
||||
value = fmt.Sprintf("%.1f", float64(mb)/1_000)
|
||||
} else {
|
||||
value = strconv.FormatUint(mb/1_000, 10)
|
||||
}
|
||||
|
||||
label = "GB"
|
||||
} else {
|
||||
value = fmt.Sprintf("%.1f", float64(mb)/1_000_000)
|
||||
label = "TB"
|
||||
}
|
||||
|
||||
return template.HTML(value + ` <span class="color-base size-h5">` + label + `</span>`)
|
||||
},
|
||||
}
|
||||
|
||||
func mustParseTemplate(primary string, dependencies ...string) *template.Template {
|
||||
t, err := template.New(primary).
|
||||
Funcs(globalTemplateFunctions).
|
||||
ParseFS(templateFS, append([]string{primary}, dependencies...)...)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func formatApproxNumber(count int) string {
|
||||
if count < 1_000 {
|
||||
return strconv.Itoa(count)
|
||||
}
|
||||
|
||||
if count < 10_000 {
|
||||
return strconv.FormatFloat(float64(count)/1_000, 'f', 1, 64) + "k"
|
||||
}
|
||||
|
||||
if count < 1_000_000 {
|
||||
return strconv.Itoa(count/1_000) + "k"
|
||||
}
|
||||
|
||||
return strconv.FormatFloat(float64(count)/1_000_000, 'f', 1, 64) + "m"
|
||||
}
|
|
@ -3,17 +3,17 @@
|
|||
{{ define "widget-content" }}
|
||||
<div class="dynamic-columns list-gap-24 list-with-separator">
|
||||
{{ range .Groups }}
|
||||
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
|
||||
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.String | safeCSS }}"{{ end }}>
|
||||
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
|
||||
<ul class="list list-gap-2">
|
||||
{{ range .Links }}
|
||||
<li class="flex items-center gap-10">
|
||||
{{ if ne "" .Icon }}
|
||||
{{ if ne "" .Icon.URL }}
|
||||
<div class="bookmarks-icon-container">
|
||||
<img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
|
||||
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||
</div>
|
||||
{{ end }}
|
||||
<a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||
<a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
7
internal/glance/templates/calendar.html
Normal file
|
@ -0,0 +1,7 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="widget-small-content-bounds">
|
||||
<div class="calendar" data-first-day-of-week="{{ .FirstDay }}"></div>
|
||||
</div>
|
||||
{{ end }}
|
7
internal/glance/templates/custom-api.html
Normal file
|
@ -0,0 +1,7 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}{{ if .Frameless }}widget-content-frameless{{ end }}{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
{{ .CompiledHTML }}
|
||||
{{ end }}
|
|
@ -18,12 +18,14 @@
|
|||
</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="color-highlight size-h3">{{ .Stats.DomainsBlocked | formatApproxNumber }}</div>
|
||||
<div class="size-h6">DOMAINS</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ $showGraph := not (or .HideGraph (eq (len .Stats.Series) 0)) }}
|
||||
{{ if $showGraph }}
|
||||
<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">
|
||||
|
@ -67,13 +69,14 @@
|
|||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if .Stats.TopBlockedDomains }}
|
||||
<details class="details margin-top-40">
|
||||
{{ if and (not .HideTopDomains) .Stats.TopBlockedDomains }}
|
||||
<details class="details {{ if $showGraph }}margin-top-40{{ else }}margin-top-15{{ end }}">
|
||||
<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">
|
||||
<li class="flex justify-between gap-10">
|
||||
<div class="text-truncate rtl">{{ .Domain }}</div>
|
||||
<div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
|
||||
</li>
|
64
internal/glance/templates/docker-containers.html
Normal file
|
@ -0,0 +1,64 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{- define "widget-content" }}
|
||||
<div class="dynamic-columns list-gap-20 list-with-separator">
|
||||
{{- range .Containers }}
|
||||
<div class="docker-container flex items-center gap-15">
|
||||
<div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem" data-popover-max-width="400px">
|
||||
<img class="docker-container-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||
<div data-popover-html>
|
||||
<div class="color-highlight text-truncate block">{{ .Image }}</div>
|
||||
<div>{{ .StateText }}</div>
|
||||
{{- if .Children }}
|
||||
<ul class="list list-gap-4 margin-top-10">
|
||||
{{- range .Children }}
|
||||
<li class="flex gap-7 items-center">
|
||||
<div class="margin-bottom-3">{{ template "state-icon" .StateIcon }}</div>
|
||||
<div class="color-highlight">{{ .Title }} <span class="size-h5 color-base">{{ .StateText }}</span></div>
|
||||
</li>
|
||||
{{- end }}
|
||||
</ul>
|
||||
{{- end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-width-0">
|
||||
{{- if .URL }}
|
||||
<a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||
{{- else }}
|
||||
<div class="color-highlight text-truncate size-title-dynamic">{{ .Title }}</div>
|
||||
{{- end }}
|
||||
{{- if .Description }}
|
||||
<div class="text-truncate">{{ .Description }}</div>
|
||||
{{- end }}
|
||||
</div>
|
||||
|
||||
<div class="margin-left-auto shrink-0" data-popover-type="text" data-popover-position="above" data-popover-text="{{ .State }}">
|
||||
{{ template "state-icon" .StateIcon }}
|
||||
</div>
|
||||
</div>
|
||||
{{- else }}
|
||||
<div class="text-center">No containers available to show.</div>
|
||||
{{- end }}
|
||||
</div>
|
||||
{{- end }}
|
||||
|
||||
{{- define "state-icon" }}
|
||||
{{- if eq . "ok" }}
|
||||
<svg class="docker-container-status-icon" fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{- else if eq . "warn" }}
|
||||
<svg class="docker-container-status-icon" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{- else if eq . "paused" }}
|
||||
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M2 10a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm5-2.25A.75.75 0 0 1 7.75 7h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Zm4 0a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{- else }}
|
||||
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.94 6.94a.75.75 0 1 1-1.061-1.061 3 3 0 1 1 2.871 5.026v.345a.75.75 0 0 1-1.5 0v-.5c0-.72.57-1.172 1.081-1.287A1.5 1.5 0 1 0 8.94 6.94ZM10 15a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{- end }}
|
||||
{{- end }}
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
{{ block "document-head-before" . }}{{ end }}
|
||||
<title>{{ block "document-title" . }}{{ end }}</title>
|
||||
<script>if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');</script>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
7
internal/glance/templates/extension.html
Normal file
|
@ -0,0 +1,7 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}{{ if .Extension.Frameless }}widget-content-frameless{{ end }}{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
{{ .Extension.Content }}
|
||||
{{ end }}
|
49
internal/glance/templates/forum-posts.html
Normal file
|
@ -0,0 +1,49 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{- define "widget-content" }}
|
||||
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||
{{- range .Posts }}
|
||||
<li>
|
||||
<div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
|
||||
{{- if $.ShowThumbnails }}
|
||||
{{- if .IsCrosspost }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
{{- else if .ThumbnailUrl }}
|
||||
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
|
||||
{{- else if .TargetUrl }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
{{- else }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
</svg>
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
<div class="grow min-width-0">
|
||||
<a href="{{ .DiscussionUrl }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
{{- if .Tags }}
|
||||
<div class="inline-block forum-post-tags-container">
|
||||
<ul class="attachments">
|
||||
{{- range .Tags }}
|
||||
<li>{{ . }}</li>
|
||||
{{- end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{- end }}
|
||||
<ul class="list-horizontal-text flex-nowrap text-compact">
|
||||
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||
<li class="shrink-0">{{ .Score | formatApproxNumber }} points</li>
|
||||
<li class="shrink-0{{ if .TargetUrl }} forum-post-autohide{{ end }}">{{ .CommentCount | formatApproxNumber }} comments</li>
|
||||
{{- if .TargetUrl }}
|
||||
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
||||
{{- end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{{- end }}
|
||||
</ul>
|
||||
{{- end }}
|
21
internal/glance/templates/group.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
{{ 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" role="tablist">
|
||||
{{- range $i, $widget := .Widgets }}
|
||||
<button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}"{{ if ne "" .TitleURL }} data-title-url="{{ .TitleURL }}"{{ end }} aria-selected="{{ if eq $i 0 }}true{{ else }}false{{ end }}" arial-level="2" role="tab" aria-controls="widget-{{ .GetID }}-tabpanel-{{ $i }}" id="widget-{{ .GetID }}-tab-{{ $i }}">{{ $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 }}" id="widget-{{ .GetID }}-tabpanel-{{ $i }}" role="tabpanel" aria-labelledby="widget-{{ .GetID }}-tab-{{ $i }}" aria-hidden="{{ if eq $i 0 }}false{{ else }}true{{ end }}">
|
||||
{{- .Render -}}
|
||||
</div>
|
||||
{{- end }}
|
||||
</div>
|
||||
{{ end }}
|
39
internal/glance/templates/monitor-compact.html
Normal file
|
@ -0,0 +1,39 @@
|
|||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
{{ if not (and .ShowFailingOnly (not .HasFailing)) }}
|
||||
<ul class="dynamic-columns list-gap-8">
|
||||
{{ range .Sites }}
|
||||
{{ if and $.ShowFailingOnly (eq .StatusStyle "ok" ) }}{{ continue }}{{ end }}
|
||||
<div class="flex items-center gap-12">
|
||||
{{ template "site" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</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" }}
|
||||
<a class="size-title-dynamic color-highlight text-truncate block grow" href="{{ .URL | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||
{{ if not .Status.TimedOut }}<div>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</div>{{ end }}
|
||||
{{ if eq .StatusStyle "ok" }}
|
||||
<div class="monitor-site-status-icon-compact" title="{{ .Status.Code }}">
|
||||
<svg fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="monitor-site-status-icon-compact" title="{{ if .Status.Error }}{{ .Status.Error }}{{ else }}{{ .Status.Code }}{{ end }}">
|
||||
<svg fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
|
@ -21,11 +21,11 @@
|
|||
{{ end }}
|
||||
|
||||
{{ define "site" }}
|
||||
{{ if .IconUrl }}
|
||||
<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy">
|
||||
{{ if .Icon.URL }}
|
||||
<img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" 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>
|
||||
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL | safeURL }}" {{ 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>
|
||||
|
@ -39,14 +39,14 @@
|
|||
</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 fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" 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 fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
{{ end }}
|
|
@ -11,18 +11,23 @@
|
|||
</div>
|
||||
|
||||
<div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
|
||||
<div class="calendar-day">Mo</div>
|
||||
<div class="calendar-day">Tu</div>
|
||||
<div class="calendar-day">We</div>
|
||||
<div class="calendar-day">Th</div>
|
||||
<div class="calendar-day">Fr</div>
|
||||
<div class="calendar-day">Sa</div>
|
||||
<div class="calendar-day">Su</div>
|
||||
{{ if .StartSunday }}
|
||||
<div class="old-calendar-day">Su</div>
|
||||
{{ end }}
|
||||
<div class="old-calendar-day">Mo</div>
|
||||
<div class="old-calendar-day">Tu</div>
|
||||
<div class="old-calendar-day">We</div>
|
||||
<div class="old-calendar-day">Th</div>
|
||||
<div class="old-calendar-day">Fr</div>
|
||||
<div class="old-calendar-day">Sa</div>
|
||||
{{ if not .StartSunday }}
|
||||
<div class="old-calendar-day">Su</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap">
|
||||
{{ range .Calendar.Days }}
|
||||
<div class="calendar-day{{ if eq . $.Calendar.CurrentDay }} calendar-day-today{{ end }}">{{ . }}</div>
|
||||
<div class="old-calendar-day{{ if eq . $.Calendar.CurrentDay }} old-calendar-day-today{{ end }}">{{ . }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|