Compare commits
No commits in common. "main" and "v0.5.0-beta.1" have entirely different histories.
main
...
v0.5.0-bet
|
@ -1,11 +0,0 @@
|
||||||
# https://docs.docker.com/build/building/context/#dockerignore-files
|
|
||||||
# Ignore all files by default
|
|
||||||
*
|
|
||||||
|
|
||||||
# Only add necessary files to the Docker build context (Dockerfiles are always included implicitly)
|
|
||||||
!/build/
|
|
||||||
!/internal/
|
|
||||||
!/pkg/
|
|
||||||
!/go.mod
|
|
||||||
!/go.sum
|
|
||||||
!main.go
|
|
1
.github/FUNDING.yml
vendored
|
@ -1 +0,0 @@
|
||||||
github: [glanceapp]
|
|
37
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
@ -1,37 +0,0 @@
|
||||||
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
|
@ -1,8 +0,0 @@
|
||||||
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
|
@ -1,33 +0,0 @@
|
||||||
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.
|
|
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
@ -1 +0,0 @@
|
||||||
<!-- 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 -->
|
|
39
.github/workflows/release.yaml
vendored
|
@ -1,39 +0,0 @@
|
||||||
name: Create release
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout the target Git reference
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Golang
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
|
|
||||||
- name: Set up Docker buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Run GoReleaser
|
|
||||||
uses: goreleaser/goreleaser-action@v5
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
args: release
|
|
3
.gitignore
vendored
|
@ -1,5 +1,4 @@
|
||||||
/assets
|
/assets
|
||||||
/build
|
/build
|
||||||
/playground
|
/playground
|
||||||
/.idea
|
glance.yml
|
||||||
/glance*.yml
|
|
||||||
|
|
|
@ -1,69 +0,0 @@
|
||||||
project_name: glanceapp/glance
|
|
||||||
|
|
||||||
checksum:
|
|
||||||
disable: true
|
|
||||||
|
|
||||||
builds:
|
|
||||||
- binary: glance
|
|
||||||
env:
|
|
||||||
- CGO_ENABLED=0
|
|
||||||
goos:
|
|
||||||
- linux
|
|
||||||
- openbsd
|
|
||||||
- freebsd
|
|
||||||
- windows
|
|
||||||
- darwin
|
|
||||||
goarch:
|
|
||||||
- amd64
|
|
||||||
- arm64
|
|
||||||
- arm
|
|
||||||
- 386
|
|
||||||
goarm:
|
|
||||||
- 7
|
|
||||||
ldflags:
|
|
||||||
- -s -w -X github.com/glanceapp/glance/internal/glance.buildVersion={{ .Tag }}
|
|
||||||
|
|
||||||
archives:
|
|
||||||
-
|
|
||||||
name_template: "glance-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}"
|
|
||||||
files:
|
|
||||||
- nothing*
|
|
||||||
format_overrides:
|
|
||||||
- goos: windows
|
|
||||||
format: zip
|
|
||||||
|
|
||||||
dockers:
|
|
||||||
- image_templates:
|
|
||||||
- &amd64_image "{{ .ProjectName }}:{{ .Tag }}-amd64"
|
|
||||||
build_flag_templates:
|
|
||||||
- --platform=linux/amd64
|
|
||||||
goarch: amd64
|
|
||||||
use: buildx
|
|
||||||
dockerfile: Dockerfile.goreleaser
|
|
||||||
|
|
||||||
- image_templates:
|
|
||||||
- &arm64v8_image "{{ .ProjectName }}:{{ .Tag }}-arm64"
|
|
||||||
build_flag_templates:
|
|
||||||
- --platform=linux/arm64
|
|
||||||
goarch: arm64
|
|
||||||
use: buildx
|
|
||||||
dockerfile: Dockerfile.goreleaser
|
|
||||||
|
|
||||||
- image_templates:
|
|
||||||
- &armv7_image "{{ .ProjectName }}:{{ .Tag }}-armv7"
|
|
||||||
build_flag_templates:
|
|
||||||
- --platform=linux/arm/v7
|
|
||||||
goarch: arm
|
|
||||||
goarm: 7
|
|
||||||
use: buildx
|
|
||||||
dockerfile: Dockerfile.goreleaser
|
|
||||||
|
|
||||||
docker_manifests:
|
|
||||||
- name_template: "{{ .ProjectName }}:{{ .Tag }}"
|
|
||||||
image_templates: &multiarch_images
|
|
||||||
- *amd64_image
|
|
||||||
- *arm64v8_image
|
|
||||||
- *armv7_image
|
|
||||||
- name_template: "{{ .ProjectName }}:latest"
|
|
||||||
skip_push: auto
|
|
||||||
image_templates: *multiarch_images
|
|
19
Dockerfile
|
@ -1,16 +1,11 @@
|
||||||
FROM golang:1.24.2-alpine3.21 AS builder
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG TARGETVARIANT
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . /app
|
COPY build/glance-$TARGETOS-$TARGETARCH${TARGETVARIANT} /app/glance
|
||||||
RUN CGO_ENABLED=0 go build .
|
|
||||||
|
|
||||||
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
|
EXPOSE 8080/tcp
|
||||||
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
ENTRYPOINT ["/app/glance"]
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
FROM alpine:3.21
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY glance .
|
|
||||||
|
|
||||||
HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
|
|
||||||
CMD wget --spider -q http://localhost:8080/api/healthz
|
|
||||||
|
|
||||||
EXPOSE 8080/tcp
|
|
||||||
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
|
7
Dockerfile.single-platform
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY build/glance /app/glance
|
||||||
|
|
||||||
|
EXPOSE 8080/tcp
|
||||||
|
ENTRYPOINT ["/app/glance"]
|
457
README.md
|
@ -1,436 +1,113 @@
|
||||||
<p align="center"><em>What if you could see everything at a...</em></p>
|
<p align="center"><em>What if you could see everything at a...</em></p>
|
||||||
<h1 align="center">Glance</h1>
|
<h1 align="center">Glance</h1>
|
||||||
<p align="center"><a href="#installation">Install</a> • <a href="docs/configuration.md">Configuration</a> • <a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a> • <a href="https://github.com/sponsors/glanceapp">Sponsor</a></p>
|
<p align="center"><a href="#installation">Install</a> • <a href="docs/configuration.md">Configuration</a> • <a href="docs/themes.md">Themes</a></p>
|
||||||
<p align="center"><a href="https://github.com/glanceapp/community-widgets">Community widgets</a> • <a href="docs/preconfigured-pages.md">Preconfigured pages</a> • <a href="docs/themes.md">Themes</a></p>
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Features
|
### Features
|
||||||
### Various widgets
|
#### Various widgets
|
||||||
* RSS feeds
|
* RSS feeds
|
||||||
* Subreddit posts
|
* Subreddit posts
|
||||||
* Hacker News posts
|
* Weather
|
||||||
* Weather forecasts
|
* Bookmarks
|
||||||
* YouTube channel uploads
|
* Latest YouTube videos from specific channels
|
||||||
* Twitch channels
|
* Calendar
|
||||||
* Market prices
|
* Stocks
|
||||||
* Docker containers status
|
* iframe
|
||||||
* Server stats
|
* Twitch channels & top games
|
||||||
* Custom widgets
|
* GitHub releases
|
||||||
* [and many more...](docs/configuration.md)
|
* Repository overview
|
||||||
|
* Site monitor
|
||||||
|
|
||||||
### Fast and lightweight
|
#### Themeable
|
||||||
* 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)
|
|
||||||
|
|
||||||
### Tons of customizability
|
#### Optimized for mobile devices
|
||||||
* Different layouts
|

|
||||||
* As many pages/tabs as you need
|
|
||||||
* Numerous configuration options for each widget
|
|
||||||
* Multiple styles for some widgets
|
|
||||||
* Custom CSS
|
|
||||||
|
|
||||||
### Optimized for mobile devices
|
#### Fast and lightweight
|
||||||
Because you'll want to take it with you on the go.
|
* 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)
|
||||||
|
|
||||||

|
### 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.
|
||||||
|
|
||||||
### Themeable
|
### Installation
|
||||||
Easily create your own theme by tweaking a few numbers or choose from one of the [already available themes](docs/themes.md).
|
> [!CAUTION]
|
||||||
|
>
|
||||||
|
> The project is under active development, expect things to break every once in a while.
|
||||||
|
|
||||||

|
#### 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
|
|
||||||
restart: unless-stopped
|
|
||||||
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
|
```bash
|
||||||
/opt/glance/glance --config /etc/glance.yml
|
/opt/glance/glance --config /etc/glance.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
To grab a starting template for the config file, run:
|
#### Docker
|
||||||
|
> [!IMPORTANT]
|
||||||
|
>
|
||||||
|
> Make sure you have a valid `glance.yml` file in the same directory before running the container.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Windows
|
Or if you prefer docker compose:
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
## Common issues
|
|
||||||
<details>
|
|
||||||
<summary><strong>Requests timing out</strong></summary>
|
|
||||||
|
|
||||||
The most common cause of this is when using Pi-Hole, AdGuard Home or other ad-blocking DNS services, which by default have a fairly low rate limit. Depending on the number of widgets you have in a single page, this limit can very easily be exceeded. To fix this, increase the rate limit in the settings of your DNS service.
|
|
||||||
|
|
||||||
If using Podman, in some rare cases the timeout can be caused by an unknown issue, in which case it may be resolved by adding the following to the bottom of your `docker-compose.yml` file:
|
|
||||||
```yaml
|
|
||||||
networks:
|
|
||||||
podman:
|
|
||||||
external: true
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Broken layout for markets, bookmarks or other widgets</strong></summary>
|
|
||||||
|
|
||||||
This is almost always caused by the browser extension Dark Reader. To fix this, disable dark mode for the domain where Glance is hosted.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>cannot unmarshal !!map into []glance.page</strong></summary>
|
|
||||||
|
|
||||||
The most common cause of this is having a `pages` key in your `glance.yml` and then also having a `pages` key inside one of your included pages. To fix this, remove the `pages` key from the top of your included pages.
|
|
||||||
|
|
||||||
</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
|
```yaml
|
||||||
- type: rss
|
services:
|
||||||
title: My custom title
|
glance:
|
||||||
|
image: glanceapp/glance
|
||||||
- type: markets
|
volumes:
|
||||||
title: My custom title
|
- ./glance.yml:/app/glance.yml
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
- type: videos
|
- /etc/localtime:/etc/localtime:ro
|
||||||
title: My custom title
|
ports:
|
||||||
|
- 8080:8080
|
||||||
# and so on for all widgets...
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
</details>
|
|
||||||
|
|
||||||
<br>
|
### Building from source
|
||||||
|
|
||||||
## Feature requests
|
Requirements: [Go](https://go.dev/dl/) >= v1.22
|
||||||
|
|
||||||
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).
|
To build:
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
go build -o build/glance .
|
go build -o build/glance .
|
||||||
```
|
```
|
||||||
|
|
||||||
To build for a specific OS and architecture, run:
|
To 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
|
```bash
|
||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
<hr>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
### Building Docker image
|
||||||
<summary><strong>Build project and Docker image with Docker</strong></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Requirements: [Docker](https://docs.docker.com/engine/install/)
|
Build Glance with CGO disabled:
|
||||||
|
|
||||||
To build the project and image using just Docker, run:
|
|
||||||
|
|
||||||
*(replace `owner` with your name or organization)*
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t owner/glance:latest .
|
CGO_ENABLED=0 go build -o build/glance .
|
||||||
```
|
```
|
||||||
|
|
||||||
If you wish to push the image to a registry (by default Docker Hub), run:
|
Build the image:
|
||||||
|
|
||||||
|
**Make sure to replace "owner" with your name or organization.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t owner/glance:latest -f Dockerfile.single-platform .
|
||||||
|
```
|
||||||
|
|
||||||
|
Push the image to your registry:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker push owner/glance:latest
|
docker push owner/glance:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
<hr>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<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.
|
|
||||||
|
|
|
@ -1,415 +0,0 @@
|
||||||
[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 on either ints or floats. If both numbers are ints, an int will be returned, otherwise a float will be returned. If you try to divide by zero, 0 will be returned. If you provide non-numeric values, `NaN` will be returned.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>{{ sub (.JSON.Int "price") (.JSON.Int "discount") }}</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>90</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Other operations include `add`, `mul`, and `div`.
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
JSON response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"posts": [
|
|
||||||
{
|
|
||||||
"title": "Exploring the Depths of Quantum Computing",
|
|
||||||
"date": "2023-10-27T10:00:00Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "A Beginner's Guide to Sustainable Living",
|
|
||||||
"date": "2023-11-15T14:30:00+01:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "The Art of Baking Sourdough Bread",
|
|
||||||
"date": "2023-12-03T08:45:22-08:00"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
To parse the date and display the relative time (e.g. 2h, 1d, etc), you would use the following:
|
|
||||||
|
|
||||||
```
|
|
||||||
{{ range .JSON.Array "posts" }}
|
|
||||||
<div>{{ .String "title" }}</div>
|
|
||||||
<div {{ .String "date" | parseTime "rfc3339" | toRelativeTime }}></div>
|
|
||||||
{{ end }}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `parseTime` function takes two arguments: the layout of the date string and the date string itself. The layout can be one of the following: "RFC3339", "RFC3339Nano", "DateTime", "DateOnly", "TimeOnly" or a custom layout in Go's [date format](https://pkg.go.dev/time#pkg-constants).
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div>Exploring the Depths of Quantum Computing</div>
|
|
||||||
<div data-dynamic-relative-time="1698400800"></div>
|
|
||||||
|
|
||||||
<div>A Beginner's Guide to Sustainable Living</div>
|
|
||||||
<div data-dynamic-relative-time="1700055000"></div>
|
|
||||||
|
|
||||||
<div>The Art of Baking Sourdough Bread</div>
|
|
||||||
<div data-dynamic-relative-time="1701621922"></div>
|
|
||||||
```
|
|
||||||
|
|
||||||
You don't have to worry about the internal implementation, this will then be dynamically populated by Glance on the client side to show the correct relative time.
|
|
||||||
|
|
||||||
The important thing to notice here is that the return value of `toRelativeTime` must be used as an attribute in an HTML tag, be it a `div`, `li`, `span`, etc.
|
|
||||||
|
|
||||||
<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>
|
|
||||||
```
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
JSON response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"name": "Steve", "age": 30}
|
|
||||||
{"name": "Alex", "age": 25}
|
|
||||||
{"name": "John", "age": 35}
|
|
||||||
```
|
|
||||||
|
|
||||||
The above format is "[ndjson](https://docs.mulesoft.com/dataweave/latest/dataweave-formats-ndjson)" or "[JSON Lines](https://jsonlines.org/)", where each line is a separate JSON object. To parse this format, you must first disable the JSON validation check in your config, since by default the response is expected to be a single valid JSON object:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- type: custom-api
|
|
||||||
skip-json-validation: true
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, to iterate over each object you can use `.JSONLines`:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{ range .JSONLines }}
|
|
||||||
<p>{{ .String "name" }} is {{ .Int "age" }} years old</p>
|
|
||||||
{{ end }}
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<p>Steve is 30 years old</p>
|
|
||||||
<p>Alex is 25 years old</p>
|
|
||||||
<p>John is 35 years old</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
For other ways of selecting data from a JSON Lines response, have a look at the docs for [tidwall/gjson](https://github.com/tidwall/gjson/tree/master?tab=readme-ov-file#json-lines). For example, to get an array of all names, you can use the following:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{ range .JSON.Array "..#.name" }}
|
|
||||||
<p>{{ .String "" }}</p>
|
|
||||||
{{ end }}
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<p>Steve</p>
|
|
||||||
<p>Alex</p>
|
|
||||||
<p>John</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
- `toRelativeTime(t time.Time) template.HTMLAttr`: Converts Time to a relative time such as 2h, 1d, etc which dynamically updates. **NOTE:** the value of this function should be used as an attribute in an HTML tag, e.g. `<span {{ toRelativeTime .Time }}></span>`.
|
|
||||||
- `now() time.Time`: Returns the current time.
|
|
||||||
- `offsetNow(offset string) time.Time`: Returns the current time with an offset. The offset can be positive or negative and must be in the format "3h" "-1h" or "2h30m10s".
|
|
||||||
- `duration(str string) time.Duration`: Parses a string such as `1h`, `24h`, `5h30m`, etc into a `time.Duration`.
|
|
||||||
- `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly".
|
|
||||||
- `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`.
|
|
||||||
- `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.
|
|
||||||
- `trimPrefix(prefix string, str string) string`: Trims the prefix from a string.
|
|
||||||
- `trimSuffix(suffix string, str string) string`: Trims the suffix from a string.
|
|
||||||
- `trimSpace(str string) string`: Trims whitespace from a string on both ends.
|
|
||||||
- `replaceAll(old string, new string, str string) string`: Replaces all occurrences of a string in a string.
|
|
||||||
- `replaceMatches(pattern string, replacement string, str string) string`: Replaces all occurrences of a regular expression in a string.
|
|
||||||
- `findMatch(pattern string, str string) string`: Finds the first match of a regular expression in a string.
|
|
||||||
- `findSubmatch(pattern string, str string) string`: Finds the first submatch of a regular expression in a string.
|
|
||||||
- `sortByString(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a string key in either ascending or descending order.
|
|
||||||
- `sortByInt(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by an integer key in either ascending or descending order.
|
|
||||||
- `sortByFloat(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a float key in either ascending or descending order.
|
|
||||||
- `sortByTime(key string, layout string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a time key in either ascending or descending order. The format must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants).
|
|
||||||
- `concat(strings ...string) string`: Concatenates multiple strings together.
|
|
||||||
- `unique(key string, arr []JSON) []JSON`: Returns a unique array of JSON objects based on the given key.
|
|
||||||
|
|
||||||
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.
|
|
|
@ -1,164 +0,0 @@
|
||||||
# Extensions
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
>
|
|
||||||
> **This document as well as the extensions feature are a work in progress. The API may change in the future. You are responsible for maintaining your own extensions.**
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
With the intention of requiring minimal knowledge in order to develop extensions, rather than being a convoluted protocol they are nothing more than an HTTP request to a server that returns a few special headers. The exchange between Glance and extensions can be seen in the following diagram:
|
|
||||||
|
|
||||||

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

|
|
||||||
|
|
||||||
**Class names or features may change, once again, you are responsible for maintaining your own extensions.**
|
|
105
docs/glance.yml
|
@ -1,105 +0,0 @@
|
||||||
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
|
|
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 7.9 KiB |
Before Width: | Height: | Size: 5.7 KiB |
Before Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 9.7 KiB |
Before Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 36 KiB |
Before Width: | Height: | Size: 133 KiB |
Before Width: | Height: | Size: 87 KiB |
Before Width: | Height: | Size: 946 KiB |
Before Width: | Height: | Size: 81 KiB |
Before Width: | Height: | Size: 39 KiB |
Before Width: | Height: | Size: 637 KiB |
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 261 KiB |
Before Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 343 KiB |
Before Width: | Height: | Size: 482 KiB After Width: | Height: | Size: 370 KiB |
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 229 KiB |
Before Width: | Height: | Size: 5.5 KiB |
Before Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 9.6 KiB |
Before Width: | Height: | Size: 200 KiB |
Before Width: | Height: | Size: 146 KiB |
Before Width: | Height: | Size: 181 KiB |
Before Width: | Height: | Size: 325 KiB |
Before Width: | Height: | Size: 339 KiB |
Before Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 1.1 MiB |
Before Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 549 KiB |
Before Width: | Height: | Size: 77 KiB |
|
@ -1,226 +0,0 @@
|
||||||
# Preconfigured pages
|
|
||||||
|
|
||||||
Don't want to spend time configuring pages from scratch? No problem! Simply copy the config from the ones below.
|
|
||||||
|
|
||||||
Pull requests with your page configurations are welcome!
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
>
|
|
||||||
> Pages must be placed under a top level `pages:` key, you can read more about that [here](configuration.md#pages).
|
|
||||||
|
|
||||||
## Startpage
|
|
||||||
|
|
||||||

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

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

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

|

|
||||||
```yaml
|
```yaml
|
||||||
|
@ -82,17 +62,6 @@ theme:
|
||||||
negative-color: 209 88 54
|
negative-color: 209 88 54
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dracula
|
|
||||||

|
|
||||||
```yaml
|
|
||||||
theme:
|
|
||||||
background-color: 231 15 21
|
|
||||||
primary-color: 265 89 79
|
|
||||||
contrast-multiplier: 1.2
|
|
||||||
positive-color: 135 94 66
|
|
||||||
negative-color: 0 100 67
|
|
||||||
```
|
|
||||||
|
|
||||||
## Light
|
## Light
|
||||||
|
|
||||||
### Catppuccin Latte
|
### Catppuccin Latte
|
||||||
|
|
|
@ -1,57 +0,0 @@
|
||||||
## 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,32 +1,19 @@
|
||||||
module github.com/glanceapp/glance
|
module github.com/glanceapp/glance
|
||||||
|
|
||||||
go 1.24.2
|
go 1.22.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
|
||||||
github.com/mmcdole/gofeed v1.3.0
|
github.com/mmcdole/gofeed v1.3.0
|
||||||
github.com/shirou/gopsutil/v4 v4.25.3
|
golang.org/x/text v0.14.0
|
||||||
github.com/tidwall/gjson v1.18.0
|
|
||||||
golang.org/x/text v0.24.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.10.2 // indirect
|
github.com/PuerkitoBio/goquery v1.9.1 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
github.com/andybalholm/cascadia v1.3.2 // 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/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
|
|
||||||
github.com/mmcdole/goxpp v1.1.1 // indirect
|
github.com/mmcdole/goxpp v1.1.1 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
golang.org/x/net v0.24.0 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
|
||||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
|
||||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
|
||||||
golang.org/x/net v0.39.0 // indirect
|
|
||||||
golang.org/x/sys v0.32.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
106
go.sum
|
@ -1,31 +1,13 @@
|
||||||
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
|
||||||
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
|
||||||
github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
|
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||||
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
|
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||||
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
|
||||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
|
||||||
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
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/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
||||||
github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
|
github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
|
||||||
|
@ -37,111 +19,47 @@ 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
|
|
||||||
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
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/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
|
||||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
|
||||||
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
|
|
||||||
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
|
|
||||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
|
||||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
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-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.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.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.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-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-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.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||||
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/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
|
||||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
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-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-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-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-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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.7.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/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
|
||||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
|
||||||
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-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.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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||||
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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
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/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
|
||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
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=
|
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|
15
internal/assets/files.go
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
package assets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//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")
|
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 |
362
internal/assets/static/main.js
Normal file
|
@ -0,0 +1,362 @@
|
||||||
|
function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
||||||
|
let debounceTimeout;
|
||||||
|
let timesDebounced = 0;
|
||||||
|
|
||||||
|
return function () {
|
||||||
|
if (timesDebounced == maxDebounceTimes) {
|
||||||
|
clearTimeout(debounceTimeout);
|
||||||
|
timesDebounced = 0;
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(debounceTimeout);
|
||||||
|
timesDebounced++;
|
||||||
|
|
||||||
|
debounceTimeout = setTimeout(() => {
|
||||||
|
timesDebounced = 0;
|
||||||
|
callback();
|
||||||
|
}, debounceDelay);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
async function fetchPageContent(pageSlug) {
|
||||||
|
// TODO: handle non 200 status codes/time outs
|
||||||
|
// TODO: add retries
|
||||||
|
const response = await fetch(`/api/pages/${pageSlug}/content/`);
|
||||||
|
const content = await response.text();
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCarousels() {
|
||||||
|
const carouselElements = document.getElementsByClassName("carousel-container");
|
||||||
|
|
||||||
|
if (carouselElements.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < carouselElements.length; i++) {
|
||||||
|
const carousel = carouselElements[i];
|
||||||
|
carousel.classList.add("show-right-cutoff");
|
||||||
|
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
|
||||||
|
|
||||||
|
const determineSideCutoffs = () => {
|
||||||
|
if (itemsContainer.scrollLeft != 0) {
|
||||||
|
carousel.classList.add("show-left-cutoff");
|
||||||
|
} else {
|
||||||
|
carousel.classList.remove("show-left-cutoff");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.ceil(itemsContainer.scrollLeft) + itemsContainer.clientWidth < itemsContainer.scrollWidth) {
|
||||||
|
carousel.classList.add("show-right-cutoff");
|
||||||
|
} else {
|
||||||
|
carousel.classList.remove("show-right-cutoff");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
|
||||||
|
|
||||||
|
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
|
||||||
|
document.addEventListener("resize", determineSideCutoffsRateLimited);
|
||||||
|
|
||||||
|
setTimeout(determineSideCutoffs, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minuteInSeconds = 60;
|
||||||
|
const hourInSeconds = minuteInSeconds * 60;
|
||||||
|
const dayInSeconds = hourInSeconds * 24;
|
||||||
|
const monthInSeconds = dayInSeconds * 30;
|
||||||
|
const yearInSeconds = monthInSeconds * 12;
|
||||||
|
|
||||||
|
function relativeTimeSince(timestamp) {
|
||||||
|
const delta = Math.round((Date.now() / 1000) - timestamp);
|
||||||
|
|
||||||
|
if (delta < minuteInSeconds) {
|
||||||
|
return "1m";
|
||||||
|
}
|
||||||
|
if (delta < hourInSeconds) {
|
||||||
|
return Math.floor(delta / minuteInSeconds) + "m";
|
||||||
|
}
|
||||||
|
if (delta < dayInSeconds) {
|
||||||
|
return Math.floor(delta / hourInSeconds) + "h";
|
||||||
|
}
|
||||||
|
if (delta < monthInSeconds) {
|
||||||
|
return Math.floor(delta / dayInSeconds) + "d";
|
||||||
|
}
|
||||||
|
if (delta < yearInSeconds) {
|
||||||
|
return Math.floor(delta / monthInSeconds) + "mo";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.floor(delta / yearInSeconds) + "y";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRelativeTimeForElements(elements)
|
||||||
|
{
|
||||||
|
for (let i = 0; i < elements.length; i++)
|
||||||
|
{
|
||||||
|
const element = elements[i];
|
||||||
|
const timestamp = element.dataset.dynamicRelativeTime;
|
||||||
|
|
||||||
|
if (timestamp === undefined)
|
||||||
|
continue
|
||||||
|
|
||||||
|
element.innerText = relativeTimeSince(timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDynamicRelativeTime() {
|
||||||
|
const elements = document.querySelectorAll("[data-dynamic-relative-time]");
|
||||||
|
const updateInterval = 60 * 1000;
|
||||||
|
let lastUpdateTime = Date.now();
|
||||||
|
|
||||||
|
updateRelativeTimeForElements(elements);
|
||||||
|
|
||||||
|
const updateElementsAndTimestamp = () => {
|
||||||
|
updateRelativeTimeForElements(elements);
|
||||||
|
lastUpdateTime = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRepeatingUpdate = () => setInterval(updateElementsAndTimestamp, updateInterval);
|
||||||
|
|
||||||
|
if (document.hidden === undefined) {
|
||||||
|
scheduleRepeatingUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout = scheduleRepeatingUpdate();
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = Date.now() - lastUpdateTime;
|
||||||
|
|
||||||
|
if (delta >= updateInterval) {
|
||||||
|
updateElementsAndTimestamp();
|
||||||
|
timeout = scheduleRepeatingUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
updateElementsAndTimestamp();
|
||||||
|
timeout = scheduleRepeatingUpdate();
|
||||||
|
}, updateInterval - delta);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupLazyImages() {
|
||||||
|
const images = document.querySelectorAll("img[loading=lazy]");
|
||||||
|
|
||||||
|
if (images.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageFinishedTransition(image) {
|
||||||
|
image.classList.add("finished-transition");
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
const image = images[i];
|
||||||
|
|
||||||
|
if (image.complete) {
|
||||||
|
image.classList.add("cached");
|
||||||
|
setTimeout(() => imageFinishedTransition(image), 5);
|
||||||
|
} else {
|
||||||
|
// TODO: also handle error event
|
||||||
|
image.addEventListener("load", () => {
|
||||||
|
image.classList.add("loaded");
|
||||||
|
setTimeout(() => imageFinishedTransition(image), 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachExpandToggleButton(collapsibleContainer) {
|
||||||
|
const showMoreText = "Show more";
|
||||||
|
const showLessText = "Show less";
|
||||||
|
|
||||||
|
let expanded = false;
|
||||||
|
const button = document.createElement("button");
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.classList.add("expand-toggle-button-icon");
|
||||||
|
const textNode = document.createTextNode(showMoreText);
|
||||||
|
button.classList.add("expand-toggle-button");
|
||||||
|
button.append(textNode, icon);
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
expanded = !expanded;
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
collapsibleContainer.classList.add("container-expanded");
|
||||||
|
button.classList.add("container-expanded");
|
||||||
|
textNode.nodeValue = showLessText;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const topBefore = button.getClientRects()[0].top;
|
||||||
|
|
||||||
|
collapsibleContainer.classList.remove("container-expanded");
|
||||||
|
button.classList.remove("container-expanded");
|
||||||
|
textNode.nodeValue = showMoreText;
|
||||||
|
|
||||||
|
const topAfter = button.getClientRects()[0].top;
|
||||||
|
|
||||||
|
if (topAfter > 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
window.scrollBy({
|
||||||
|
top: topAfter - topBefore,
|
||||||
|
behavior: "instant"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
collapsibleContainer.after(button);
|
||||||
|
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function setupCollapsibleLists() {
|
||||||
|
const collapsibleLists = document.querySelectorAll(".list.collapsible-container");
|
||||||
|
|
||||||
|
if (collapsibleLists.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < collapsibleLists.length; i++) {
|
||||||
|
const list = collapsibleLists[i];
|
||||||
|
|
||||||
|
if (list.dataset.collapseAfter === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapseAfter = parseInt(list.dataset.collapseAfter);
|
||||||
|
|
||||||
|
if (collapseAfter == -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.children.length <= collapseAfter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachExpandToggleButton(list);
|
||||||
|
|
||||||
|
for (let c = collapseAfter; c < list.children.length; c++) {
|
||||||
|
const child = list.children[c];
|
||||||
|
child.classList.add("collapsible-item");
|
||||||
|
child.style.animationDelay = ((c - collapseAfter) * 20).toString() + "ms";
|
||||||
|
}
|
||||||
|
|
||||||
|
list.classList.add("ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCollapsibleGrids() {
|
||||||
|
const collapsibleGridElements = document.querySelectorAll(".cards-grid.collapsible-container");
|
||||||
|
|
||||||
|
if (collapsibleGridElements.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < collapsibleGridElements.length; i++) {
|
||||||
|
const gridElement = collapsibleGridElements[i];
|
||||||
|
|
||||||
|
if (gridElement.dataset.collapseAfterRows === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapseAfterRows = parseInt(gridElement.dataset.collapseAfterRows);
|
||||||
|
|
||||||
|
if (collapseAfterRows == -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCardsPerRow = () => {
|
||||||
|
return parseInt(getComputedStyle(gridElement).getPropertyValue('--cards-per-row'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const button = attachExpandToggleButton(gridElement);
|
||||||
|
|
||||||
|
let cardsPerRow = 2;
|
||||||
|
|
||||||
|
const resolveCollapsibleItems = () => {
|
||||||
|
const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
|
||||||
|
|
||||||
|
if (hideItemsAfterIndex >= gridElement.children.length) {
|
||||||
|
button.style.display = "none";
|
||||||
|
} else {
|
||||||
|
button.style.removeProperty("display");
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < gridElement.children.length; i++) {
|
||||||
|
const child = gridElement.children[i];
|
||||||
|
|
||||||
|
if (i >= hideItemsAfterIndex) {
|
||||||
|
child.classList.add("collapsible-item");
|
||||||
|
child.style.animationDelay = (row * 40).toString() + "ms";
|
||||||
|
|
||||||
|
if (i % cardsPerRow + 1 == cardsPerRow) {
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
child.classList.remove("collapsible-item");
|
||||||
|
child.style.removeProperty("animation-delay");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
cardsPerRow = getCardsPerRow();
|
||||||
|
resolveCollapsibleItems();
|
||||||
|
gridElement.classList.add("ready");
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
const newCardsPerRow = getCardsPerRow();
|
||||||
|
|
||||||
|
if (cardsPerRow == newCardsPerRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cardsPerRow = newCardsPerRow;
|
||||||
|
resolveCollapsibleItems();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupPage() {
|
||||||
|
const pageElement = document.getElementById("page");
|
||||||
|
const pageContentElement = document.getElementById("page-content");
|
||||||
|
const pageContent = await fetchPageContent(pageData.slug);
|
||||||
|
|
||||||
|
pageContentElement.innerHTML = pageContent;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.classList.add("animate-element-transition");
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setupLazyImages();
|
||||||
|
setupCarousels();
|
||||||
|
setupCollapsibleLists();
|
||||||
|
setupCollapsibleGrids();
|
||||||
|
setupDynamicRelativeTime();
|
||||||
|
} finally {
|
||||||
|
pageElement.classList.add("content-ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", setupPage);
|
||||||
|
} else {
|
||||||
|
setupPage();
|
||||||
|
}
|
|
@ -1,14 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "Glance",
|
"name": "Glance",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#151519",
|
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "app-icon.png",
|
"src": "/static/app-icon.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "512x512"
|
"sizes": "512x512"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
115
internal/assets/templates.go
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
|
||||||
|
VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
|
||||||
|
StocksTemplate = compileTemplate("stocks.html", "widget-base.html")
|
||||||
|
RSSListTemplate = compileTemplate("rss-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")
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
"formatTime": func(t time.Time) string {
|
||||||
|
return t.Format("2006-01-02 15:04:05")
|
||||||
|
},
|
||||||
|
"shouldCollapse": func(i int, collapseAfter int) bool {
|
||||||
|
if collapseAfter < -1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return i >= collapseAfter
|
||||||
|
},
|
||||||
|
"itemAnimationDelay": func(i int, collapseAfter int) string {
|
||||||
|
return fmt.Sprintf("%dms", (i-collapseAfter)*30)
|
||||||
|
},
|
||||||
|
"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
|
||||||
|
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))
|
||||||
|
}
|
37
internal/assets/templates/bookmarks.html
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
{{ if ne .Style "dynamic-columns-experimental" }}
|
||||||
|
<ul class="list list-gap-24 list-with-separator">
|
||||||
|
{{ range .Groups }}
|
||||||
|
<li class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
|
||||||
|
{{ template "group" . }}
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ else }}
|
||||||
|
<div class="dynamic-columns">
|
||||||
|
{{ range .Groups }}
|
||||||
|
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
|
||||||
|
{{ template "group" . }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "group" }}
|
||||||
|
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
|
||||||
|
<ul class="list list-gap-2">
|
||||||
|
{{ range .Links }}
|
||||||
|
<li class="flex items-center gap-10">
|
||||||
|
{{ if ne "" .Icon }}
|
||||||
|
<div class="bookmarks-icon-container">
|
||||||
|
<img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
27
internal/assets/templates/calendar.html
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div>
|
||||||
|
<ul class="list-horizontal-text color-highlight size-h4">
|
||||||
|
<li>Week {{ .Calendar.CurrentWeekNumber }}</li>
|
||||||
|
<li>{{ .Calendar.CurrentYear }}</li>
|
||||||
|
</ul>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap">
|
||||||
|
{{ range .Calendar.Days }}
|
||||||
|
<div class="calendar-day{{ if eq . $.Calendar.CurrentDay }} calendar-day-today{{ end }}">{{ . }}</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
|
@ -3,7 +3,6 @@
|
||||||
<head>
|
<head>
|
||||||
{{ block "document-head-before" . }}{{ end }}
|
{{ block "document-head-before" . }}{{ end }}
|
||||||
<title>{{ block "document-title" . }}{{ end }}</title>
|
<title>{{ block "document-title" . }}{{ end }}</title>
|
||||||
<script>if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');</script>
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
@ -12,11 +11,12 @@
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="Glance">
|
<meta name="apple-mobile-web-app-title" content="Glance">
|
||||||
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
|
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
|
||||||
<link rel="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}">
|
<link rel="apple-touch-icon" sizes="512x512" href="/static/app-icon.png">
|
||||||
<link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
|
<link rel="icon" type="image/png" sizes="50x50" href="/static/favicon.png">
|
||||||
<link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
|
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||||
<script type="module" src="{{ .App.AssetPath "js/main.js" }}"></script>
|
<link rel="stylesheet" href="/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}">
|
||||||
|
<script async src="/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script>
|
||||||
{{ block "document-head-after" . }}{{ end }}
|
{{ block "document-head-after" . }}{{ end }}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
36
internal/assets/templates/forum-posts.html
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
|
{{ range .Posts }}
|
||||||
|
<li>
|
||||||
|
<div class="forum-post-list-item thumbnail-container">
|
||||||
|
{{ if $.ShowThumbnails }}
|
||||||
|
{{ 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">
|
||||||
|
<a href="{{ .DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
|
<li>{{ .CommentCount | formatNumber }} comments</li>
|
||||||
|
{{ if .HasTargetUrl }}
|
||||||
|
<li class="shrink 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 }}
|
53
internal/assets/templates/monitor.html
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
{{ if ne .Style "dynamic-columns-experimental" }}
|
||||||
|
<ul class="list list-gap-20 list-with-separator">
|
||||||
|
{{ range .Sites }}
|
||||||
|
<li class="monitor-site flex items-center gap-15">
|
||||||
|
{{ template "site" . }}
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ else }}
|
||||||
|
<ul class="dynamic-columns">
|
||||||
|
{{ range .Sites }}
|
||||||
|
<div class="flex items-center gap-15">
|
||||||
|
{{ template "site" . }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "site" }}
|
||||||
|
{{ if .IconUrl }}
|
||||||
|
<img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy">
|
||||||
|
{{ end }}
|
||||||
|
<div>
|
||||||
|
<a class="size-h3 color-highlight" href="{{ .Url }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
{{ if not .Status.Error }}
|
||||||
|
<li>{{ .StatusText }}</li>
|
||||||
|
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
|
||||||
|
{{ else if .Status.TimedOut }}
|
||||||
|
<li class="color-negative">Timed Out</li>
|
||||||
|
{{ else }}
|
||||||
|
<li class="color-negative" title="{{ .Status.Error }}">ERROR</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ if eq .StatusStyle "good" }}
|
||||||
|
<div class="monitor-site-status-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
|
||||||
|
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="monitor-site-status-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
|
||||||
|
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
14
internal/assets/templates/page-style-overrides.gotmpl
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<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>
|
67
internal/assets/templates/page.html
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
{{ template "document.html" . }}
|
||||||
|
|
||||||
|
{{ define "document-title" }}{{ .Page.Title }} - Glance{{ end }}
|
||||||
|
|
||||||
|
{{ define "document-head-before" }}
|
||||||
|
<script>
|
||||||
|
const pageData = {
|
||||||
|
slug: "{{ .Page.Slug }}",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "document-root-attrs" }}{{ if .App.Config.Theme.Light }}class="light-scheme"{{ end }}{{ end }}
|
||||||
|
{{ define "document-head-after" }}
|
||||||
|
{{ template "page-style-overrides.gotmpl" . }}
|
||||||
|
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
|
||||||
|
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "navigation-links" }}
|
||||||
|
{{ range .App.Config.Pages }}
|
||||||
|
<a href="/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "document-body" }}
|
||||||
|
<div class="header-container content-bounds">
|
||||||
|
<div class="header flex padding-inline-widget widget-content-frame">
|
||||||
|
<!-- TODO: Replace G with actual logo, first need an actual logo -->
|
||||||
|
<div class="logo">G</div>
|
||||||
|
<div class="nav flex grow">
|
||||||
|
{{ template "navigation-links" . }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-navigation">
|
||||||
|
<div class="mobile-navigation-icons">
|
||||||
|
<a class="mobile-navigation-label" href="#top">↑</a>
|
||||||
|
{{ range $i, $column := .Page.Columns }}
|
||||||
|
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
|
||||||
|
{{ end }}
|
||||||
|
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-navigation-page-links">
|
||||||
|
{{ template "navigation-links" . }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-bounds">
|
||||||
|
<div class="page" id="page">
|
||||||
|
<div class="page-content" id="page-content"></div>
|
||||||
|
<div class="page-loading-container">
|
||||||
|
<!-- TODO: add a bigger/better loading indicator -->
|
||||||
|
<div class="loading-icon"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer flex items-center flex-column">
|
||||||
|
<div>
|
||||||
|
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
||||||
|
</div>
|
||||||
|
<a class="color-primary block margin-top-5 size-h5" href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
|
@ -18,10 +18,10 @@
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<div class="color-highlight size-h5 text-truncate">/r/{{ $.Subreddit }}</div>
|
<div class="color-highlight size-h5 text-truncate">/r/{{ $.Subreddit }}</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<a href="{{ .DiscussionUrl }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text margin-top-7">
|
<ul class="list-horizontal-text margin-top-7">
|
||||||
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li>{{ .Score | formatApproxNumber }} points</li>
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
|
@ -17,10 +17,10 @@
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<div class="color-highlight size-h5 text-truncate">/r/{{ $.Subreddit }}</div>
|
<div class="color-highlight size-h5 text-truncate">/r/{{ $.Subreddit }}</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<a href="{{ .DiscussionUrl }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text margin-top-7">
|
<ul class="list-horizontal-text margin-top-7">
|
||||||
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li>{{ .Score | formatApproxNumber }} points</li>
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
18
internal/assets/templates/releases.html
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
|
{{ range $i, $release := .Releases }}
|
||||||
|
<li>
|
||||||
|
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
<li {{ dynamicRelativeTimeAttrs $release.TimeReleased }}></li>
|
||||||
|
<li>{{ $release.Version }}</li>
|
||||||
|
{{ if gt $release.Downvotes 3 }}
|
||||||
|
<li>{{ $release.Downvotes | formatNumber }} ⚠</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
44
internal/assets/templates/repository.html
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
{{ 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.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 }}
|
|
@ -3,11 +3,10 @@
|
||||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
{{ if gt (len .Items) 0 }}
|
|
||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
|
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
|
||||||
{{ range .Items }}
|
{{ range .Items }}
|
||||||
<div class="card rss-card-2 widget-content-frame thumbnail-parent">
|
<div class="card rss-card-2 widget-content-frame thumbnail-container">
|
||||||
{{ if ne "" .ImageURL }}
|
{{ if ne "" .ImageURL }}
|
||||||
<img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
<img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||||
{{ else }}
|
{{ else }}
|
||||||
|
@ -16,17 +15,14 @@
|
||||||
</svg>
|
</svg>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div class="rss-card-2-content padding-inline-widget">
|
<div class="rss-card-2-content padding-inline-widget">
|
||||||
<a href="{{ .Link }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text flex-nowrap margin-top-5">
|
<ul class="list-horizontal-text flex-nowrap margin-top-5">
|
||||||
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
|
<li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ else }}
|
|
||||||
<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
|
@ -3,11 +3,10 @@
|
||||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
{{ if gt (len .Items) 0 }}
|
|
||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
|
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
|
||||||
{{ range .Items }}
|
{{ range .Items }}
|
||||||
<div class="card widget-content-frame thumbnail-parent">
|
<div class="card widget-content-frame thumbnail-container">
|
||||||
{{ if ne "" .ImageURL }}
|
{{ if ne "" .ImageURL }}
|
||||||
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||||
{{ else }}
|
{{ else }}
|
||||||
|
@ -16,17 +15,14 @@
|
||||||
</svg>
|
</svg>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
|
<div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
|
||||||
<a href="{{ .Link }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
||||||
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
|
<li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ else }}
|
|
||||||
<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
17
internal/assets/templates/rss-list.html
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
|
{{ range .Items }}
|
||||||
|
<li>
|
||||||
|
<a class="size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
|
{{ if gt (len $.FeedRequests) 1 }}
|
||||||
|
<li><a href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
39
internal/assets/templates/stocks.html
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
{{ if ne .Style "dynamic-columns-experimental" }}
|
||||||
|
<ul class="list list-gap-20 list-with-separator">
|
||||||
|
{{ range .Stocks }}
|
||||||
|
<li class="flex items-center gap-15">
|
||||||
|
{{ template "stock" . }}
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ else }}
|
||||||
|
<div class="dynamic-columns">
|
||||||
|
{{ range .Stocks }}
|
||||||
|
<div class="flex items-center gap-15">
|
||||||
|
{{ template "stock" . }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "stock" }}
|
||||||
|
<div class="shrink min-width-0">
|
||||||
|
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
|
||||||
|
<div class="text-truncate">{{ .Name }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="stock-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
|
||||||
|
<svg class="stock-chart shrink-0" viewBox="0 0 100 50">
|
||||||
|
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="stock-values shrink-0">
|
||||||
|
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
|
||||||
|
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
|
@ -4,34 +4,24 @@
|
||||||
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range .Channels }}
|
{{ range .Channels }}
|
||||||
<li>
|
<li>
|
||||||
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
|
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-container">
|
||||||
<div class="twitch-channel-avatar-container"{{ if .IsLive }} data-popover-type="html" data-popover-position="above" data-popover-margin="0.15rem" data-popover-offset="0.2"{{ end }}>
|
<div class="twitch-channel-avatar-container">
|
||||||
{{ if .IsLive }}
|
|
||||||
<div data-popover-html>
|
|
||||||
<img class="twitch-stream-preview" src="https://static-cdn.jtvnw.net/previews-ttv/live_user_{{ .Login }}-440x248.jpg" loading="lazy" alt="">
|
|
||||||
<p class="margin-top-10 color-highlight text-truncate-3-lines">{{ .StreamTitle }}</p>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
{{ if .Exists }}
|
{{ if .Exists }}
|
||||||
<a href="https://twitch.tv/{{ .Login }}" target="_blank" rel="noreferrer">
|
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
|
||||||
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
|
|
||||||
</a>
|
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="min-width-0">
|
<div class="shrink min-width-0">
|
||||||
<a href="https://twitch.tv/{{ .Login }}" class="size-h3{{ if .IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
<a href="https://twitch.tv/{{ .Login }}" class="size-h3{{ if .IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
{{ if .Exists }}
|
{{ if .Exists }}
|
||||||
{{ if .IsLive }}
|
{{ if .IsLive }}
|
||||||
{{ if .Category }}
|
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a>
|
||||||
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a>
|
|
||||||
{{ end }}
|
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
|
<li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
|
||||||
<li>{{ .ViewersCount | formatApproxNumber }} viewers</li>
|
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
|
||||||
</ul>
|
</ul>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<div>Offline</div>
|
<div>Offline</div>
|
|
@ -3,13 +3,13 @@
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range .Categories }}
|
{{ range .Categories }}
|
||||||
<li class="twitch-category thumbnail-parent">
|
<li class="twitch-category thumbnail-container">
|
||||||
<div class="flex gap-10 items-start">
|
<div class="flex gap-10 items-center">
|
||||||
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ .AvatarUrl }}" alt="">
|
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ .AvatarUrl }}" alt="">
|
||||||
<div class="min-width-0">
|
<div class="shrink min-width-0">
|
||||||
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li>{{ .ViewersCount | formatApproxNumber }} viewers</li>
|
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
|
||||||
{{ if .IsNew }}
|
{{ if .IsNew }}
|
||||||
<li class="color-primary">NEW</li>
|
<li class="color-primary">NEW</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
@ -19,7 +19,7 @@
|
||||||
{{ if eq $i 0 }}
|
{{ if eq $i 0 }}
|
||||||
<li class="shrink-0">{{ $tag.Name }}</li>
|
<li class="shrink-0">{{ $tag.Name }}</li>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<li class="text-truncate min-width-0">{{ $tag.Name }}</li>
|
<li class="text-truncate shrink min-width-0">{{ $tag.Name }}</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
|
@ -1,10 +1,10 @@
|
||||||
{{ define "video-card-contents" }}
|
{{ define "video-card-contents" }}
|
||||||
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
||||||
<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
|
<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
|
||||||
<a class="text-truncate-2-lines margin-bottom-auto color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
||||||
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li class="min-width-0">
|
<li class="shrink min-width-0">
|
||||||
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
|
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
|
@ -5,7 +5,7 @@
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<div class="cards-grid collapsible-container" data-collapse-after-rows="{{ .CollapseAfterRows }}">
|
<div class="cards-grid collapsible-container" data-collapse-after-rows="{{ .CollapseAfterRows }}">
|
||||||
{{ range .Videos }}
|
{{ range .Videos }}
|
||||||
<div class="card widget-content-frame thumbnail-parent">
|
<div class="card widget-content-frame thumbnail-container">
|
||||||
{{ template "video-card-contents" . }}
|
{{ template "video-card-contents" . }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
|
@ -6,7 +6,7 @@
|
||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
<div class="cards-horizontal carousel-items-container">
|
<div class="cards-horizontal carousel-items-container">
|
||||||
{{ range .Videos }}
|
{{ range .Videos }}
|
||||||
<div class="card widget-content-frame thumbnail-parent">
|
<div class="card widget-content-frame thumbnail-container">
|
||||||
{{ template "video-card-contents" . }}
|
{{ template "video-card-contents" . }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
29
internal/assets/templates/weather.html
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="size-h2 color-highlight text-center">{{ .Weather.WeatherCodeAsString }}</div>
|
||||||
|
<div class="size-h4 text-center">Feels like {{ .Weather.ApparentTemperature }}°{{ if eq .Units "metric" }}C{{ else }}F{{ end }}</div>
|
||||||
|
|
||||||
|
<div class="weather-columns flex margin-top-15 justify-center">
|
||||||
|
{{ range $i, $column := .Weather.Columns }}
|
||||||
|
<div class="weather-column{{ if eq $i $.Weather.CurrentColumn }} weather-column-current{{ end }}">
|
||||||
|
{{ if $column.HasPrecipitation }}
|
||||||
|
<div class="weather-column-rain"></div>
|
||||||
|
{{ end }}
|
||||||
|
{{ if and (ge $i $.Weather.SunriseColumn) (le $i $.Weather.SunsetColumn ) }}
|
||||||
|
<div class="weather-column-daylight{{ if eq $i $.Weather.SunriseColumn }} weather-column-daylight-sunrise{{ else if eq $i $.Weather.SunsetColumn }} weather-column-daylight-sunset{{ end }}"></div>
|
||||||
|
{{ end }}
|
||||||
|
<div class="weather-column-value{{ if lt $column.Temperature 0 }} weather-column-value-negative{{ end }}">{{ $column.Temperature | absInt }}</div>
|
||||||
|
<div class="weather-bar" style='--weather-bar-height: {{ printf "%.2f" $column.Scale }}'></div>
|
||||||
|
<div class="weather-column-time">{{ index $.TimeLabels $i }}</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if not .HideLocation }}
|
||||||
|
<div class="flex items-center justify-center margin-top-15 gap-7 size-h5">
|
||||||
|
<div class="location-icon"></div>
|
||||||
|
<div class="text-truncate">{{ .Place.Name }},{{ if .ShowAreaName }} {{ .Place.Area }},{{ end }} {{ .Place.Country }}</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
21
internal/assets/templates/widget-base.html
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<div class="widget widget-type-{{ .GetType }}">
|
||||||
|
<div class="widget-header">
|
||||||
|
<div class="uppercase">{{ .Title }}</div>
|
||||||
|
{{ 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>
|
||||||
|
<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>
|
53
internal/feed/calendar.go
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
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()
|
||||||
|
}
|
248
internal/feed/github.go
Normal file
|
@ -0,0 +1,248 @@
|
||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type githubReleaseResponseJson struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
PublishedAt string `json:"published_at"`
|
||||||
|
HtmlUrl string `json:"html_url"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
PreRelease bool `json:"prerelease"`
|
||||||
|
Reactions struct {
|
||||||
|
Downvotes int `json:"-1"`
|
||||||
|
} `json:"reactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGithubTime(t string) time.Time {
|
||||||
|
parsedTime, err := time.Parse("2006-01-02T15:04:05Z", t)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedTime
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchLatestReleasesFromGithub(repositories []string, token string) (AppReleases, error) {
|
||||||
|
appReleases := make(AppReleases, 0, len(repositories))
|
||||||
|
|
||||||
|
if len(repositories) == 0 {
|
||||||
|
return appReleases, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := make([]*http.Request, len(repositories))
|
||||||
|
|
||||||
|
for i, repository := range repositories {
|
||||||
|
request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=10", repository), nil)
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||||
|
}
|
||||||
|
|
||||||
|
requests[i] = request
|
||||||
|
}
|
||||||
|
|
||||||
|
task := decodeJsonFromRequestTask[[]githubReleaseResponseJson](defaultClient)
|
||||||
|
job := newJob(task, requests).withWorkers(15)
|
||||||
|
responses, errs, err := workerPoolDo(job)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var failed int
|
||||||
|
|
||||||
|
for i := range responses {
|
||||||
|
if errs[i] != nil {
|
||||||
|
failed++
|
||||||
|
slog.Error("Failed to fetch or parse github release", "error", errs[i], "url", requests[i].URL)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
releases := responses[i]
|
||||||
|
|
||||||
|
if len(releases) < 1 {
|
||||||
|
failed++
|
||||||
|
slog.Error("No releases found", "repository", repositories[i], "url", requests[i].URL)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var liveRelease *githubReleaseResponseJson
|
||||||
|
|
||||||
|
for i := range releases {
|
||||||
|
release := &releases[i]
|
||||||
|
|
||||||
|
if !release.Draft && !release.PreRelease {
|
||||||
|
liveRelease = release
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if liveRelease == nil {
|
||||||
|
slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
version := liveRelease.TagName
|
||||||
|
|
||||||
|
if version[0] != 'v' {
|
||||||
|
version = "v" + version
|
||||||
|
}
|
||||||
|
|
||||||
|
appReleases = append(appReleases, AppRelease{
|
||||||
|
Name: repositories[i],
|
||||||
|
Version: version,
|
||||||
|
NotesUrl: liveRelease.HtmlUrl,
|
||||||
|
TimeReleased: parseGithubTime(liveRelease.PublishedAt),
|
||||||
|
Downvotes: liveRelease.Reactions.Downvotes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(appReleases) == 0 {
|
||||||
|
return nil, ErrNoContent
|
||||||
|
}
|
||||||
|
|
||||||
|
appReleases.SortByNewest()
|
||||||
|
|
||||||
|
if failed > 0 {
|
||||||
|
return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return appReleases, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GithubTicket struct {
|
||||||
|
Number int
|
||||||
|
CreatedAt time.Time
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RepositoryDetails struct {
|
||||||
|
Name string
|
||||||
|
Stars int
|
||||||
|
Forks int
|
||||||
|
OpenPullRequests int
|
||||||
|
PullRequests []GithubTicket
|
||||||
|
OpenIssues int
|
||||||
|
Issues []GithubTicket
|
||||||
|
}
|
||||||
|
|
||||||
|
type githubRepositoryDetailsResponseJson struct {
|
||||||
|
Name string `json:"full_name"`
|
||||||
|
Stars int `json:"stargazers_count"`
|
||||||
|
Forks int `json:"forks_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type githubTicketResponseJson struct {
|
||||||
|
Count int `json:"total_count"`
|
||||||
|
Tickets []struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int) (RepositoryDetails, error) {
|
||||||
|
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil)
|
||||||
|
issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil)
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
token = fmt.Sprintf("Bearer %s", token)
|
||||||
|
repositoryRequest.Header.Add("Authorization", token)
|
||||||
|
PRsRequest.Header.Add("Authorization", token)
|
||||||
|
issuesRequest.Header.Add("Authorization", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailsResponse githubRepositoryDetailsResponseJson
|
||||||
|
var detailsErr error
|
||||||
|
var PRsResponse githubTicketResponseJson
|
||||||
|
var PRsErr error
|
||||||
|
var issuesResponse githubTicketResponseJson
|
||||||
|
var issuesErr error
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go (func() {
|
||||||
|
defer wg.Done()
|
||||||
|
detailsResponse, detailsErr = decodeJsonFromRequest[githubRepositoryDetailsResponseJson](defaultClient, repositoryRequest)
|
||||||
|
})()
|
||||||
|
|
||||||
|
if maxPRs > 0 {
|
||||||
|
wg.Add(1)
|
||||||
|
go (func() {
|
||||||
|
defer wg.Done()
|
||||||
|
PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, PRsRequest)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxIssues > 0 {
|
||||||
|
wg.Add(1)
|
||||||
|
go (func() {
|
||||||
|
defer wg.Done()
|
||||||
|
issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, issuesRequest)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if detailsErr != nil {
|
||||||
|
return RepositoryDetails{}, fmt.Errorf("%w: could not get repository details: %s", ErrNoContent, detailsErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
details := RepositoryDetails{
|
||||||
|
Name: detailsResponse.Name,
|
||||||
|
Stars: detailsResponse.Stars,
|
||||||
|
Forks: detailsResponse.Forks,
|
||||||
|
PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
|
||||||
|
Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = nil
|
||||||
|
|
||||||
|
if maxPRs > 0 {
|
||||||
|
if PRsErr != nil {
|
||||||
|
err = fmt.Errorf("%w: could not get PRs: %s", ErrPartialContent, PRsErr)
|
||||||
|
} else {
|
||||||
|
details.OpenPullRequests = PRsResponse.Count
|
||||||
|
|
||||||
|
for i := range PRsResponse.Tickets {
|
||||||
|
details.PullRequests = append(details.PullRequests, GithubTicket{
|
||||||
|
Number: PRsResponse.Tickets[i].Number,
|
||||||
|
CreatedAt: parseGithubTime(PRsResponse.Tickets[i].CreatedAt),
|
||||||
|
Title: PRsResponse.Tickets[i].Title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxIssues > 0 {
|
||||||
|
if issuesErr != nil {
|
||||||
|
// TODO: fix, overwriting the previous error
|
||||||
|
err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, issuesErr)
|
||||||
|
} else {
|
||||||
|
details.OpenIssues = issuesResponse.Count
|
||||||
|
|
||||||
|
for i := range issuesResponse.Tickets {
|
||||||
|
details.Issues = append(details.Issues, GithubTicket{
|
||||||
|
Number: issuesResponse.Tickets[i].Number,
|
||||||
|
CreatedAt: parseGithubTime(issuesResponse.Tickets[i].CreatedAt),
|
||||||
|
Title: issuesResponse.Tickets[i].Title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return details, err
|
||||||
|
}
|
98
internal/feed/hacker-news.go
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
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)
|
||||||
|
}
|
51
internal/feed/monitor.go
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SiteStatus struct {
|
||||||
|
Code int
|
||||||
|
TimedOut bool
|
||||||
|
ResponseTime time.Duration
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
||||||
|
defer cancel()
|
||||||
|
request = request.WithContext(ctx)
|
||||||
|
start := time.Now()
|
||||||
|
response, err := http.DefaultClient.Do(request)
|
||||||
|
took := time.Since(start)
|
||||||
|
status := SiteStatus{ResponseTime: took}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
status.TimedOut = true
|
||||||
|
}
|
||||||
|
|
||||||
|
status.Error = err
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
status.Code = response.StatusCode
|
||||||
|
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchStatusesForRequests(requests []*http.Request) ([]SiteStatus, error) {
|
||||||
|
job := newJob(getSiteStatusTask, requests).withWorkers(20)
|
||||||
|
results, _, err := workerPoolDo(job)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
|
@ -1,10 +1,7 @@
|
||||||
package glance
|
package feed
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
@ -15,94 +12,11 @@ import (
|
||||||
_ "time/tzdata"
|
_ "time/tzdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
var weatherWidgetTemplate = mustParseTemplate("weather.html", "widget-base.html")
|
type PlacesResponseJson struct {
|
||||||
|
Results []PlaceJson
|
||||||
type weatherWidget struct {
|
|
||||||
widgetBase `yaml:",inline"`
|
|
||||||
Location string `yaml:"location"`
|
|
||||||
ShowAreaName bool `yaml:"show-area-name"`
|
|
||||||
HideLocation bool `yaml:"hide-location"`
|
|
||||||
HourFormat string `yaml:"hour-format"`
|
|
||||||
Units string `yaml:"units"`
|
|
||||||
Place *openMeteoPlaceResponseJson `yaml:"-"`
|
|
||||||
Weather *weather `yaml:"-"`
|
|
||||||
TimeLabels [12]string `yaml:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var timeLabels12h = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"}
|
type PlaceJson struct {
|
||||||
var timeLabels24h = [12]string{"02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00", "22:00", "00:00"}
|
|
||||||
|
|
||||||
func (widget *weatherWidget) initialize() error {
|
|
||||||
widget.withTitle("Weather").withCacheOnTheHour()
|
|
||||||
|
|
||||||
if widget.Location == "" {
|
|
||||||
return fmt.Errorf("location is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if widget.HourFormat == "" || widget.HourFormat == "12h" {
|
|
||||||
widget.TimeLabels = timeLabels12h
|
|
||||||
} else if widget.HourFormat == "24h" {
|
|
||||||
widget.TimeLabels = timeLabels24h
|
|
||||||
} else {
|
|
||||||
return errors.New("hour-format must be either 12h or 24h")
|
|
||||||
}
|
|
||||||
|
|
||||||
if widget.Units == "" {
|
|
||||||
widget.Units = "metric"
|
|
||||||
} else if widget.Units != "metric" && widget.Units != "imperial" {
|
|
||||||
return errors.New("units must be either metric or imperial")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (widget *weatherWidget) update(ctx context.Context) {
|
|
||||||
if widget.Place == nil {
|
|
||||||
place, err := fetchOpenMeteoPlaceFromName(widget.Location)
|
|
||||||
if err != nil {
|
|
||||||
widget.withError(err).scheduleEarlyUpdate()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
widget.Place = place
|
|
||||||
}
|
|
||||||
|
|
||||||
weather, err := fetchWeatherForOpenMeteoPlace(widget.Place, widget.Units)
|
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
widget.Weather = weather
|
|
||||||
}
|
|
||||||
|
|
||||||
func (widget *weatherWidget) Render() template.HTML {
|
|
||||||
return widget.renderTemplate(widget, weatherWidgetTemplate)
|
|
||||||
}
|
|
||||||
|
|
||||||
type weather struct {
|
|
||||||
Temperature int
|
|
||||||
ApparentTemperature int
|
|
||||||
WeatherCode int
|
|
||||||
CurrentColumn int
|
|
||||||
SunriseColumn int
|
|
||||||
SunsetColumn int
|
|
||||||
Columns []weatherColumn
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *weather) WeatherCodeAsString() string {
|
|
||||||
if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok {
|
|
||||||
return weatherCode
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type openMeteoPlacesResponseJson struct {
|
|
||||||
Results []openMeteoPlaceResponseJson
|
|
||||||
}
|
|
||||||
|
|
||||||
type openMeteoPlaceResponseJson struct {
|
|
||||||
Name string
|
Name string
|
||||||
Area string `json:"admin1"`
|
Area string `json:"admin1"`
|
||||||
Latitude float64
|
Latitude float64
|
||||||
|
@ -112,7 +26,7 @@ type openMeteoPlaceResponseJson struct {
|
||||||
location *time.Location
|
location *time.Location
|
||||||
}
|
}
|
||||||
|
|
||||||
type openMeteoWeatherResponseJson struct {
|
type WeatherResponseJson struct {
|
||||||
Daily struct {
|
Daily struct {
|
||||||
Sunrise []int64 `json:"sunrise"`
|
Sunrise []int64 `json:"sunrise"`
|
||||||
Sunset []int64 `json:"sunset"`
|
Sunset []int64 `json:"sunset"`
|
||||||
|
@ -168,20 +82,21 @@ func parsePlaceName(name string) (string, string) {
|
||||||
return parts[0] + ", " + expandCountryAbbreviations(parts[2]), strings.TrimSpace(parts[1])
|
return parts[0] + ", " + expandCountryAbbreviations(parts[2]), strings.TrimSpace(parts[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, error) {
|
func FetchPlaceFromName(location string) (*PlaceJson, error) {
|
||||||
location, area := parsePlaceName(location)
|
location, area := parsePlaceName(location)
|
||||||
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=10&language=en&format=json", url.QueryEscape(location))
|
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=10&language=en&format=json", url.QueryEscape(location))
|
||||||
request, _ := http.NewRequest("GET", requestUrl, nil)
|
request, _ := http.NewRequest("GET", requestUrl, nil)
|
||||||
responseJson, err := decodeJsonFromRequest[openMeteoPlacesResponseJson](defaultHTTPClient, request)
|
responseJson, err := decodeJsonFromRequest[PlacesResponseJson](defaultClient, request)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetching places data: %v", err)
|
return nil, fmt.Errorf("could not fetch places data: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(responseJson.Results) == 0 {
|
if len(responseJson.Results) == 0 {
|
||||||
return nil, fmt.Errorf("no places found for %s", location)
|
return nil, fmt.Errorf("no places found for %s", location)
|
||||||
}
|
}
|
||||||
|
|
||||||
var place *openMeteoPlaceResponseJson
|
var place *PlaceJson
|
||||||
|
|
||||||
if area != "" {
|
if area != "" {
|
||||||
area = strings.ToLower(area)
|
area = strings.ToLower(area)
|
||||||
|
@ -201,8 +116,9 @@ func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson,
|
||||||
}
|
}
|
||||||
|
|
||||||
loc, err := time.LoadLocation(place.Timezone)
|
loc, err := time.LoadLocation(place.Timezone)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("loading location: %v", err)
|
return nil, fmt.Errorf("could not load location: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
place.location = loc
|
place.location = loc
|
||||||
|
@ -210,7 +126,12 @@ func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson,
|
||||||
return place, nil
|
return place, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units string) (*weather, error) {
|
func barIndexFromHour(h int) int {
|
||||||
|
return h / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: bunch of spaget, refactor
|
||||||
|
func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) {
|
||||||
query := url.Values{}
|
query := url.Values{}
|
||||||
var temperatureUnit string
|
var temperatureUnit string
|
||||||
|
|
||||||
|
@ -232,16 +153,17 @@ func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units stri
|
||||||
|
|
||||||
requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode()
|
requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode()
|
||||||
request, _ := http.NewRequest("GET", requestUrl, nil)
|
request, _ := http.NewRequest("GET", requestUrl, nil)
|
||||||
responseJson, err := decodeJsonFromRequest[openMeteoWeatherResponseJson](defaultHTTPClient, request)
|
responseJson, err := decodeJsonFromRequest[WeatherResponseJson](defaultClient, request)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", errNoContent, err)
|
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().In(place.location)
|
now := time.Now().In(place.location)
|
||||||
bars := make([]weatherColumn, 0, 24)
|
bars := make([]weatherColumn, 0, 24)
|
||||||
currentBar := now.Hour() / 2
|
currentBar := barIndexFromHour(now.Hour())
|
||||||
sunriseBar := (time.Unix(int64(responseJson.Daily.Sunrise[0]), 0).In(place.location).Hour()) / 2
|
sunriseBar := barIndexFromHour(time.Unix(int64(responseJson.Daily.Sunrise[0]), 0).In(place.location).Hour())
|
||||||
sunsetBar := (time.Unix(int64(responseJson.Daily.Sunset[0]), 0).In(place.location).Hour() - 1) / 2
|
sunsetBar := barIndexFromHour(time.Unix(int64(responseJson.Daily.Sunset[0]), 0).In(place.location).Hour()) - 1
|
||||||
|
|
||||||
if sunsetBar < 0 {
|
if sunsetBar < 0 {
|
||||||
sunsetBar = 0
|
sunsetBar = 0
|
||||||
|
@ -267,23 +189,16 @@ func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units stri
|
||||||
minT := slices.Min(temperatures)
|
minT := slices.Min(temperatures)
|
||||||
maxT := slices.Max(temperatures)
|
maxT := slices.Max(temperatures)
|
||||||
|
|
||||||
temperaturesRange := float64(maxT - minT)
|
|
||||||
|
|
||||||
for i := 0; i < 12; i++ {
|
for i := 0; i < 12; i++ {
|
||||||
bars = append(bars, weatherColumn{
|
bars = append(bars, weatherColumn{
|
||||||
Temperature: temperatures[i],
|
Temperature: temperatures[i],
|
||||||
|
Scale: float64(temperatures[i]-minT) / float64(maxT-minT),
|
||||||
HasPrecipitation: precipitations[i],
|
HasPrecipitation: precipitations[i],
|
||||||
})
|
})
|
||||||
|
|
||||||
if temperaturesRange > 0 {
|
|
||||||
bars[i].Scale = float64(temperatures[i]-minT) / temperaturesRange
|
|
||||||
} else {
|
|
||||||
bars[i].Scale = 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &weather{
|
return &Weather{
|
||||||
Temperature: int(responseJson.Current.Temperature),
|
Temperature: int(responseJson.Current.Temperature),
|
||||||
ApparentTemperature: int(responseJson.Current.ApparentTemperature),
|
ApparentTemperature: int(responseJson.Current.ApparentTemperature),
|
||||||
WeatherCode: responseJson.Current.WeatherCode,
|
WeatherCode: responseJson.Current.WeatherCode,
|
||||||
|
@ -293,34 +208,3 @@ func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units stri
|
||||||
Columns: bars,
|
Columns: bars,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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",
|
|
||||||
}
|
|
211
internal/feed/primitives.go
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 Stock struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Symbol string `yaml:"symbol"`
|
||||||
|
ChartLink string `yaml:"chart-link"`
|
||||||
|
SymbolLink string `yaml:"symbol-link"`
|
||||||
|
Currency string `yaml:"-"`
|
||||||
|
Price float64 `yaml:"-"`
|
||||||
|
PercentChange float64 `yaml:"-"`
|
||||||
|
SvgChartPoints string `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stocks []Stock
|
||||||
|
|
||||||
|
func (t Stocks) 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
|
||||||
|
}
|
114
internal/feed/reddit.go
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
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"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"children"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string) (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 = strings.ReplaceAll(commentsUrlTemplate, "{SUBREDDIT}", subreddit)
|
||||||
|
commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-ID}", post.Id)
|
||||||
|
commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-PATH}", strings.TrimLeft(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
|
||||||
|
}
|
||||||
|
|
||||||
|
posts = append(posts, forumPost)
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts, nil
|
||||||
|
}
|