├── .dockerignore
├── .github
├── CODE_OF_CONDUCT.md
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ ├── config.yml
│ └── feature_request.yml
├── PULL_REQUEST_TEMPLATE.md
├── SECURITY.md
└── workflows
│ └── release.yaml
├── .gitignore
├── .goreleaser.yaml
├── Dockerfile
├── Dockerfile.goreleaser
├── LICENSE
├── README.md
├── docs
├── configuration.md
├── custom-api.md
├── extensions.md
├── glance.yml
├── images
│ ├── bookmarks-widget-preview.png
│ ├── calendar-legacy-widget-preview.png
│ ├── calendar-widget-preview.png
│ ├── change-detection-widget-preview.png
│ ├── clock-widget-preview.png
│ ├── column-configuration-1.png
│ ├── column-configuration-2.png
│ ├── column-configuration-3.png
│ ├── contrast-multiplier-example.png
│ ├── custom-api-preview-1.png
│ ├── custom-api-preview-2.png
│ ├── custom-api-preview-3.png
│ ├── dns-stats-widget-preview.png
│ ├── docker-container-parent.png
│ ├── docker-container-parent2.png
│ ├── docker-containers-preview.png
│ ├── docker-widget-preview.png
│ ├── extension-html-reusing-existing-features-preview.png
│ ├── extension-overview.png
│ ├── gaming-page-preview.png
│ ├── group-widget-preview.png
│ ├── hacker-news-widget-preview.png
│ ├── head-widgets-preview.png
│ ├── lobsters-widget-preview.png
│ ├── markets-page-preview.png
│ ├── markets-widget-preview.png
│ ├── mobile-header-preview.png
│ ├── mobile-preview.png
│ ├── monitor-widget-compact-preview.png
│ ├── monitor-widget-preview.png
│ ├── pages-and-columns-illustration.png
│ ├── preconfigured-page-preview.png
│ ├── readme-main-image.png
│ ├── reddit-field-search.png
│ ├── reddit-widget-horizontal-cards-preview.png
│ ├── reddit-widget-preview.png
│ ├── reddit-widget-vertical-cards-preview.png
│ ├── reddit-widget-vertical-list-thumbnails.png
│ ├── releases-widget-preview.png
│ ├── reorder-todo-tasks-prevew.gif
│ ├── repository-preview.png
│ ├── rss-feed-horizontal-cards-preview.png
│ ├── rss-feed-vertical-list-preview.png
│ ├── rss-widget-detailed-list-preview.png
│ ├── rss-widget-horizontal-cards-2-preview.png
│ ├── search-widget-bangs-preview.png
│ ├── search-widget-preview.png
│ ├── server-stats-flame-icon.png
│ ├── server-stats-preview.gif
│ ├── split-column-widget-3-columns.png
│ ├── split-column-widget-4-columns.png
│ ├── split-column-widget-masonry.png
│ ├── split-column-widget-preview.png
│ ├── startpage-preview.png
│ ├── themes-example.png
│ ├── themes
│ │ ├── camouflage.png
│ │ ├── catppuccin-frappe.png
│ │ ├── catppuccin-latte.png
│ │ ├── catppuccin-macchiato.png
│ │ ├── catppuccin-mocha.png
│ │ ├── dracula.png
│ │ ├── gruvbox.png
│ │ ├── kanagawa-dark.png
│ │ ├── peachy.png
│ │ ├── teal-city.png
│ │ ├── tucan.png
│ │ └── zebra.png
│ ├── todo-widget-preview.png
│ ├── twitch-channels-widget-preview.png
│ ├── twitch-top-games-widget-preview.png
│ ├── videos-channel-description-example.png
│ ├── videos-copy-channel-id-example.png
│ ├── videos-widget-grid-cards-preview.png
│ ├── videos-widget-preview.png
│ ├── videos-widget-vertical-list-preview.png
│ └── weather-widget-preview.png
├── logo.png
├── preconfigured-pages.md
├── themes.md
└── v0.7.0-upgrade.md
├── go.mod
├── go.sum
├── internal
└── glance
│ ├── auth.go
│ ├── auth_test.go
│ ├── cli.go
│ ├── config-fields.go
│ ├── config.go
│ ├── diagnose.go
│ ├── embed.go
│ ├── glance.go
│ ├── main.go
│ ├── static
│ ├── app-icon.png
│ ├── css
│ │ ├── forum-posts.css
│ │ ├── login.css
│ │ ├── main.css
│ │ ├── mobile.css
│ │ ├── popover.css
│ │ ├── site.css
│ │ ├── utils.css
│ │ ├── widget-bookmarks.css
│ │ ├── widget-calendar.css
│ │ ├── widget-clock.css
│ │ ├── widget-dns-stats.css
│ │ ├── widget-docker-containers.css
│ │ ├── widget-group.css
│ │ ├── widget-markets.css
│ │ ├── widget-monitor.css
│ │ ├── widget-reddit.css
│ │ ├── widget-releases.css
│ │ ├── widget-rss.css
│ │ ├── widget-search.css
│ │ ├── widget-server-stats.css
│ │ ├── widget-todo.css
│ │ ├── widget-twitch.css
│ │ ├── widget-videos.css
│ │ ├── widget-weather.css
│ │ └── widgets.css
│ ├── favicon.png
│ ├── favicon.svg
│ ├── fonts
│ │ └── JetBrainsMono-Regular.woff2
│ ├── icons
│ │ ├── codeberg.svg
│ │ ├── dockerhub.svg
│ │ ├── github.svg
│ │ └── gitlab.svg
│ └── js
│ │ ├── animations.js
│ │ ├── calendar.js
│ │ ├── login.js
│ │ ├── masonry.js
│ │ ├── page.js
│ │ ├── popover.js
│ │ ├── templating.js
│ │ ├── todo.js
│ │ └── utils.js
│ ├── templates.go
│ ├── templates
│ ├── bookmarks.html
│ ├── calendar.html
│ ├── change-detection.html
│ ├── clock.html
│ ├── custom-api.html
│ ├── dns-stats.html
│ ├── docker-containers.html
│ ├── document.html
│ ├── extension.html
│ ├── footer.html
│ ├── forum-posts.html
│ ├── group.html
│ ├── iframe.html
│ ├── login.html
│ ├── manifest.json
│ ├── markets.html
│ ├── monitor-compact.html
│ ├── monitor.html
│ ├── old-calendar.html
│ ├── page-content.html
│ ├── page.html
│ ├── reddit-horizontal-cards.html
│ ├── reddit-vertical-cards.html
│ ├── releases.html
│ ├── repository.html
│ ├── rss-detailed-list.html
│ ├── rss-horizontal-cards-2.html
│ ├── rss-horizontal-cards.html
│ ├── rss-list.html
│ ├── search.html
│ ├── server-stats.html
│ ├── split-column.html
│ ├── theme-preset-preview.html
│ ├── theme-style.gotmpl
│ ├── todo.html
│ ├── twitch-channels.html
│ ├── twitch-games-list.html
│ ├── v0.7-update-notice-page.html
│ ├── video-card-contents.html
│ ├── videos-grid.html
│ ├── videos-vertical-list.html
│ ├── videos.html
│ ├── weather.html
│ └── widget-base.html
│ ├── theme.go
│ ├── utils.go
│ ├── widget-bookmarks.go
│ ├── widget-calendar.go
│ ├── widget-changedetection.go
│ ├── widget-clock.go
│ ├── widget-container.go
│ ├── widget-custom-api.go
│ ├── widget-dns-stats.go
│ ├── widget-docker-containers.go
│ ├── widget-extension.go
│ ├── widget-group.go
│ ├── widget-hacker-news.go
│ ├── widget-html.go
│ ├── widget-iframe.go
│ ├── widget-lobsters.go
│ ├── widget-markets.go
│ ├── widget-monitor.go
│ ├── widget-old-calendar.go
│ ├── widget-reddit.go
│ ├── widget-releases.go
│ ├── widget-repository.go
│ ├── widget-rss.go
│ ├── widget-search.go
│ ├── widget-server-stats.go
│ ├── widget-shared.go
│ ├── widget-split-column.go
│ ├── widget-todo.go
│ ├── widget-twitch-channels.go
│ ├── widget-twitch-top-games.go
│ ├── widget-utils.go
│ ├── widget-videos.go
│ ├── widget-weather.go
│ └── widget.go
├── main.go
└── pkg
└── sysinfo
└── sysinfo.go
/.dockerignore:
--------------------------------------------------------------------------------
1 | # https://docs.docker.com/build/building/context/#dockerignore-files
2 | # Ignore all files by default
3 | *
4 |
5 | # Only add necessary files to the Docker build context (Dockerfiles are always included implicitly)
6 | !/build/
7 | !/internal/
8 | !/pkg/
9 | !/go.mod
10 | !/go.sum
11 | !main.go
12 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [glanceapp]
2 | patreon: glanceapp
3 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: Bug report
2 | description: Let us know if something isn't working as expected
3 | labels: ["bug report"]
4 | body:
5 | - type: markdown
6 | attributes:
7 | value: |
8 | > [!NOTE]
9 | >
10 | > Do not prefix your title with "[BUG]", "[Bug report]", etc., a label will be added automatically.
11 |
12 | 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.
13 |
14 | Please include only the information you think is relevant to the bug:
15 |
16 | * How did you install Glance? (Docker container, manual binary install, etc)
17 | * Which version of Glance are you using?
18 | * Include the relevant parts of your `glance.yml` if applicable (widget, data source, properties used, etc)
19 | * Include any relevant logs or screenshots if applicable
20 | * Is the issue specific to a certain browser or OS?
21 | * Steps to reliably reproduce the issue
22 | * Are you hosting Glance on a VPS?
23 | * Anything else you think might be relevant
24 |
25 | **No need to copy the above list into your description, it's just a guide to help you provide the most useful information.**
26 |
27 | - type: textarea
28 | id: description
29 | validations:
30 | required: true
31 | attributes:
32 | label: Description
33 |
34 | - type: markdown
35 | attributes:
36 | value: |
37 | Thank you for taking the time to submit a bug report.
38 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: Discussions
4 | url: https://github.com/glanceapp/glance/discussions
5 | about: For help, feedback, guides, resources and more
6 | - name: Discord
7 | url: https://discord.com/invite/7KQ7Xa9kJd
8 | about: Much like the discussions but more chatty
9 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | name: Feature request
2 | description: Share your ideas for new features or improvements
3 | labels: ["feature request"]
4 | body:
5 | - type: markdown
6 | attributes:
7 | value: |
8 | > [!NOTE]
9 | >
10 | > Do not prefix your title with "[REQUEST]", "[Feature request]", etc., a label will be added automatically.
11 |
12 | Please provide a detailed description of what the feature would do and what it would look like:
13 |
14 | * What problem would this feature solve?
15 | * Are there any potential downsides to this feature?
16 | * If applicable, what would the configuration for this feature look like?
17 | * Are there any existing examples of this feature in other software?
18 | * If applicable, include any external documentation required to implement this feature
19 | * Anything else you think might be relevant
20 |
21 | **No need to copy the above list into your description, it's just a guide to help you provide the most useful information.**
22 |
23 | - type: textarea
24 | id: description
25 | validations:
26 | required: true
27 | attributes:
28 | label: Description
29 |
30 | - type: markdown
31 | attributes:
32 | value: |
33 | Thank you for taking the time to submit your idea.
34 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | <!-- 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 -->
2 |
--------------------------------------------------------------------------------
/.github/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | Security updates will be applied to the latest as well as previous minor version release depending on severity and if applicable.
6 |
7 | ## Reporting a Vulnerability
8 |
9 | Please report any suspected security vulnerabilities to [glanceapp@duck.com](mailto:glanceapp@duck.com) and do not disclose them publicly. You should receive a response within a few days and if confirmed the issue will be resolved as soon as possible.
10 |
--------------------------------------------------------------------------------
/.github/workflows/release.yaml:
--------------------------------------------------------------------------------
1 | name: Create release
2 |
3 | permissions:
4 | contents: write
5 |
6 | on:
7 | push:
8 | tags:
9 | - 'v*'
10 |
11 | jobs:
12 | release:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout the target Git reference
16 | uses: actions/checkout@v4
17 | with:
18 | fetch-depth: 0
19 |
20 | - name: Log in to Docker Hub
21 | uses: docker/login-action@v3
22 | with:
23 | username: ${{ secrets.DOCKERHUB_USERNAME }}
24 | password: ${{ secrets.DOCKERHUB_TOKEN }}
25 |
26 | - name: Set up Golang
27 | uses: actions/setup-go@v5
28 | with:
29 | go-version-file: go.mod
30 |
31 | - name: Set up Docker buildx
32 | uses: docker/setup-buildx-action@v3
33 |
34 | - name: Run GoReleaser
35 | uses: goreleaser/goreleaser-action@v5
36 | env:
37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38 | with:
39 | args: release
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /assets
2 | /build
3 | /playground
4 | /.idea
5 | /glance*.yml
6 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | project_name: glanceapp/glance
2 |
3 | checksum:
4 | disable: true
5 |
6 | builds:
7 | - binary: glance
8 | env:
9 | - CGO_ENABLED=0
10 | goos:
11 | - linux
12 | - openbsd
13 | - freebsd
14 | - windows
15 | - darwin
16 | goarch:
17 | - amd64
18 | - arm64
19 | - arm
20 | - 386
21 | goarm:
22 | - 7
23 | ldflags:
24 | - -s -w -X github.com/glanceapp/glance/internal/glance.buildVersion={{ .Tag }}
25 |
26 | archives:
27 | -
28 | name_template: "glance-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}"
29 | files:
30 | - nothing*
31 | format_overrides:
32 | - goos: windows
33 | format: zip
34 |
35 | dockers:
36 | - image_templates:
37 | - &amd64_image "{{ .ProjectName }}:{{ .Tag }}-amd64"
38 | build_flag_templates:
39 | - --platform=linux/amd64
40 | goarch: amd64
41 | use: buildx
42 | dockerfile: Dockerfile.goreleaser
43 |
44 | - image_templates:
45 | - &arm64v8_image "{{ .ProjectName }}:{{ .Tag }}-arm64"
46 | build_flag_templates:
47 | - --platform=linux/arm64
48 | goarch: arm64
49 | use: buildx
50 | dockerfile: Dockerfile.goreleaser
51 |
52 | - image_templates:
53 | - &armv7_image "{{ .ProjectName }}:{{ .Tag }}-armv7"
54 | build_flag_templates:
55 | - --platform=linux/arm/v7
56 | goarch: arm
57 | goarm: 7
58 | use: buildx
59 | dockerfile: Dockerfile.goreleaser
60 |
61 | docker_manifests:
62 | - name_template: "{{ .ProjectName }}:{{ .Tag }}"
63 | image_templates: &multiarch_images
64 | - *amd64_image
65 | - *arm64v8_image
66 | - *armv7_image
67 | - name_template: "{{ .ProjectName }}:latest"
68 | skip_push: auto
69 | image_templates: *multiarch_images
70 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.24.3-alpine3.21 AS builder
2 |
3 | WORKDIR /app
4 | COPY . /app
5 | RUN CGO_ENABLED=0 go build .
6 |
7 | FROM alpine:3.21
8 |
9 | WORKDIR /app
10 | COPY --from=builder /app/glance .
11 |
12 | EXPOSE 8080/tcp
13 | ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
14 |
--------------------------------------------------------------------------------
/Dockerfile.goreleaser:
--------------------------------------------------------------------------------
1 | FROM alpine:3.21
2 |
3 | WORKDIR /app
4 | COPY glance .
5 |
6 | EXPOSE 8080/tcp
7 | ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
8 |
--------------------------------------------------------------------------------
/docs/glance.yml:
--------------------------------------------------------------------------------
1 | pages:
2 | - name: Home
3 | # Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look
4 | # hide-desktop-navigation: true
5 | columns:
6 | - size: small
7 | widgets:
8 | - type: calendar
9 | first-day-of-week: monday
10 |
11 | - type: rss
12 | limit: 10
13 | collapse-after: 3
14 | cache: 12h
15 | feeds:
16 | - url: https://selfh.st/rss/
17 | title: selfh.st
18 | limit: 4
19 | - url: https://ciechanow.ski/atom.xml
20 | - url: https://www.joshwcomeau.com/rss.xml
21 | title: Josh Comeau
22 | - url: https://samwho.dev/rss.xml
23 | - url: https://ishadeed.com/feed.xml
24 | title: Ahmad Shadeed
25 |
26 | - type: twitch-channels
27 | channels:
28 | - theprimeagen
29 | - j_blow
30 | - piratesoftware
31 | - cohhcarnage
32 | - christitustech
33 | - EJ_SA
34 |
35 | - size: full
36 | widgets:
37 | - type: group
38 | widgets:
39 | - type: hacker-news
40 | - type: lobsters
41 |
42 | - type: videos
43 | channels:
44 | - UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
45 | - UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
46 | - UCsBjURrPoezykLs9EqgamOA # Fireship
47 | - UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
48 | - UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
49 |
50 | - type: group
51 | widgets:
52 | - type: reddit
53 | subreddit: technology
54 | show-thumbnails: true
55 | - type: reddit
56 | subreddit: selfhosted
57 | show-thumbnails: true
58 |
59 | - size: small
60 | widgets:
61 | - type: weather
62 | location: London, United Kingdom
63 | units: metric # alternatively "imperial"
64 | hour-format: 12h # alternatively "24h"
65 | # Optionally hide the location from being displayed in the widget
66 | # hide-location: true
67 |
68 | - type: markets
69 | markets:
70 | - symbol: SPY
71 | name: S&P 500
72 | - symbol: BTC-USD
73 | name: Bitcoin
74 | - symbol: NVDA
75 | name: NVIDIA
76 | - symbol: AAPL
77 | name: Apple
78 | - symbol: MSFT
79 | name: Microsoft
80 |
81 | - type: releases
82 | cache: 1d
83 | # Without authentication the Github API allows for up to 60 requests per hour. You can create a
84 | # read-only token from your Github account settings and use it here to increase the limit.
85 | # token: ...
86 | repositories:
87 | - glanceapp/glance
88 | - go-gitea/gitea
89 | - immich-app/immich
90 | - syncthing/syncthing
91 |
92 | # Add more pages here:
93 | # - name: Your page name
94 | # columns:
95 | # - size: small
96 | # widgets:
97 | # # Add widgets here
98 |
99 | # - size: full
100 | # widgets:
101 | # # Add widgets here
102 |
103 | # - size: small
104 | # widgets:
105 | # # Add widgets here
106 |
--------------------------------------------------------------------------------
/docs/images/bookmarks-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/bookmarks-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/calendar-legacy-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/calendar-legacy-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/calendar-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/calendar-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/change-detection-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/change-detection-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/clock-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/clock-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/column-configuration-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/column-configuration-1.png
--------------------------------------------------------------------------------
/docs/images/column-configuration-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/column-configuration-2.png
--------------------------------------------------------------------------------
/docs/images/column-configuration-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/column-configuration-3.png
--------------------------------------------------------------------------------
/docs/images/contrast-multiplier-example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/contrast-multiplier-example.png
--------------------------------------------------------------------------------
/docs/images/custom-api-preview-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/custom-api-preview-1.png
--------------------------------------------------------------------------------
/docs/images/custom-api-preview-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/custom-api-preview-2.png
--------------------------------------------------------------------------------
/docs/images/custom-api-preview-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/custom-api-preview-3.png
--------------------------------------------------------------------------------
/docs/images/dns-stats-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/dns-stats-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/docker-container-parent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/docker-container-parent.png
--------------------------------------------------------------------------------
/docs/images/docker-container-parent2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/docker-container-parent2.png
--------------------------------------------------------------------------------
/docs/images/docker-containers-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/docker-containers-preview.png
--------------------------------------------------------------------------------
/docs/images/docker-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/docker-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/extension-html-reusing-existing-features-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/extension-html-reusing-existing-features-preview.png
--------------------------------------------------------------------------------
/docs/images/extension-overview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/extension-overview.png
--------------------------------------------------------------------------------
/docs/images/gaming-page-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/gaming-page-preview.png
--------------------------------------------------------------------------------
/docs/images/group-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/group-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/hacker-news-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/hacker-news-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/head-widgets-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/head-widgets-preview.png
--------------------------------------------------------------------------------
/docs/images/lobsters-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/lobsters-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/markets-page-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/markets-page-preview.png
--------------------------------------------------------------------------------
/docs/images/markets-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/markets-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/mobile-header-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/mobile-header-preview.png
--------------------------------------------------------------------------------
/docs/images/mobile-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/mobile-preview.png
--------------------------------------------------------------------------------
/docs/images/monitor-widget-compact-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/monitor-widget-compact-preview.png
--------------------------------------------------------------------------------
/docs/images/monitor-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/monitor-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/pages-and-columns-illustration.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/pages-and-columns-illustration.png
--------------------------------------------------------------------------------
/docs/images/preconfigured-page-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/preconfigured-page-preview.png
--------------------------------------------------------------------------------
/docs/images/readme-main-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/readme-main-image.png
--------------------------------------------------------------------------------
/docs/images/reddit-field-search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/reddit-field-search.png
--------------------------------------------------------------------------------
/docs/images/reddit-widget-horizontal-cards-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/reddit-widget-horizontal-cards-preview.png
--------------------------------------------------------------------------------
/docs/images/reddit-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/reddit-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/reddit-widget-vertical-cards-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/reddit-widget-vertical-cards-preview.png
--------------------------------------------------------------------------------
/docs/images/reddit-widget-vertical-list-thumbnails.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/reddit-widget-vertical-list-thumbnails.png
--------------------------------------------------------------------------------
/docs/images/releases-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/releases-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/reorder-todo-tasks-prevew.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/reorder-todo-tasks-prevew.gif
--------------------------------------------------------------------------------
/docs/images/repository-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/repository-preview.png
--------------------------------------------------------------------------------
/docs/images/rss-feed-horizontal-cards-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/rss-feed-horizontal-cards-preview.png
--------------------------------------------------------------------------------
/docs/images/rss-feed-vertical-list-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/rss-feed-vertical-list-preview.png
--------------------------------------------------------------------------------
/docs/images/rss-widget-detailed-list-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/rss-widget-detailed-list-preview.png
--------------------------------------------------------------------------------
/docs/images/rss-widget-horizontal-cards-2-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/rss-widget-horizontal-cards-2-preview.png
--------------------------------------------------------------------------------
/docs/images/search-widget-bangs-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/search-widget-bangs-preview.png
--------------------------------------------------------------------------------
/docs/images/search-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/search-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/server-stats-flame-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/server-stats-flame-icon.png
--------------------------------------------------------------------------------
/docs/images/server-stats-preview.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/server-stats-preview.gif
--------------------------------------------------------------------------------
/docs/images/split-column-widget-3-columns.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/split-column-widget-3-columns.png
--------------------------------------------------------------------------------
/docs/images/split-column-widget-4-columns.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/split-column-widget-4-columns.png
--------------------------------------------------------------------------------
/docs/images/split-column-widget-masonry.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/split-column-widget-masonry.png
--------------------------------------------------------------------------------
/docs/images/split-column-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/split-column-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/startpage-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/startpage-preview.png
--------------------------------------------------------------------------------
/docs/images/themes-example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/themes-example.png
--------------------------------------------------------------------------------
/docs/images/themes/camouflage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/themes/camouflage.png
--------------------------------------------------------------------------------
/docs/images/themes/catppuccin-frappe.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/themes/catppuccin-frappe.png
--------------------------------------------------------------------------------
/docs/images/themes/catppuccin-latte.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/themes/catppuccin-latte.png
--------------------------------------------------------------------------------
/docs/images/themes/catppuccin-macchiato.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/themes/catppuccin-macchiato.png
--------------------------------------------------------------------------------
/docs/images/themes/catppuccin-mocha.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/themes/catppuccin-mocha.png
--------------------------------------------------------------------------------
/docs/images/themes/dracula.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/themes/dracula.png
--------------------------------------------------------------------------------
/docs/images/themes/gruvbox.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/themes/gruvbox.png
--------------------------------------------------------------------------------
/docs/images/themes/kanagawa-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/themes/kanagawa-dark.png
--------------------------------------------------------------------------------
/docs/images/themes/peachy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/themes/peachy.png
--------------------------------------------------------------------------------
/docs/images/themes/teal-city.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/themes/teal-city.png
--------------------------------------------------------------------------------
/docs/images/themes/tucan.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/themes/tucan.png
--------------------------------------------------------------------------------
/docs/images/themes/zebra.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/themes/zebra.png
--------------------------------------------------------------------------------
/docs/images/todo-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/todo-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/twitch-channels-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/twitch-channels-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/twitch-top-games-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/twitch-top-games-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/videos-channel-description-example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/videos-channel-description-example.png
--------------------------------------------------------------------------------
/docs/images/videos-copy-channel-id-example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/videos-copy-channel-id-example.png
--------------------------------------------------------------------------------
/docs/images/videos-widget-grid-cards-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/videos-widget-grid-cards-preview.png
--------------------------------------------------------------------------------
/docs/images/videos-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/videos-widget-preview.png
--------------------------------------------------------------------------------
/docs/images/videos-widget-vertical-list-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/videos-widget-vertical-list-preview.png
--------------------------------------------------------------------------------
/docs/images/weather-widget-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/images/weather-widget-preview.png
--------------------------------------------------------------------------------
/docs/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/docs/logo.png
--------------------------------------------------------------------------------
/docs/themes.md:
--------------------------------------------------------------------------------
1 | # Themes
2 |
3 | ## Dark
4 |
5 | ### Teal City
6 | 
7 | ```yaml
8 | theme:
9 | background-color: 225 14 15
10 | primary-color: 157 47 65
11 | contrast-multiplier: 1.1
12 | ```
13 |
14 | ### Catppuccin Frappe
15 | 
16 | ```yaml
17 | theme:
18 | background-color: 229 19 23
19 | contrast-multiplier: 1.2
20 | primary-color: 222 74 74
21 | positive-color: 96 44 68
22 | negative-color: 359 68 71
23 | ```
24 |
25 | ### Catppuccin Macchiato
26 | 
27 | ```yaml
28 | theme:
29 | background-color: 232 23 18
30 | contrast-multiplier: 1.2
31 | primary-color: 220 83 75
32 | positive-color: 105 48 72
33 | negative-color: 351 74 73
34 | ```
35 |
36 | ### Catppuccin Mocha
37 | 
38 | ```yaml
39 | theme:
40 | background-color: 240 21 15
41 | contrast-multiplier: 1.2
42 | primary-color: 217 92 83
43 | positive-color: 115 54 76
44 | negative-color: 347 70 65
45 | ```
46 |
47 | ### Camouflage
48 | 
49 | ```yaml
50 | theme:
51 | background-color: 186 21 20
52 | contrast-multiplier: 1.2
53 | primary-color: 97 13 80
54 | ```
55 |
56 | ### Gruvbox Dark
57 | 
58 | ```yaml
59 | theme:
60 | background-color: 0 0 16
61 | primary-color: 43 59 81
62 | positive-color: 61 66 44
63 | negative-color: 6 96 59
64 | ```
65 |
66 | ### Kanagawa Dark
67 | 
68 | ```yaml
69 | theme:
70 | background-color: 240 13 14
71 | primary-color: 51 33 68
72 | negative-color: 358 100 68
73 | contrast-multiplier: 1.2
74 | ```
75 |
76 | ### Tucan
77 | 
78 | ```yaml
79 | theme:
80 | background-color: 50 1 6
81 | primary-color: 24 97 58
82 | negative-color: 209 88 54
83 | ```
84 |
85 | ### Dracula
86 | 
87 | ```yaml
88 | theme:
89 | background-color: 231 15 21
90 | primary-color: 265 89 79
91 | contrast-multiplier: 1.2
92 | positive-color: 135 94 66
93 | negative-color: 0 100 67
94 | ```
95 |
96 | ## Light
97 |
98 | ### Catppuccin Latte
99 | 
100 | ```yaml
101 | theme:
102 | light: true
103 | background-color: 220 23 95
104 | contrast-multiplier: 1.0
105 | primary-color: 220 91 54
106 | positive-color: 109 58 40
107 | negative-color: 347 87 44
108 | ```
109 |
110 | ### Peachy
111 | 
112 | ```yaml
113 | theme:
114 | light: true
115 | background-color: 28 40 77
116 | primary-color: 155 100 20
117 | negative-color: 0 100 60
118 | contrast-multiplier: 1.1
119 | text-saturation-multiplier: 0.5
120 | ```
121 |
122 | ### Zebra
123 | 
124 | ```yaml
125 | theme:
126 | light: true
127 | background-color: 0 0 95
128 | primary-color: 0 0 10
129 | negative-color: 0 90 50
130 | ```
131 |
--------------------------------------------------------------------------------
/docs/v0.7.0-upgrade.md:
--------------------------------------------------------------------------------
1 | ## Upgrading to v0.7.0 from previous versions
2 |
3 | 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.
4 |
5 | ### Before
6 |
7 | Versions before v0.7.0 used a `docker-compose.yml` that looked like the following:
8 |
9 | ```yaml
10 | services:
11 | glance:
12 | image: glanceapp/glance
13 | volumes:
14 | - ./glance.yml:/app/glance.yml
15 | ports:
16 | - 8080:8080
17 | ```
18 |
19 | And expected you to have the following directory structure:
20 |
21 | ```plaintext
22 | glance/
23 | docker-compose.yml
24 | glance.yml
25 | ```
26 |
27 | ### After
28 |
29 | With the release of v0.7.0, the recommended `docker-compose.yml` looks like the following:
30 |
31 | ```yaml
32 | services:
33 | glance:
34 | container_name: glance
35 | image: glanceapp/glance
36 | volumes:
37 | - ./config:/app/config
38 | ports:
39 | - 8080:8080
40 | ```
41 |
42 | And expects you to have the following directory structure:
43 |
44 | ```plaintext
45 | glance/
46 | docker-compose.yml
47 | config/
48 | glance.yml
49 | ```
50 |
51 | ## Why this change was necessary
52 |
53 | 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
54 | 2. v0.7.0 added automatic reloads when the configuration file changes, which based on testing didn't work when mounting a single file
55 | 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
56 |
57 | Taking all of these into account, it felt like the right time to implement the change.
58 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/glanceapp/glance
2 |
3 | go 1.24.3
4 |
5 | require (
6 | github.com/fsnotify/fsnotify v1.9.0
7 | github.com/mmcdole/gofeed v1.3.0
8 | github.com/shirou/gopsutil/v4 v4.25.4
9 | github.com/tidwall/gjson v1.18.0
10 | golang.org/x/crypto v0.38.0
11 | golang.org/x/text v0.25.0
12 | gopkg.in/yaml.v3 v3.0.1
13 | )
14 |
15 | require (
16 | github.com/PuerkitoBio/goquery v1.10.3 // indirect
17 | github.com/andybalholm/cascadia v1.3.3 // indirect
18 | github.com/ebitengine/purego v0.8.4 // indirect
19 | github.com/go-ole/go-ole v1.3.0 // indirect
20 | github.com/json-iterator/go v1.1.12 // indirect
21 | github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
22 | github.com/mmcdole/goxpp v1.1.1 // indirect
23 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
24 | github.com/modern-go/reflect2 v1.0.2 // indirect
25 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
26 | github.com/tidwall/match v1.1.1 // indirect
27 | github.com/tidwall/pretty v1.2.1 // indirect
28 | github.com/tklauser/go-sysconf v0.3.15 // indirect
29 | github.com/tklauser/numcpus v0.10.0 // indirect
30 | github.com/yusufpapurcu/wmi v1.2.4 // indirect
31 | golang.org/x/net v0.40.0 // indirect
32 | golang.org/x/sys v0.33.0 // indirect
33 | )
34 |
--------------------------------------------------------------------------------
/internal/glance/auth_test.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "bytes"
5 | "encoding/base64"
6 | "testing"
7 | "time"
8 | )
9 |
10 | func TestAuthTokenGenerationAndVerification(t *testing.T) {
11 | secret, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH)
12 | if err != nil {
13 | t.Fatalf("Failed to generate secret key: %v", err)
14 | }
15 |
16 | secretBytes, err := base64.StdEncoding.DecodeString(secret)
17 | if err != nil {
18 | t.Fatalf("Failed to decode secret key: %v", err)
19 | }
20 |
21 | if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
22 | t.Fatalf("Secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
23 | }
24 |
25 | now := time.Now()
26 | username := "admin"
27 |
28 | token, err := generateSessionToken(username, secretBytes, now)
29 | if err != nil {
30 | t.Fatalf("Failed to generate session token: %v", err)
31 | }
32 |
33 | usernameHashBytes, shouldRegen, err := verifySessionToken(token, secretBytes, now)
34 | if err != nil {
35 | t.Fatalf("Failed to verify session token: %v", err)
36 | }
37 |
38 | if shouldRegen {
39 | t.Fatal("Token should not need to be regenerated immediately after generation")
40 | }
41 |
42 | computedUsernameHash, err := computeUsernameHash(username, secretBytes)
43 | if err != nil {
44 | t.Fatalf("Failed to compute username hash: %v", err)
45 | }
46 |
47 | if !bytes.Equal(usernameHashBytes, computedUsernameHash) {
48 | t.Fatal("Username hash does not match the expected value")
49 | }
50 |
51 | // Test token regeneration
52 | timeRightAfterRegenPeriod := now.Add(AUTH_TOKEN_VALID_PERIOD - AUTH_TOKEN_REGEN_BEFORE + 2*time.Second)
53 | _, shouldRegen, err = verifySessionToken(token, secretBytes, timeRightAfterRegenPeriod)
54 | if err != nil {
55 | t.Fatalf("Token verification should not fail during regeneration period, err: %v", err)
56 | }
57 |
58 | if !shouldRegen {
59 | t.Fatal("Token should have been marked for regeneration")
60 | }
61 |
62 | // Test token expiration
63 | _, _, err = verifySessionToken(token, secretBytes, now.Add(AUTH_TOKEN_VALID_PERIOD+2*time.Second))
64 | if err == nil {
65 | t.Fatal("Expected token verification to fail after token expiration")
66 | }
67 |
68 | // Test tampered token
69 | decodedToken, err := base64.StdEncoding.DecodeString(token)
70 | if err != nil {
71 | t.Fatalf("Failed to decode token: %v", err)
72 | }
73 |
74 | // If any of the bytes are off by 1, the token should be considered invalid
75 | for i := range len(decodedToken) {
76 | tampered := make([]byte, len(decodedToken))
77 | copy(tampered, decodedToken)
78 | tampered[i] += 1
79 |
80 | _, _, err = verifySessionToken(base64.StdEncoding.EncodeToString(tampered), secretBytes, now)
81 | if err == nil {
82 | t.Fatalf("Expected token verification to fail for tampered token at index %d", i)
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/internal/glance/cli.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "os"
7 | "strings"
8 |
9 | "github.com/shirou/gopsutil/v4/disk"
10 | "github.com/shirou/gopsutil/v4/sensors"
11 | )
12 |
13 | type cliIntent uint8
14 |
15 | const (
16 | cliIntentVersionPrint cliIntent = iota
17 | cliIntentServe
18 | cliIntentConfigValidate
19 | cliIntentConfigPrint
20 | cliIntentDiagnose
21 | cliIntentSensorsPrint
22 | cliIntentMountpointInfo
23 | cliIntentSecretMake
24 | cliIntentPasswordHash
25 | )
26 |
27 | type cliOptions struct {
28 | intent cliIntent
29 | configPath string
30 | args []string
31 | }
32 |
33 | func parseCliOptions() (*cliOptions, error) {
34 | var args []string
35 |
36 | args = os.Args[1:]
37 | if len(args) == 1 && (args[0] == "--version" || args[0] == "-v" || args[0] == "version") {
38 | return &cliOptions{
39 | intent: cliIntentVersionPrint,
40 | }, nil
41 | }
42 |
43 | flags := flag.NewFlagSet("", flag.ExitOnError)
44 | flags.Usage = func() {
45 | fmt.Println("Usage: glance [options] command")
46 |
47 | fmt.Println("\nOptions:")
48 | flags.PrintDefaults()
49 |
50 | fmt.Println("\nCommands:")
51 | fmt.Println(" config:validate Validate the config file")
52 | fmt.Println(" config:print Print the parsed config file with embedded includes")
53 | fmt.Println(" password:hash <pwd> Hash a password")
54 | fmt.Println(" secret:make Generate a random secret key")
55 | fmt.Println(" sensors:print List all sensors")
56 | fmt.Println(" mountpoint:info Print information about a given mountpoint path")
57 | fmt.Println(" diagnose Run diagnostic checks")
58 | }
59 |
60 | configPath := flags.String("config", "glance.yml", "Set config path")
61 | err := flags.Parse(os.Args[1:])
62 | if err != nil {
63 | return nil, err
64 | }
65 |
66 | var intent cliIntent
67 | args = flags.Args()
68 | unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
69 |
70 | if len(args) == 0 {
71 | intent = cliIntentServe
72 | } else if len(args) == 1 {
73 | if args[0] == "config:validate" {
74 | intent = cliIntentConfigValidate
75 | } else if args[0] == "config:print" {
76 | intent = cliIntentConfigPrint
77 | } else if args[0] == "sensors:print" {
78 | intent = cliIntentSensorsPrint
79 | } else if args[0] == "diagnose" {
80 | intent = cliIntentDiagnose
81 | } else if args[0] == "secret:make" {
82 | intent = cliIntentSecretMake
83 | } else {
84 | return nil, unknownCommandErr
85 | }
86 | } else if len(args) == 2 {
87 | if args[0] == "password:hash" {
88 | intent = cliIntentPasswordHash
89 | } else {
90 | return nil, unknownCommandErr
91 | }
92 | } else if len(args) == 2 {
93 | if args[0] == "mountpoint:info" {
94 | intent = cliIntentMountpointInfo
95 | } else {
96 | return nil, unknownCommandErr
97 | }
98 | } else {
99 | return nil, unknownCommandErr
100 | }
101 |
102 | return &cliOptions{
103 | intent: intent,
104 | configPath: *configPath,
105 | args: args,
106 | }, nil
107 | }
108 |
109 | func cliSensorsPrint() int {
110 | tempSensors, err := sensors.SensorsTemperatures()
111 | if err != nil {
112 | if warns, ok := err.(*sensors.Warnings); ok {
113 | fmt.Printf("Could not retrieve information for some sensors (%v):\n", err)
114 | for _, w := range warns.List {
115 | fmt.Printf(" - %v\n", w)
116 | }
117 | fmt.Println()
118 | } else {
119 | fmt.Printf("Failed to retrieve sensor information: %v\n", err)
120 | return 1
121 | }
122 | }
123 |
124 | if len(tempSensors) == 0 {
125 | fmt.Println("No sensors found")
126 | return 0
127 | }
128 |
129 | fmt.Println("Sensors found:")
130 | for _, sensor := range tempSensors {
131 | fmt.Printf(" %s: %.1f°C\n", sensor.SensorKey, sensor.Temperature)
132 | }
133 |
134 | return 0
135 | }
136 |
137 | func cliMountpointInfo(requestedPath string) int {
138 | usage, err := disk.Usage(requestedPath)
139 | if err != nil {
140 | fmt.Printf("Failed to retrieve info for path %s: %v\n", requestedPath, err)
141 | if warns, ok := err.(*disk.Warnings); ok {
142 | for _, w := range warns.List {
143 | fmt.Printf(" - %v\n", w)
144 | }
145 | }
146 |
147 | return 1
148 | }
149 |
150 | fmt.Println("Path:", usage.Path)
151 | fmt.Println("FS type:", ternary(usage.Fstype == "", "unknown", usage.Fstype))
152 | fmt.Printf("Used percent: %.1f%%\n", usage.UsedPercent)
153 |
154 | return 0
155 | }
156 |
--------------------------------------------------------------------------------
/internal/glance/embed.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "bytes"
5 | "crypto/md5"
6 | "embed"
7 | "encoding/hex"
8 | "errors"
9 | "fmt"
10 | "io"
11 | "io/fs"
12 | "log"
13 | "path/filepath"
14 | "regexp"
15 | "strconv"
16 | "strings"
17 | "time"
18 | )
19 |
20 | //go:embed static
21 | var _staticFS embed.FS
22 |
23 | //go:embed templates
24 | var _templateFS embed.FS
25 |
26 | var staticFS, _ = fs.Sub(_staticFS, "static")
27 | var templateFS, _ = fs.Sub(_templateFS, "templates")
28 |
29 | func readAllFromStaticFS(path string) ([]byte, error) {
30 | // For some reason fs.FS only works with forward slashes, so in case we're
31 | // running on Windows or pass paths with backslashes we need to replace them.
32 | path = strings.ReplaceAll(path, "\\", "/")
33 |
34 | file, err := staticFS.Open(path)
35 | if err != nil {
36 | return nil, err
37 | }
38 |
39 | return io.ReadAll(file)
40 | }
41 |
42 | var staticFSHash = func() string {
43 | hash, err := computeFSHash(staticFS)
44 | if err != nil {
45 | log.Printf("Could not compute static assets cache key: %v", err)
46 | return strconv.FormatInt(time.Now().Unix(), 10)
47 | }
48 |
49 | return hash
50 | }()
51 |
52 | func computeFSHash(files fs.FS) (string, error) {
53 | hash := md5.New()
54 |
55 | err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
56 | if err != nil {
57 | return err
58 | }
59 |
60 | if d.IsDir() {
61 | return nil
62 | }
63 |
64 | file, err := files.Open(path)
65 | if err != nil {
66 | return err
67 | }
68 |
69 | if _, err := io.Copy(hash, file); err != nil {
70 | return err
71 | }
72 |
73 | return nil
74 | })
75 |
76 | if err != nil {
77 | return "", err
78 | }
79 |
80 | return hex.EncodeToString(hash.Sum(nil))[:10], nil
81 | }
82 |
83 | var cssImportPattern = regexp.MustCompile(`(?m)^@import "(.*?)";
)
84 | var cssSingleLineCommentPattern = regexp.MustCompile(`(?m)^\s*\/\*.*?\*\/
)
85 |
86 | // Yes, we bundle at runtime, give comptime pls
87 | var bundledCSSContents = func() []byte {
88 | const mainFilePath = "css/main.css"
89 |
90 | var recursiveParseImports func(path string, depth int) ([]byte, error)
91 | recursiveParseImports = func(path string, depth int) ([]byte, error) {
92 | if depth > 20 {
93 | return nil, errors.New("maximum import depth reached, is one of your imports circular?")
94 | }
95 |
96 | mainFileContents, err := readAllFromStaticFS(path)
97 | if err != nil {
98 | return nil, err
99 | }
100 |
101 | // Normalize line endings, otherwise the \r's make the regex not match
102 | mainFileContents = bytes.ReplaceAll(mainFileContents, []byte("\r\n"), []byte("\n"))
103 |
104 | mainFileDir := filepath.Dir(path)
105 | var importLastErr error
106 |
107 | parsed := cssImportPattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
108 | if importLastErr != nil {
109 | return nil
110 | }
111 |
112 | matches := cssImportPattern.FindSubmatch(match)
113 | if len(matches) != 2 {
114 | importLastErr = fmt.Errorf(
115 | "import didn't return expected number of capture groups: %s, expected 2, got %d",
116 | match, len(matches),
117 | )
118 | return nil
119 | }
120 |
121 | importFilePath := filepath.Join(mainFileDir, string(matches[1]))
122 | importContents, err := recursiveParseImports(importFilePath, depth+1)
123 | if err != nil {
124 | importLastErr = err
125 | return nil
126 | }
127 |
128 | return importContents
129 | })
130 |
131 | if importLastErr != nil {
132 | return nil, importLastErr
133 | }
134 |
135 | return parsed, nil
136 | }
137 |
138 | contents, err := recursiveParseImports(mainFilePath, 0)
139 | if err != nil {
140 | panic(fmt.Sprintf("building CSS bundle: %v", err))
141 | }
142 |
143 | // We could strip a bunch more unnecessary characters, but the biggest
144 | // win comes from removing the whitespace at the beginning of lines
145 | // since that's at least 4 bytes per property, which yielded a ~20% reduction.
146 | contents = cssSingleLineCommentPattern.ReplaceAll(contents, nil)
147 | contents = whitespaceAtBeginningOfLinePattern.ReplaceAll(contents, nil)
148 | contents = bytes.ReplaceAll(contents, []byte("\n"), []byte(""))
149 |
150 | return contents
151 | }()
152 |
--------------------------------------------------------------------------------
/internal/glance/static/app-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/internal/glance/static/app-icon.png
--------------------------------------------------------------------------------
/internal/glance/static/css/forum-posts.css:
--------------------------------------------------------------------------------
1 | .forum-post-list-thumbnail {
2 | flex-shrink: 0;
3 | width: 6rem;
4 | height: 4.1rem;
5 | border-radius: var(--border-radius);
6 | object-fit: cover;
7 | border: 1px solid var(--color-separator);
8 | margin-top: 0.1rem;
9 | }
10 |
11 | .forum-post-tags-container {
12 | transform: translateY(-0.15rem);
13 | }
14 |
15 | @container widget (max-width: 550px) {
16 | .forum-post-autohide {
17 | display: none;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/internal/glance/static/css/login.css:
--------------------------------------------------------------------------------
1 | .login-bounds {
2 | max-width: 500px;
3 | padding: 0 2rem;
4 | }
5 |
6 | .form-label {
7 | text-transform: uppercase;
8 | margin-bottom: 0.5rem;
9 | }
10 |
11 | .form-input {
12 | transition: border-color .2s;
13 | }
14 |
15 | .form-input input {
16 | border: 0;
17 | background: none;
18 | width: 100%;
19 | height: 5.2rem;
20 | font: inherit;
21 | outline: none;
22 | color: var(--color-text-highlight);
23 | }
24 |
25 | .form-input-icon {
26 | width: 2rem;
27 | height: 2rem;
28 | margin-top: -0.1rem;
29 | opacity: 0.5;
30 | }
31 |
32 | .form-input input[type="password"] {
33 | letter-spacing: 0.3rem;
34 | font-size: 0.9em;
35 | }
36 |
37 | .form-input input[type="password"]::placeholder {
38 | letter-spacing: 0;
39 | font-size: var(--font-size-base);
40 | }
41 |
42 | .form-input:hover {
43 | border-color: var(--color-progress-border);
44 | }
45 |
46 | .form-input:focus-within {
47 | border-color: var(--color-primary);
48 | transition-duration: .7s;
49 | }
50 |
51 | .login-button {
52 | width: 100%;
53 | display: block;
54 | padding: 1rem;
55 | background: none;
56 | border: 1px solid var(--color-text-subdue);
57 | border-radius: var(--border-radius);
58 | color: var(--color-text-paragraph);
59 | cursor: pointer;
60 | font: inherit;
61 | font-size: var(--font-size-h4);
62 | display: flex;
63 | gap: .5rem;
64 | align-items: center;
65 | justify-content: center;
66 | transition: all .3s, margin-top 0s;
67 | margin-top: 3rem;
68 | }
69 |
70 | .login-button:not(:disabled) {
71 | box-shadow: 0 0 10px 1px var(--color-separator);
72 | }
73 |
74 | .login-error-message:not(:empty) + .login-button {
75 | margin-top: 2rem;
76 | }
77 |
78 | .login-button:focus, .login-button:hover {
79 | outline: none;
80 | border-color: var(--color-primary);
81 | color: var(--color-primary);
82 | }
83 |
84 | .login-button:disabled {
85 | border-color: var(--color-separator);
86 | color: var(--color-text-subdue);
87 | cursor: not-allowed;
88 | }
89 |
90 | .login-button svg {
91 | width: 1.7rem;
92 | height: 1.7rem;
93 | transition: transform .2s;
94 | }
95 |
96 | .login-button:not(:disabled):hover svg, .login-button:not(:disabled):focus svg {
97 | transform: translateX(.5rem);
98 | }
99 |
100 | .animate-entrance {
101 | animation: fieldReveal 0.7s backwards;
102 | animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
103 | }
104 |
105 | .animate-entrance:nth-child(1) { animation-delay: .1s; }
106 | .animate-entrance:nth-child(2) { animation-delay: .2s; }
107 | .animate-entrance:nth-child(4) { animation-delay: .3s; }
108 |
109 | @keyframes fieldReveal {
110 | from {
111 | opacity: 0.0001;
112 | transform: translateY(4rem);
113 | }
114 | }
115 |
116 | .login-error-message {
117 | color: var(--color-negative);
118 | font-size: var(--font-size-base);
119 | padding: 1.3rem calc(var(--widget-content-horizontal-padding) + 1px);
120 | position: relative;
121 | margin-top: 2rem;
122 | animation: errorMessageEntrance 0.4s backwards cubic-bezier(0.34, 1.56, 0.64, 1);
123 | }
124 |
125 | @keyframes errorMessageEntrance {
126 | from {
127 | opacity: 0;
128 | transform: scale(1.1);
129 | }
130 | }
131 |
132 | .login-error-message:empty {
133 | display: none;
134 | }
135 |
136 | .login-error-message::before {
137 | content: "";
138 | position: absolute;
139 | inset: 0;
140 | border-radius: var(--border-radius);
141 | background: var(--color-negative);
142 | opacity: 0.05;
143 | z-index: -1;
144 | }
145 |
146 | .footer {
147 | animation-delay: .4s;
148 | animation-duration: 1s;
149 | }
150 |
151 | .toggle-password-visibility {
152 | background: none;
153 | border: none;
154 | cursor: pointer;
155 | }
156 |
--------------------------------------------------------------------------------
/internal/glance/static/css/main.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'JetBrains Mono';
3 | font-style: normal;
4 | font-weight: 400;
5 | font-display: swap;
6 | src: url('../fonts/JetBrainsMono-Regular.woff2') format('woff2');
7 | }
8 |
9 | :root {
10 | font-size: 10px;
11 |
12 | --scheme: ;
13 | --bgh: 240;
14 | --bgs: 8%;
15 | --bgl: 9%;
16 | --bghs: var(--bgh), var(--bgs);
17 | --cm: 1;
18 | --tsm: 1;
19 |
20 | --widget-gap: 23px;
21 | --widget-content-vertical-padding: 15px;
22 | --widget-content-horizontal-padding: 17px;
23 | --widget-content-padding: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
24 | --content-bounds-padding: 15px;
25 | --border-radius: 5px;
26 | --mobile-navigation-height: 50px;
27 |
28 | --color-primary: hsl(43, 50%, 70%);
29 | --color-positive: var(--color-primary);
30 | --color-negative: hsl(0, 70%, 70%);
31 | --color-background: hsl(var(--bghs), var(--bgl));
32 | --color-widget-background-hsl-values: var(--bghs), calc(var(--bgl) + 1%);
33 | --color-widget-background: hsl(var(--color-widget-background-hsl-values));
34 | --color-separator: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 4% * var(--cm))));
35 | --color-widget-content-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
36 | --color-widget-background-highlight: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
37 | --color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 3%));
38 | --color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 12%)));
39 | --color-progress-border: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 10% * var(--cm))));
40 | --color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 26% * var(--cm))));
41 | --color-vertical-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 28% * var(--cm))));
42 | --color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm))));
43 |
44 | --ths: var(--bgh), calc(var(--bgs) * var(--tsm));
45 | --color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
46 | --color-text-paragraph: hsl(var(--ths), calc(var(--scheme) var(--cm) * 73%));
47 | --color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
48 | --color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%));
49 | --color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
50 |
51 | --font-size-h1: 1.7rem;
52 | --font-size-h2: 1.6rem;
53 | --font-size-h3: 1.5rem;
54 | --font-size-h4: 1.4rem;
55 | --font-size-base: 1.3rem;
56 | --font-size-h5: 1.2rem;
57 | --font-size-h6: 1.1rem;
58 | }
59 |
60 | /* Do not change the order of the below imports unless you know what you're doing */
61 |
62 | @import "site.css";
63 | @import "widgets.css";
64 | @import "popover.css";
65 | @import "utils.css";
66 | @import "mobile.css";
67 |
--------------------------------------------------------------------------------
/internal/glance/static/css/popover.css:
--------------------------------------------------------------------------------
1 | .popover-container, [data-popover-html] {
2 | display: none;
3 | }
4 |
5 | .popover-container {
6 | --triangle-size: 10px;
7 | --triangle-offset: 50%;
8 | --triangle-margin: calc(var(--triangle-size) + 3px);
9 | --entrance-y-offset: 8px;
10 | --entrance-direction: calc(var(--entrance-y-offset) * -1);
11 |
12 | z-index: 20;
13 | position: absolute;
14 | padding-top: var(--triangle-margin);
15 | padding-inline: var(--content-bounds-padding);
16 | }
17 |
18 | .popover-container.position-above {
19 | --entrance-direction: var(--entrance-y-offset);
20 | padding-top: 0;
21 | padding-bottom: var(--triangle-margin);
22 | }
23 |
24 | .popover-frame {
25 | --shadow-properties: 0 15px 20px -10px;
26 | --shadow-color: hsla(var(--bghs), calc(var(--bgl) * 0.2), 0.5);
27 | position: relative;
28 | padding: 10px;
29 | background: var(--color-popover-background);
30 | border: 1px solid var(--color-popover-border);
31 | border-radius: 5px;
32 | animation: popoverFrameEntrance 0.3s backwards cubic-bezier(0.16, 1, 0.3, 1);
33 | box-shadow: var(--shadow-properties) var(--shadow-color);
34 | }
35 |
36 | .popover-frame::before {
37 | content: '';
38 | position: absolute;
39 | width: var(--triangle-size);
40 | height: var(--triangle-size);
41 | transform: rotate(45deg);
42 | background-color: var(--color-popover-background);
43 | border-top-left-radius: 2px;
44 | border-left: 1px solid var(--color-popover-border);
45 | border-top: 1px solid var(--color-popover-border);
46 | left: calc(var(--triangle-offset) - (var(--triangle-size) / 2));
47 | top: calc(var(--triangle-size) / 2 * -1 - 1px);
48 | }
49 |
50 | .popover-container.position-above .popover-frame::before {
51 | transform: rotate(-135deg);
52 | top: auto;
53 | bottom: calc(var(--triangle-size) / 2 * -1 - 1px);
54 | }
55 |
56 | .popover-container.position-above .popover-frame {
57 | --shadow-properties: 0 10px 20px -10px;
58 | }
59 |
60 | @keyframes popoverFrameEntrance {
61 | from {
62 | opacity: 0;
63 | transform: translateY(var(--entrance-direction));
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-bookmarks.css:
--------------------------------------------------------------------------------
1 | .bookmarks-group {
2 | --bookmarks-group-color: var(--color-primary);
3 | }
4 |
5 | .bookmarks-group-title {
6 | color: var(--bookmarks-group-color);
7 | }
8 |
9 | .bookmarks-link:not(.bookmarks-link-no-arrow)::after {
10 | content: '↗' / "";
11 | margin-left: 0.5em;
12 | display: inline-block;
13 | position: relative;
14 | top: 0.15em;
15 | color: var(--bookmarks-group-color);
16 | }
17 |
18 | .bookmarks-icon-container {
19 | margin-block: 0.1rem;
20 | background-color: var(--color-widget-background-highlight);
21 | border-radius: var(--border-radius);
22 | padding: 0.5rem;
23 | opacity: 0.7;
24 | flex-shrink: 0;
25 | }
26 |
27 | .bookmarks-icon {
28 | width: 20px;
29 | height: 20px;
30 | opacity: 0.8;
31 | }
32 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-calendar.css:
--------------------------------------------------------------------------------
1 | .old-calendar-day {
2 | width: calc(100% / 7);
3 | text-align: center;
4 | padding: 0.6rem 0;
5 | }
6 |
7 | .old-calendar-day-today {
8 | border-radius: var(--border-radius);
9 | background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%)));
10 | color: var(--color-text-highlight);
11 | }
12 |
13 | .calendar-dates {
14 | text-align: center;
15 | display: grid;
16 | grid-template-columns: repeat(7, 1fr);
17 | gap: 2px;
18 | }
19 |
20 | .calendar-date {
21 | padding: 0.4rem 0;
22 | color: var(--color-text-base);
23 | position: relative;
24 | border-radius: var(--border-radius);
25 | background: none;
26 | border: none;
27 | font: inherit;
28 | }
29 |
30 | .calendar-current-date {
31 | border-radius: var(--border-radius);
32 | background-color: var(--color-popover-border);
33 | color: var(--color-text-highlight);
34 | }
35 |
36 | .calendar-spillover-date {
37 | color: var(--color-text-subdue);
38 | }
39 |
40 | .calendar-header-button {
41 | position: relative;
42 | cursor: pointer;
43 | width: 2rem;
44 | height: 2rem;
45 | z-index: 1;
46 | background: none;
47 | border: none;
48 | }
49 |
50 | .calendar-header-button::before {
51 | content: '';
52 | position: absolute;
53 | inset: -0.2rem;
54 | border-radius: var(--border-radius);
55 | background-color: var(--color-text-subdue);
56 | opacity: 0;
57 | transition: opacity 0.2s;
58 | z-index: -1;
59 | }
60 |
61 | .calendar-header-button:hover::before {
62 | opacity: 0.4;
63 | }
64 |
65 | .calendar-undo-button {
66 | display: inline-block;
67 | vertical-align: text-top;
68 | width: 2rem;
69 | height: 2rem;
70 | margin-left: 0.7rem;
71 | }
72 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-clock.css:
--------------------------------------------------------------------------------
1 | .clock-time {
2 | min-width: 8ch;
3 | }
4 |
5 | .clock-time span {
6 | color: var(--color-text-highlight);
7 | }
8 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-dns-stats.css:
--------------------------------------------------------------------------------
1 | .dns-stats-totals {
2 | transition: opacity .3s;
3 | transition-delay: 50ms;
4 | }
5 |
6 | .dns-stats:has(.dns-stats-graph .popover-active) .dns-stats-totals {
7 | opacity: 0.1;
8 | transition-delay: 0s;
9 | }
10 |
11 | .dns-stats-graph {
12 | --graph-height: 70px;
13 | height: var(--graph-height);
14 | position: relative;
15 | margin-bottom: 2.5rem;
16 | }
17 |
18 | .dns-stats-graph-gridlines-container {
19 | position: absolute;
20 | inset: 0;
21 | }
22 |
23 | .dns-stats-graph-gridlines {
24 | height: 100%;
25 | width: 100%;
26 | }
27 |
28 | .dns-stats-graph-columns {
29 | display: flex;
30 | height: 100%;
31 | }
32 |
33 | .dns-stats-graph-column {
34 | display: flex;
35 | justify-content: flex-end;
36 | align-items: center;
37 | flex-direction: column;
38 | width: calc(100% / 8);
39 | position: relative;
40 | }
41 |
42 | .dns-stats-graph-column::before {
43 | content: '';
44 | position: absolute;
45 | inset: 1px 0;
46 | opacity: 0;
47 | background: var(--color-text-base);
48 | transition: opacity .2s;
49 | }
50 |
51 | .dns-stats-graph-column:hover::before {
52 | opacity: 0.05;
53 | }
54 |
55 | .dns-stats-graph-bar {
56 | width: 14px;
57 | height: calc((var(--bar-height) / 100) * var(--graph-height));
58 | border: 1px solid var(--color-progress-border);
59 | border-radius: var(--border-radius) var(--border-radius) 0 0;
60 | display: flex;
61 | background: var(--color-widget-background);
62 | padding: 2px 2px 0 2px;
63 | flex-direction: column;
64 | gap: 2px;
65 | transition: border-color .2s;
66 | min-height: 10px;
67 | }
68 |
69 | .dns-stats-graph-column.popover-active .dns-stats-graph-bar {
70 | border-color: var(--color-text-subdue);
71 | border-bottom-color: var(--color-progress-border);
72 | }
73 |
74 | .dns-stats-graph-bar > * {
75 | border-radius: 2px;
76 | background: var(--color-vertical-progress-value);
77 | min-height: 1px;
78 | }
79 |
80 | .dns-stats-graph-bar > .queries {
81 | flex-grow: 1;
82 | }
83 |
84 | .dns-stats-graph-bar > *:last-child {
85 | border-bottom-right-radius: 0;
86 | border-bottom-left-radius: 0;
87 | }
88 |
89 | .dns-stats-graph-bar > .blocked {
90 | background-color: var(--color-negative);
91 | flex-basis: calc(var(--percent) - 1px);
92 | }
93 |
94 | .dns-stats-graph-column:nth-child(even) .dns-stats-graph-time {
95 | opacity: 1;
96 | transform: translateY(0);
97 | }
98 |
99 | .dns-stats-graph-time, .dns-stats-graph-columns:hover .dns-stats-graph-time {
100 | position: absolute;
101 | font-size: var(--font-size-h6);
102 | inset-inline: 0;
103 | text-align: center;
104 | height: 2.5rem;
105 | line-height: 2.5rem;
106 | top: 100%;
107 | user-select: none;
108 | opacity: 0;
109 | transform: translateY(-0.5rem);
110 | transition: opacity .2s, transform .2s;
111 | }
112 |
113 | .dns-stats-graph-column:hover .dns-stats-graph-time {
114 | opacity: 1;
115 | transform: translateY(0);
116 | }
117 |
118 | .dns-stats-graph-columns:hover .dns-stats-graph-column:not(:hover) .dns-stats-graph-time {
119 | opacity: 0;
120 | }
121 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-docker-containers.css:
--------------------------------------------------------------------------------
1 | .docker-container-icon {
2 | display: block;
3 | filter: grayscale(0.4);
4 | object-fit: contain;
5 | aspect-ratio: 1 / 1;
6 | width: 2.7rem;
7 | opacity: 0.8;
8 | transition: filter 0.3s, opacity 0.3s;
9 | }
10 |
11 | .docker-container-icon.flat-icon {
12 | opacity: 0.7;
13 | }
14 |
15 | .docker-container:hover .docker-container-icon {
16 | opacity: 1;
17 | }
18 |
19 | .docker-container:hover .docker-container-icon:not(.flat-icon) {
20 | filter: grayscale(0);
21 | }
22 |
23 | .docker-container-status-icon {
24 | width: 2rem;
25 | height: 2rem;
26 | }
27 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-group.css:
--------------------------------------------------------------------------------
1 | .widget-group-header {
2 | overflow-x: auto;
3 | scrollbar-width: thin;
4 | }
5 |
6 | .widget-group-title {
7 | background: none;
8 | font: inherit;
9 | border: none;
10 | text-transform: uppercase;
11 | border-bottom: 1px dotted transparent;
12 | cursor: pointer;
13 | flex-shrink: 0;
14 | transition: color .3s, border-color .3s;
15 | color: var(--color-text-subdue);
16 | line-height: calc(1.6em - 1px);
17 | }
18 |
19 | .widget-group-title:hover:not(.widget-group-title-current) {
20 | color: var(--color-text-base);
21 | }
22 |
23 | .widget-group-title-current {
24 | border-bottom-color: var(--color-text-base-muted);
25 | color: var(--color-text-base);
26 | }
27 |
28 | .widget-group-content {
29 | animation: widgetGroupContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
30 | }
31 |
32 | .widget-group-content[data-direction="right"] {
33 | --direction: 5px;
34 | }
35 |
36 | .widget-group-content[data-direction="left"] {
37 | --direction: -5px;
38 | }
39 |
40 | @keyframes widgetGroupContentEntrance {
41 | from {
42 | opacity: 0;
43 | transform: translateX(var(--direction));
44 | }
45 | }
46 |
47 | .widget-group-content:not(.widget-group-content-current) {
48 | display: none;
49 | }
50 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-markets.css:
--------------------------------------------------------------------------------
1 | .market-chart {
2 | margin-left: auto;
3 | width: 6.5rem;
4 | flex-shrink: 0;
5 | }
6 |
7 | .market-chart svg {
8 | width: 100%;
9 | }
10 |
11 | .market-values {
12 | min-width: 8rem;
13 | }
14 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-monitor.css:
--------------------------------------------------------------------------------
1 | .monitor-site-icon {
2 | display: block;
3 | opacity: 0.8;
4 | filter: grayscale(0.4);
5 | object-fit: contain;
6 | aspect-ratio: 1 / 1;
7 | width: 3.2rem;
8 | position: relative;
9 | top: -0.1rem;
10 | transition: filter 0.3s, opacity 0.3s;
11 | }
12 |
13 | .monitor-site-icon.flat-icon {
14 | opacity: 0.7;
15 | }
16 |
17 | .monitor-site:hover .monitor-site-icon {
18 | opacity: 1;
19 | }
20 |
21 | .monitor-site:hover .monitor-site-icon:not(.flat-icon) {
22 | filter: grayscale(0);
23 | }
24 |
25 | .monitor-site-status-icon {
26 | flex-shrink: 0;
27 | margin-left: auto;
28 | width: 2rem;
29 | height: 2rem;
30 | }
31 |
32 | .monitor-site-status-icon-compact {
33 | width: 1.8rem;
34 | height: 1.8rem;
35 | flex-shrink: 0;
36 | }
37 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-reddit.css:
--------------------------------------------------------------------------------
1 | .reddit-card-thumbnail {
2 | width: 100%;
3 | height: 100%;
4 | object-fit: cover;
5 | object-position: 0% 20%;
6 | opacity: 0.15;
7 | filter: blur(1px);
8 | }
9 |
10 | .reddit-card-thumbnail-container {
11 | position: absolute;
12 | inset: 0;
13 | overflow: hidden;
14 | border-radius: var(--border-radius);
15 | }
16 |
17 | .reddit-card-thumbnail-container::after {
18 | content: '';
19 | position: absolute;
20 | inset: 0;
21 | background: linear-gradient(0deg, var(--color-widget-background) 10%, transparent);
22 | }
23 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-releases.css:
--------------------------------------------------------------------------------
1 | .release-source-icon {
2 | width: 16px;
3 | height: 16px;
4 | flex-shrink: 0;
5 | opacity: 0.4;
6 | }
7 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-rss.css:
--------------------------------------------------------------------------------
1 | .rss-card-image {
2 | height: var(--rss-thumbnail-height, 10rem);
3 | object-fit: cover;
4 | border-radius: var(--border-radius) var(--border-radius) 0 0;
5 | }
6 |
7 | .rss-card-2 {
8 | position: relative;
9 | height: var(--rss-card-height, 27rem);
10 | overflow: hidden;
11 | }
12 |
13 | .rss-card-2::before {
14 | content: '';
15 | position: absolute;
16 | inset: 0;
17 | pointer-events: none;
18 | background-image: linear-gradient(
19 | 0deg,
20 | var(--color-widget-background),
21 | hsla(var(--color-widget-background-hsl-values), 0.8) 6rem, transparent 14rem
22 | );
23 | z-index: 2;
24 | }
25 |
26 | .rss-card-2-image {
27 | position: absolute;
28 | width: 100%;
29 | height: 100%;
30 | object-fit: cover;
31 | /* +1px is required to fix some weird graphical bug where the image overflows on the bottom in firefox */
32 | border-radius: calc(var(--border-radius) + 1px);
33 | opacity: 0.9;
34 | z-index: 1;
35 | }
36 |
37 | .rss-card-2-content {
38 | position: absolute;
39 | inset-inline: 0;
40 | bottom: var(--widget-content-vertical-padding);
41 | z-index: 3;
42 | }
43 |
44 | .rss-detailed-description {
45 | max-width: 55rem;
46 | color: var(--color-text-base-muted);
47 | }
48 |
49 | .rss-detailed-thumbnail {
50 | margin-top: 0.3rem;
51 | }
52 |
53 | .rss-detailed-thumbnail > * {
54 | aspect-ratio: 3 / 2;
55 | height: 8.7rem;
56 | }
57 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-search.css:
--------------------------------------------------------------------------------
1 | .search-icon {
2 | width: 2.3rem;
3 | }
4 |
5 | .search-icon-container {
6 | position: relative;
7 | flex-shrink: 0;
8 | }
9 |
10 | /* gives a wider hit area for the 3 people that will notice the animation : ) */
11 | .search-icon-container::before {
12 | content: '';
13 | position: absolute;
14 | inset: -1rem;
15 | }
16 |
17 | .search-icon-container:hover > .search-icon {
18 | animation: searchIconHover 2.9s forwards;
19 | }
20 |
21 | @keyframes searchIconHover {
22 | 0%, 39% { translate: 0 0; }
23 | 20% { scale: 1.3; }
24 | 40% { scale: 1; }
25 | 50% { translate: -30% 30%; }
26 | 70% { translate: 30% -30%; }
27 | 90% { translate: -30% -30%; }
28 | 100% { translate: 0 0; }
29 | }
30 |
31 | .search {
32 | transition: border-color .2s;
33 | position: relative;
34 | }
35 |
36 | .search:hover {
37 | border-color: var(--color-text-subdue);
38 | }
39 |
40 | .search:focus-within {
41 | border-color: var(--color-primary);
42 | }
43 |
44 | .search-input {
45 | border: 0;
46 | background: none;
47 | width: 100%;
48 | height: 6rem;
49 | font: inherit;
50 | outline: none;
51 | color: var(--color-text-highlight);
52 | }
53 |
54 | .search-input::placeholder {
55 | color: var(--color-text-base-muted);
56 | opacity: 1;
57 | }
58 |
59 | .search-bangs { display: none; }
60 |
61 | .search-bang {
62 | border-radius: calc(var(--border-radius) * 2);
63 | background: var(--color-widget-background-highlight);
64 | padding: 0.3rem 1rem;
65 | flex-shrink: 0;
66 | font-size: var(--font-size-h5);
67 | animation: searchBangsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
68 | }
69 |
70 | @keyframes searchBangsEntrance {
71 | 0% {
72 | opacity: 0;
73 | transform: translateX(-10px);
74 | }
75 | }
76 |
77 | .search-bang:empty {
78 | display: none;
79 | }
80 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-server-stats.css:
--------------------------------------------------------------------------------
1 | .widget-type-server-info {
2 | position: relative;
3 | }
4 |
5 | .server + .server {
6 | margin-top: 3rem;
7 | }
8 |
9 | .server {
10 | gap: 1rem;
11 | display: flex;
12 | flex-direction: column;
13 | }
14 |
15 | .server-info {
16 | align-items: center;
17 | display: flex;
18 | justify-content: space-between;
19 | gap: 1.5rem;
20 | flex-shrink: 1;
21 | min-width: 0;
22 | }
23 |
24 | .server-details {
25 | min-width: 0;
26 | }
27 |
28 | .server-icon {
29 | height: 3rem;
30 | width: 3rem;
31 | }
32 |
33 | .server-spicy-cpu-icon {
34 | height: 1em;
35 | align-self: center;
36 | margin-left: 0.4em;
37 | margin-bottom: 0.2rem;
38 | }
39 |
40 | .server-stats {
41 | display: flex;
42 | gap: 1.5rem;
43 | margin-top: 0.5rem;
44 | }
45 |
46 | .server-stat-unavailable {
47 | opacity: 0.5;
48 | }
49 |
50 | @container widget (min-width: 650px) {
51 | .server {
52 | gap: 2rem;
53 | flex-direction: row;
54 | align-items: center;
55 | }
56 |
57 | .server + .server {
58 | margin-top: 1rem;
59 | }
60 |
61 | .server-info {
62 | flex-direction: row-reverse;
63 | justify-content: unset;
64 | margin-right: auto;
65 | z-index: 1;
66 | }
67 |
68 | .server-stats {
69 | flex-direction: row;
70 | justify-content: right;
71 | min-width: 450px;
72 | margin-top: 0;
73 | gap: 2rem;
74 | padding-bottom: 0.8rem;
75 | z-index: 1;
76 | }
77 |
78 | .server-stats > * {
79 | max-width: 200px;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-todo.css:
--------------------------------------------------------------------------------
1 | .todo-widget {
2 | padding-top: 4rem;
3 | }
4 |
5 | .todo-plus-icon {
6 | --icon-color: var(--color-text-subdue);
7 | position: relative;
8 | width: 1.4rem;
9 | height: 1.4rem;
10 | }
11 |
12 | .todo-plus-icon::before, .todo-plus-icon::after {
13 | content: "";
14 | position: absolute;
15 | background-color: var(--icon-color);
16 | transition: background-color .2s;
17 | }
18 |
19 | .todo-plus-icon::before {
20 | width: 2px;
21 | inset-block: 0.2rem;
22 | left: 50%;
23 | transform: translateX(-50%);
24 | }
25 |
26 | .todo-plus-icon::after {
27 | height: 2px;
28 | inset-inline: 0.2rem;
29 | top: 50%;
30 | transform: translateY(-50%);
31 | }
32 |
33 | .todo-input textarea::placeholder {
34 | color: var(--color-text-base-muted);
35 | }
36 |
37 | .todo-input {
38 | position: relative;
39 | color: var(--color-text-highlight);
40 | }
41 |
42 | .todo-input:focus-within .todo-plus-icon {
43 | --icon-color: var(--color-text-base);
44 | }
45 |
46 | .todo-item {
47 | transform-origin: center;
48 | padding: 0.5rem 0;
49 | }
50 |
51 | .todo-item-checkbox {
52 | -webkit-appearance: none;
53 | appearance: none;
54 | border: 2px solid var(--color-text-subdue);
55 | width: 1.4rem;
56 | height: 1.4rem;
57 | position: relative;
58 | cursor: pointer;
59 | border-radius: 0.3rem;
60 | transition: border-color .2s;
61 | }
62 |
63 | .todo-item-checkbox::before {
64 | content: "";
65 | inset: -1rem;
66 | position: absolute;
67 | }
68 |
69 | .todo-item-checkbox::after {
70 | content: '';
71 | position: absolute;
72 | inset: 0.3rem;
73 | border-radius: 0.1rem;
74 | opacity: 0;
75 | transition: opacity .2s;
76 | }
77 |
78 | .todo-item-checkbox:checked::after {
79 | background: var(--color-primary);
80 | opacity: 1;
81 | }
82 |
83 | .todo-item-checkbox:focus-visible {
84 | outline: none;
85 | border-color: var(--color-primary);
86 | }
87 |
88 | .todo-item-text {
89 | color: var(--color-text-base);
90 | transition: color .35s;
91 | }
92 |
93 | .todo-item-text:focus {
94 | color: var(--color-text-highlight);
95 | }
96 |
97 | .todo-item-drag-handle {
98 | position: absolute;
99 | top: -0.5rem;
100 | inset-inline: 0;
101 | height: 1rem;
102 | cursor: grab;
103 | }
104 |
105 | .todo-item.is-being-dragged .todo-item-drag-handle {
106 | height: 3rem;
107 | top: -1.5rem;
108 | }
109 |
110 | .todo-item:has(.todo-item-checkbox:checked) .todo-item-text {
111 | text-decoration: line-through;
112 | color: var(--color-text-subdue);
113 | }
114 |
115 | .todo-item-delete {
116 | width: 1.5rem;
117 | height: 1.5rem;
118 | opacity: 0;
119 | transition: opacity .2s;
120 | outline-offset: .5rem;
121 | }
122 |
123 | .todo-item:hover .todo-item-delete, .todo-item:focus-within .todo-item-delete {
124 | opacity: 1;
125 | }
126 |
127 | .todo-item.is-being-dragged .todo-item-delete {
128 | opacity: 0;
129 | }
130 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-twitch.css:
--------------------------------------------------------------------------------
1 | .twitch-category-thumbnail {
2 | width: 5rem;
3 | aspect-ratio: 3 / 4;
4 | border-radius: var(--border-radius);
5 | }
6 |
7 | .twitch-channel-avatar {
8 | aspect-ratio: 1;
9 | border-radius: 50%;
10 | }
11 |
12 | .twitch-channel-avatar-container {
13 | width: 4.4rem;
14 | height: 4.4rem;
15 | border: 2px solid var(--color-text-subdue);
16 | padding: 2px;
17 | border-radius: 50%;
18 | position: relative;
19 | flex-shrink: 0;
20 | }
21 |
22 | .twitch-channel-live .twitch-channel-avatar-container {
23 | border: 2px solid var(--color-positive);
24 | margin-bottom: 1rem;
25 | }
26 |
27 | .twitch-channel-live .twitch-channel-avatar-container::after {
28 | content: 'LIVE';
29 | position: absolute;
30 | background: var(--color-positive);
31 | color: var(--color-widget-background);
32 | font-size: var(--font-size-h6);
33 | left: 50%;
34 | bottom: -35%;
35 | border-radius: var(--border-radius);
36 | padding-inline: 0.3rem;
37 | transform: translate(-50%);
38 | border: 2px solid var(--color-widget-background);
39 | }
40 |
41 | .twitch-stream-preview {
42 | max-width: 100%;
43 | width: 400px;
44 | aspect-ratio: 16 / 9;
45 | border-radius: var(--border-radius);
46 | object-fit: cover;
47 | }
48 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-videos.css:
--------------------------------------------------------------------------------
1 | .video-thumbnail {
2 | width: 100%;
3 | aspect-ratio: 16 / 8.9;
4 | object-fit: cover;
5 | border-radius: var(--border-radius) var(--border-radius) 0 0;
6 | }
7 |
8 | .video-horizontal-list-thumbnail {
9 | height: 4rem;
10 | aspect-ratio: 16 / 8.9;
11 | object-fit: cover;
12 | border-radius: var(--border-radius);
13 | }
14 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widget-weather.css:
--------------------------------------------------------------------------------
1 | .weather-column {
2 | position: relative;
3 | display: flex;
4 | align-items: center;
5 | justify-content: end;
6 | flex-direction: column;
7 | width: calc(100% / 12);
8 | padding-top: 3px;
9 | }
10 |
11 | .weather-column-value, .weather-columns:hover .weather-column-value {
12 | font-size: 13px;
13 | color: var(--color-text-highlight);
14 | letter-spacing: -0.1rem;
15 | margin-right: 0.1rem;
16 | position: relative;
17 | margin-bottom: 0.3rem;
18 | opacity: 0;
19 | transform: translateY(0.5rem);
20 | transition: opacity .2s, transform .2s;
21 | user-select: none;
22 | }
23 |
24 | .weather-column-current .weather-column-value, .weather-column:hover .weather-column-value {
25 | opacity: 1;
26 | transform: translateY(0);
27 | }
28 |
29 | .weather-column-value::after {
30 | position: absolute;
31 | content: '°';
32 | left: 100%;
33 | color: var(--color-text-subdue);
34 | }
35 |
36 | .weather-column-value.weather-column-value-negative::before {
37 | position: absolute;
38 | content: '-';
39 | right: 100%;
40 | }
41 |
42 | .weather-bar, .weather-columns:hover .weather-bar {
43 | height: calc(20px + var(--weather-bar-height) * 40px);
44 | width: 6px;
45 | background-color: hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 18%)));
46 | border: 1px solid hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 24%)));
47 | border-bottom: 0;
48 | border-radius: 6px 6px 0 0;
49 | mask-image: linear-gradient(0deg, transparent 0, #000 10px);
50 | -webkit-mask-image: linear-gradient(0deg, transparent 0, #000 10px);
51 | transition: background-color .2s, border-color .2s, width .2s;
52 | }
53 |
54 | .weather-column-current .weather-bar, .weather-column:hover .weather-bar {
55 | width: 10px;
56 | background-color: hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 40%)));
57 | border: 1px solid hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 50%)));
58 | }
59 |
60 | .weather-column-rain {
61 | position: absolute;
62 | inset: 0;
63 | bottom: 20%;
64 | overflow: hidden;
65 | mask-image: linear-gradient(0deg, transparent 40%, #000);
66 | -webkit-mask-image: linear-gradient(0deg, transparent 40%, #000);
67 | }
68 |
69 | .weather-column-rain::before {
70 | content: '';
71 | position: absolute;
72 | /* TODO: figure out a way to make it look continuous between columns, right now */
73 | /* depending on the width of the page the rain inside two columns next to each other */
74 | /* can overlap and look bad */
75 | background: radial-gradient(circle at 4px 4px, hsl(200, 90%, 70%, 0.4) 1px, transparent 0);
76 | background-size: 8px 8px;
77 | transform: rotate(45deg) translate(-50%, 25%);
78 | height: 130%;
79 | aspect-ratio: 1;
80 | left: 55%;
81 | }
82 |
83 | .weather-column:nth-child(3) .weather-column-time,
84 | .weather-column:nth-child(7) .weather-column-time,
85 | .weather-column:nth-child(11) .weather-column-time {
86 | opacity: 1;
87 | transform: translateY(0);
88 | }
89 |
90 | .weather-column-time, .weather-columns:hover .weather-column-time {
91 | margin-top: 0.3rem;
92 | font-size: var(--font-size-h6);
93 | opacity: 0;
94 | transform: translateY(-0.5rem);
95 | transition: opacity .2s, transform .2s;
96 | user-select: none;
97 | }
98 |
99 | .weather-column:hover .weather-column-time {
100 | opacity: 1;
101 | transform: translateY(0);
102 | }
103 |
104 | .weather-column-daylight {
105 | position: absolute;
106 | inset: 0;
107 | background: linear-gradient(0deg, transparent 30px, hsl(50, 50%, 30%, 0.2));
108 | }
109 |
110 | .weather-column-daylight-sunrise {
111 | border-radius: 20px 0 0 0;
112 | }
113 |
114 | .weather-column-daylight-sunset {
115 | border-radius: 0 20px 0 0;
116 | }
117 |
118 | .location-icon {
119 | width: 0.8em;
120 | height: 0.8em;
121 | border-radius: 0 50% 50% 50%;
122 | background-color: currentColor;
123 | transform: rotate(225deg) translate(.1em, .1em);
124 | position: relative;
125 | flex-shrink: 0;
126 | }
127 |
128 | .location-icon::after {
129 | content: '';
130 | position: absolute;
131 | z-index: 2;
132 | width: .4em;
133 | height: .4em;
134 | border-radius: 50%;
135 | background-color: var(--color-widget-background);
136 | top: 50%;
137 | left: 50%;
138 | transform: translate(-50%, -50%);
139 | }
140 |
--------------------------------------------------------------------------------
/internal/glance/static/css/widgets.css:
--------------------------------------------------------------------------------
1 | @import "widget-bookmarks.css";
2 | @import "widget-calendar.css";
3 | @import "widget-clock.css";
4 | @import "widget-dns-stats.css";
5 | @import "widget-docker-containers.css";
6 | @import "widget-group.css";
7 | @import "widget-markets.css";
8 | @import "widget-monitor.css";
9 | @import "widget-reddit.css";
10 | @import "widget-releases.css";
11 | @import "widget-rss.css";
12 | @import "widget-search.css";
13 | @import "widget-server-stats.css";
14 | @import "widget-twitch.css";
15 | @import "widget-videos.css";
16 | @import "widget-weather.css";
17 | @import "widget-todo.css";
18 |
19 | @import "forum-posts.css";
20 |
21 | .widget-error-header {
22 | display: flex;
23 | align-items: center;
24 | justify-content: space-between;
25 | position: relative;
26 | margin-bottom: 1.8rem;
27 | z-index: 1;
28 | }
29 |
30 | .widget-error-header::before {
31 | content: '';
32 | position: absolute;
33 | inset: calc(0rem - (var(--widget-content-vertical-padding) / 2)) calc(0rem - (var(--widget-content-horizontal-padding) / 2));
34 | background: var(--color-negative);
35 | opacity: 0.05;
36 | border-radius: var(--border-radius);
37 | z-index: -1;
38 | }
39 |
40 | .widget-error-icon {
41 | width: 2.4rem;
42 | height: 2.4rem;
43 | flex-shrink: 0;
44 | stroke: var(--color-negative);
45 | opacity: 0.6;
46 | }
47 |
48 | .head-widgets {
49 | margin-bottom: var(--widget-gap);
50 | }
51 |
52 | .widget-content {
53 | container-type: inline-size;
54 | container-name: widget;
55 | }
56 |
57 | .widget-content:not(.widget-content-frameless) {
58 | padding: var(--widget-content-padding);
59 | }
60 |
61 | .widget-content:not(.widget-content-frameless), .widget-content-frame {
62 | background: var(--color-widget-background);
63 | border-radius: var(--border-radius);
64 | border: 1px solid var(--color-widget-content-border);
65 | box-shadow: 0px 3px 0px 0px hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl)) - 0.5%));
66 | }
67 |
68 | .widget-header {
69 | padding: 0 calc(var(--widget-content-horizontal-padding) + 1px);
70 | font-size: var(--font-size-h4);
71 | margin-bottom: 0.9rem;
72 | display: flex;
73 | align-items: center;
74 | gap: 1rem;
75 | }
76 |
77 | .widget-beta-icon {
78 | width: 1.6rem;
79 | height: 1.6rem;
80 | flex-shrink: 0;
81 | transition: transform .45s, opacity .45s, stroke .45s;
82 | opacity: 0.7;
83 | }
84 |
85 | .widget-beta-icon:hover, .widget-header .popover-active > .widget-beta-icon {
86 | fill: var(--color-text-highlight);
87 | transform: translateY(-10%) scale(1.3);
88 | opacity: 1;
89 | }
90 |
91 | .widget + .widget {
92 | margin-top: var(--widget-gap);
93 | }
94 |
--------------------------------------------------------------------------------
/internal/glance/static/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/internal/glance/static/favicon.png
--------------------------------------------------------------------------------
/internal/glance/static/favicon.svg:
--------------------------------------------------------------------------------
1 | <svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
2 | <rect width="26" height="26" rx="3" fill="#151519"/>
3 | <rect x="2" y="2" width="10" height="22" rx="2" fill="#ededed"/>
4 | <rect x="14" y="2" width="10" height="10" rx="2" fill="#ededed"/>
5 | <path d="M16.3018 5.04032L17.328 4H22V8.72984L20.9014 9.81855V6.49193C20.9014 6.35484 20.9095 6.21774 20.9256 6.08065C20.9497 5.93548 20.9859 5.81855 21.0342 5.72984L16.7847 10L16 9.2379L20.3099 4.93145C20.2294 4.97984 20.1167 5.0121 19.9718 5.02823C19.827 5.03629 19.674 5.04032 19.5131 5.04032H16.3018Z" fill="#151519"/>
6 | <rect x="14" y="14" width="10" height="10" rx="2" fill="#ededed"/>
7 | </svg>
8 |
--------------------------------------------------------------------------------
/internal/glance/static/fonts/JetBrainsMono-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glanceapp/glance/c88fd526e55117445c7f4440c83b661faa402047/internal/glance/static/fonts/JetBrainsMono-Regular.woff2
--------------------------------------------------------------------------------
/internal/glance/static/icons/codeberg.svg:
--------------------------------------------------------------------------------
1 | <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.955.49A12 12 0 0 0 0 12.49a12 12 0 0 0 1.832 6.373L11.838 5.928a.187.14 0 0 1 .324 0l10.006 12.935A12 12 0 0 0 24 12.49a12 12 0 0 0-12-12 12 12 0 0 0-.045 0zm.375 6.467l4.416 16.553a12 12 0 0 0 5.137-4.213z"/></svg>
2 |
--------------------------------------------------------------------------------
/internal/glance/static/icons/dockerhub.svg:
--------------------------------------------------------------------------------
1 | <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/></svg>
2 |
--------------------------------------------------------------------------------
/internal/glance/static/icons/github.svg:
--------------------------------------------------------------------------------
1 | <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
2 |
--------------------------------------------------------------------------------
/internal/glance/static/icons/gitlab.svg:
--------------------------------------------------------------------------------
1 | <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"/></svg>
2 |
--------------------------------------------------------------------------------
/internal/glance/static/js/animations.js:
--------------------------------------------------------------------------------
1 | export const easeOutQuint = 'cubic-bezier(0.22, 1, 0.36, 1)';
2 |
3 | export function directions(anim, opt, ...dirs) {
4 | return dirs.map(dir => anim({ direction: dir, ...opt }));
5 | }
6 |
7 | export function slideFade({
8 | direction = 'left',
9 | fill = 'backwards',
10 | duration = 200,
11 | distance = '1rem',
12 | easing = 'ease',
13 | offset = 0,
14 | }) {
15 | const axis = direction === 'left' || direction === 'right' ? 'X' : 'Y';
16 | const negative = direction === 'left' || direction === 'up' ? '-' : '';
17 | const amount = negative + distance;
18 |
19 | return {
20 | keyframes: [
21 | {
22 | offset: offset,
23 | opacity: 0,
24 | transform: `translate${axis}(${amount})`,
25 | }
26 | ],
27 | options: {
28 | duration: duration,
29 | easing: easing,
30 | fill: fill,
31 | },
32 | };
33 | }
34 |
35 |
36 | export function animateReposition(
37 | element,
38 | onAnimEnd,
39 | animOptions = { duration: 400, easing: easeOutQuint }
40 | ) {
41 | const rectBefore = element.getBoundingClientRect();
42 |
43 | return () => {
44 | const rectAfter = element.getBoundingClientRect();
45 | const offsetY = rectBefore.y - rectAfter.y;
46 | const offsetX = rectBefore.x - rectAfter.x;
47 |
48 | element.animate({
49 | keyframes: [
50 | { transform: `translate(${offsetX}px, ${offsetY}px)` },
51 | { transform: 'none' }
52 | ],
53 | options: animOptions
54 | }, onAnimEnd);
55 |
56 | return rectAfter;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/internal/glance/static/js/login.js:
--------------------------------------------------------------------------------
1 | import { find } from "./templating.js";
2 |
3 | const AUTH_ENDPOINT = pageData.baseURL + "/api/authenticate";
4 |
5 | const showPasswordSVG = `<svg class="form-input-icon" stroke="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
6 | <path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
7 | </svg>`;
8 |
9 | const hidePasswordSVG = `<svg class="form-input-icon" stroke="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
10 | <path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
11 | <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
12 | </svg>`;
13 |
14 | const container = find("#login-container");
15 | const usernameInput = find("#username");
16 | const passwordInput = find("#password");
17 | const errorMessage = find("#error-message");
18 | const loginButton = find("#login-button");
19 | const toggleVisibilityButton = find("#toggle-password-visibility");
20 |
21 | const state = {
22 | lastUsername: "",
23 | lastPassword: "",
24 | isLoading: false,
25 | isRateLimited: false
26 | };
27 |
28 | const lang = {
29 | showPassword: "Show password",
30 | hidePassword: "Hide password",
31 | incorrectCredentials: "Incorrect username or password",
32 | rateLimited: "Too many login attempts, try again in a few minutes",
33 | unknownError: "An error occurred, please try again",
34 | };
35 |
36 | container.clearStyles("display");
37 | setTimeout(() => usernameInput.focus(), 200);
38 |
39 | toggleVisibilityButton
40 | .html(showPasswordSVG)
41 | .attr("title", lang.showPassword)
42 | .on("click", function() {
43 | if (passwordInput.type === "password") {
44 | passwordInput.type = "text";
45 | toggleVisibilityButton.html(hidePasswordSVG).attr("title", lang.hidePassword);
46 | return;
47 | }
48 |
49 | passwordInput.type = "password";
50 | toggleVisibilityButton.html(showPasswordSVG).attr("title", lang.showPassword);
51 | });
52 |
53 | function enableLoginButtonIfCriteriaMet() {
54 | const usernameValue = usernameInput.value.trim();
55 | const passwordValue = passwordInput.value.trim();
56 |
57 | const usernameValid = usernameValue.length >= 3;
58 | const passwordValid = passwordValue.length >= 6;
59 |
60 | const isUsingLastCredentials =
61 | usernameValue === state.lastUsername
62 | && passwordValue === state.lastPassword;
63 |
64 | loginButton.disabled = !(
65 | usernameValid
66 | && passwordValid
67 | && !isUsingLastCredentials
68 | && !state.isLoading
69 | && !state.isRateLimited
70 | );
71 | }
72 |
73 | usernameInput.on("input", enableLoginButtonIfCriteriaMet);
74 | passwordInput.on("input", enableLoginButtonIfCriteriaMet);
75 |
76 | async function handleLoginAttempt() {
77 | state.lastUsername = usernameInput.value;
78 | state.lastPassword = passwordInput.value;
79 | errorMessage.text("");
80 |
81 | loginButton.disable();
82 | state.isLoading = true;
83 |
84 | const response = await fetch(AUTH_ENDPOINT, {
85 | method: "POST",
86 | headers: {
87 | "Content-Type": "application/json"
88 | },
89 | body: JSON.stringify({
90 | username: usernameInput.value,
91 | password: passwordInput.value
92 | }),
93 | });
94 |
95 | state.isLoading = false;
96 | if (response.status === 200) {
97 | setTimeout(() => { window.location.href = pageData.baseURL + "/"; }, 300);
98 |
99 | container.animate({
100 | keyframes: [{ offset: 1, transform: "scale(0.95)", opacity: 0 }],
101 | options: { duration: 300, easing: "ease", fill: "forwards" }}
102 | );
103 |
104 | find("footer")?.animate({
105 | keyframes: [{ offset: 1, opacity: 0 }],
106 | options: { duration: 300, easing: "ease", fill: "forwards", delay: 50 }
107 | });
108 | } else if (response.status === 401) {
109 | errorMessage.text(lang.incorrectCredentials);
110 | passwordInput.focus();
111 | } else if (response.status === 429) {
112 | errorMessage.text(lang.rateLimited);
113 | state.isRateLimited = true;
114 | const retryAfter = response.headers.get("Retry-After") || 30;
115 | setTimeout(() => {
116 | state.lastUsername = "";
117 | state.lastPassword = "";
118 | state.isRateLimited = false;
119 |
120 | enableLoginButtonIfCriteriaMet();
121 | }, retryAfter * 1000);
122 | } else {
123 | errorMessage.text(lang.unknownError);
124 | passwordInput.focus();
125 | }
126 | }
127 |
128 | loginButton.disable().on("click", handleLoginAttempt);
129 |
--------------------------------------------------------------------------------
/internal/glance/static/js/masonry.js:
--------------------------------------------------------------------------------
1 |
2 | import { clamp } from "./utils.js";
3 |
4 | export function setupMasonries() {
5 | const masonryContainers = document.getElementsByClassName("masonry");
6 |
7 | for (let i = 0; i < masonryContainers.length; i++) {
8 | const container = masonryContainers[i];
9 |
10 | const options = {
11 | minColumnWidth: container.dataset.minColumnWidth || 330,
12 | maxColumns: container.dataset.maxColumns || 6,
13 | };
14 |
15 | const items = Array.from(container.children);
16 | let previousColumnsCount = 0;
17 |
18 | const render = function() {
19 | const columnsCount = clamp(
20 | Math.floor(container.offsetWidth / options.minColumnWidth),
21 | 1,
22 | Math.min(options.maxColumns, items.length)
23 | );
24 |
25 | if (columnsCount === previousColumnsCount) {
26 | return;
27 | } else {
28 | container.textContent = "";
29 | previousColumnsCount = columnsCount;
30 | }
31 |
32 | const columnsFragment = document.createDocumentFragment();
33 |
34 | for (let i = 0; i < columnsCount; i++) {
35 | const column = document.createElement("div");
36 | column.className = "masonry-column";
37 | columnsFragment.append(column);
38 | }
39 |
40 | for (let i = 0; i < items.length; i++) {
41 | columnsFragment.children[i % columnsCount].appendChild(items[i]);
42 | }
43 |
44 | container.append(columnsFragment);
45 | };
46 |
47 | const observer = new ResizeObserver(() => requestAnimationFrame(render));
48 | observer.observe(container);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/internal/glance/static/js/templating.js:
--------------------------------------------------------------------------------
1 | export function elem(tag = "div") {
2 | return document.createElement(tag);
3 | }
4 |
5 | export function fragment(...children) {
6 | const f = document.createDocumentFragment();
7 | if (children) f.append(...children);
8 | return f;
9 | }
10 |
11 | export function text(str = "") {
12 | return document.createTextNode(str);
13 | }
14 |
15 | export function repeat(n, fn) {
16 | const elems = Array(n);
17 |
18 | for (let i = 0; i < n; i++)
19 | elems[i] = fn(i);
20 |
21 | return elems;
22 | }
23 |
24 | export function find(selector) {
25 | return document.querySelector(selector);
26 | }
27 |
28 | export function findAll(selector) {
29 | return document.querySelectorAll(selector);
30 | }
31 |
32 |
33 | HTMLCollection.prototype.map = function(fn) {
34 | return Array.from(this).map(fn);
35 | }
36 |
37 | HTMLCollection.prototype.indexOf = function(element) {
38 | return Array.prototype.indexOf.call(this, element);
39 | }
40 |
41 | const ep = HTMLElement.prototype;
42 | const fp = DocumentFragment.prototype;
43 | const tp = Text.prototype;
44 |
45 | ep.classes = function(...classes) {
46 | this.classList.add(...classes);
47 | return this;
48 | }
49 |
50 | ep.find = function(selector) {
51 | return this.querySelector(selector);
52 | }
53 |
54 | ep.findAll = function(selector) {
55 | return this.querySelectorAll(selector);
56 | }
57 |
58 | ep.classesIf = function(cond, ...classes) {
59 | cond ? this.classList.add(...classes) : this.classList.remove(...classes);
60 | return this;
61 | }
62 |
63 | ep.hide = function() {
64 | this.style.display = "none";
65 | return this;
66 | }
67 |
68 | ep.show = function() {
69 | this.style.removeProperty("display");
70 | return this;
71 | }
72 |
73 | ep.showIf = function(cond) {
74 | cond ? this.show() : this.hide();
75 | return this;
76 | }
77 |
78 | ep.isHidden = function() {
79 | return this.style.display === "none";
80 | }
81 |
82 | ep.clearClasses = function(...classes) {
83 | classes.length ? this.classList.remove(...classes) : this.className = "";
84 | return this;
85 | }
86 |
87 | ep.hasClass = function(className) {
88 | return this.classList.contains(className);
89 | }
90 |
91 | ep.attr = function(name, value) {
92 | this.setAttribute(name, value);
93 | return this;
94 | }
95 |
96 | ep.attrs = function(attrs) {
97 | for (const [name, value] of Object.entries(attrs))
98 | this.setAttribute(name, value);
99 | return this;
100 | }
101 |
102 | ep.tap = function(fn) {
103 | fn(this);
104 | return this;
105 | }
106 |
107 | ep.text = function(text) {
108 | this.innerText = text;
109 | return this;
110 | }
111 |
112 | ep.html = function(html) {
113 | this.innerHTML = html;
114 | return this;
115 | }
116 |
117 | ep.appendTo = function(parent) {
118 | parent.appendChild(this);
119 | return this;
120 | }
121 |
122 | ep.swapWith = function(element) {
123 | this.replaceWith(element);
124 | return element;
125 | }
126 |
127 | ep.on = function(event, callback, options) {
128 | if (typeof event === "string") {
129 | this.addEventListener(event, callback, options);
130 | return this;
131 | }
132 |
133 | for (let i = 0; i < event.length; i++)
134 | this.addEventListener(event[i], callback, options);
135 |
136 | return this;
137 | }
138 |
139 | const epAppend = ep.append;
140 | ep.append = function(...children) {
141 | epAppend.apply(this, children);
142 | return this;
143 | }
144 |
145 | ep.duplicate = function(n) {
146 | const elems = Array(n);
147 |
148 | for (let i = 0; i < n; i++)
149 | elems[i] = this.cloneNode(true);
150 |
151 | return elems;
152 | }
153 |
154 | ep.styles = function(s) {
155 | Object.assign(this.style, s);
156 | return this;
157 | }
158 |
159 | ep.clearStyles = function(...props) {
160 | for (let i = 0; i < props.length; i++)
161 | this.style.removeProperty(props[i]);
162 | return this;
163 | }
164 |
165 | ep.disable = function() {
166 | this.disabled = true;
167 | return this;
168 | }
169 |
170 | ep.enable = function() {
171 | this.disabled = false;
172 | return this;
173 | }
174 |
175 | const epAnimate = ep.animate;
176 | ep.animate = function(anim, callback) {
177 | const a = epAnimate.call(this, anim.keyframes, anim.options);
178 | if (callback) a.onfinish = () => callback(this, a);
179 | return this;
180 | }
181 |
182 | ep.animateUpdate = function(update, exit, entrance) {
183 | this.animate(exit, () => {
184 | update(this);
185 | this.animate(entrance);
186 | });
187 |
188 | return this;
189 | }
190 |
191 | ep.styleVar = function(name, value) {
192 | this.style.setProperty(`--${name}`, value);
193 | return this;
194 | }
195 |
196 | ep.component = function (methods) {
197 | this.component = methods;
198 | return this;
199 | }
200 |
201 | const fpAppend = fp.append;
202 | fp.append = function(...children) {
203 | fpAppend.apply(this, children);
204 | return this;
205 | }
206 |
207 | fp.appendTo = function(parent) {
208 | parent.appendChild(this);
209 | return this;
210 | }
211 |
212 | tp.text = function(text) {
213 | this.nodeValue = text;
214 | return this;
215 | }
216 |
--------------------------------------------------------------------------------
/internal/glance/static/js/utils.js:
--------------------------------------------------------------------------------
1 | export function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
2 | let debounceTimeout;
3 | let timesDebounced = 0;
4 |
5 | return function () {
6 | if (timesDebounced == maxDebounceTimes) {
7 | clearTimeout(debounceTimeout);
8 | timesDebounced = 0;
9 | callback();
10 | return;
11 | }
12 |
13 | clearTimeout(debounceTimeout);
14 | timesDebounced++;
15 |
16 | debounceTimeout = setTimeout(() => {
17 | timesDebounced = 0;
18 | callback();
19 | }, debounceDelay);
20 | };
21 | };
22 |
23 | export function isElementVisible(element) {
24 | return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
25 | }
26 |
27 | export function clamp(value, min, max) {
28 | return Math.min(Math.max(value, min), max);
29 | }
30 |
31 | // NOTE: inconsistent behavior between browsers when it comes to
32 | // whether the newly opened tab gets focused or not, potentially
33 | // depending on the event that this function is called from
34 | export function openURLInNewTab(url, focus = true) {
35 | const newWindow = window.open(url, '_blank', 'noopener,noreferrer');
36 |
37 | if (focus && newWindow != null) newWindow.focus();
38 | }
39 |
40 |
41 | export class Vec2 {
42 | constructor(x, y) {
43 | this.x = x;
44 | this.y = y;
45 | }
46 |
47 | static new(x = 0, y = 0) {
48 | return new Vec2(x, y);
49 | }
50 |
51 | static fromEvent(event) {
52 | return new Vec2(event.clientX, event.clientY);
53 | }
54 |
55 | setFromEvent(event) {
56 | this.x = event.clientX;
57 | this.y = event.clientY;
58 | return this;
59 | }
60 |
61 | set(x, y) {
62 | this.x = x;
63 | this.y = y;
64 | return this;
65 | }
66 | }
67 |
68 | export function toggleableEvents(element, eventToHandlerMap) {
69 | return [
70 | () => {
71 | for (const [event, handler] of Object.entries(eventToHandlerMap)) {
72 | element.addEventListener(event, handler);
73 | }
74 | },
75 | () => {
76 | for (const [event, handler] of Object.entries(eventToHandlerMap)) {
77 | element.removeEventListener(event, handler);
78 | }
79 | }
80 | ];
81 | }
82 |
--------------------------------------------------------------------------------
/internal/glance/templates.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "fmt"
5 | "html/template"
6 | "math"
7 | "strconv"
8 |
9 | "golang.org/x/text/language"
10 | "golang.org/x/text/message"
11 | )
12 |
13 | var intl = message.NewPrinter(language.English)
14 |
15 | var globalTemplateFunctions = template.FuncMap{
16 | "formatApproxNumber": formatApproxNumber,
17 | "formatNumber": intl.Sprint,
18 | "safeCSS": func(str string) template.CSS {
19 | return template.CSS(str)
20 | },
21 | "safeURL": func(str string) template.URL {
22 | return template.URL(str)
23 | },
24 | "safeHTML": func(str string) template.HTML {
25 | return template.HTML(str)
26 | },
27 | "absInt": func(i int) int {
28 | return int(math.Abs(float64(i)))
29 | },
30 | "formatPrice": func(price float64) string {
31 | return intl.Sprintf("%.2f", price)
32 | },
33 | "formatPriceWithPrecision": func(precision int, price float64) string {
34 | return intl.Sprintf("%."+strconv.Itoa(precision)+"f", price)
35 | },
36 | "dynamicRelativeTimeAttrs": dynamicRelativeTimeAttrs,
37 | "formatServerMegabytes": func(mb uint64) template.HTML {
38 | var value string
39 | var label string
40 |
41 | if mb < 1_000 {
42 | value = strconv.FormatUint(mb, 10)
43 | label = "MB"
44 | } else if mb < 1_000_000 {
45 | if mb < 10_000 {
46 | value = fmt.Sprintf("%.1f", float64(mb)/1_000)
47 | } else {
48 | value = strconv.FormatUint(mb/1_000, 10)
49 | }
50 |
51 | label = "GB"
52 | } else {
53 | value = fmt.Sprintf("%.1f", float64(mb)/1_000_000)
54 | label = "TB"
55 | }
56 |
57 | return template.HTML(value + ` <span class="color-base size-h5">` + label + `</span>`)
58 | },
59 | }
60 |
61 | func mustParseTemplate(primary string, dependencies ...string) *template.Template {
62 | t, err := template.New(primary).
63 | Funcs(globalTemplateFunctions).
64 | ParseFS(templateFS, append([]string{primary}, dependencies...)...)
65 |
66 | if err != nil {
67 | panic(err)
68 | }
69 |
70 | return t
71 | }
72 |
73 | func formatApproxNumber(count int) string {
74 | if count < 1_000 {
75 | return strconv.Itoa(count)
76 | }
77 |
78 | if count < 10_000 {
79 | return strconv.FormatFloat(float64(count)/1_000, 'f', 1, 64) + "k"
80 | }
81 |
82 | if count < 1_000_000 {
83 | return strconv.Itoa(count/1_000) + "k"
84 | }
85 |
86 | return strconv.FormatFloat(float64(count)/1_000_000, 'f', 1, 64) + "m"
87 | }
88 |
89 | func dynamicRelativeTimeAttrs(t interface{ Unix() int64 }) template.HTMLAttr {
90 | return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`)
91 | }
92 |
--------------------------------------------------------------------------------
/internal/glance/templates/bookmarks.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | <div class="dynamic-columns list-gap-24 list-with-separator">
5 | {{- range .Groups }}
6 | <div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.String | safeCSS }}"{{ end }}>
7 | {{- if ne .Title "" }}
8 | <div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>
9 | {{- end }}
10 | <ul class="list list-gap-2">
11 | {{- range .Links }}
12 | <li>
13 | <div class="flex items-center gap-10">
14 | {{- if ne "" .Icon.URL }}
15 | <div class="bookmarks-icon-container">
16 | <img class="bookmarks-icon{{ if .Icon.AutoInvert }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
17 | </div>
18 | {{- end }}
19 | <a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">{{ .Title }}</a>
20 | </div>
21 | {{- if .Description }}
22 | <div class="margin-bottom-5">{{ .Description }}</div>
23 | {{- end }}
24 | </li>
25 | {{- end }}
26 | </ul>
27 | </div>
28 | {{- end }}
29 | </div>
30 | {{ end }}
31 |
--------------------------------------------------------------------------------
/internal/glance/templates/calendar.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | <div class="widget-small-content-bounds">
5 | <div class="calendar" data-first-day-of-week="{{ .FirstDay }}"></div>
6 | </div>
7 | {{ end }}
8 |
--------------------------------------------------------------------------------
/internal/glance/templates/change-detection.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | <ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
5 | {{ range .ChangeDetections }}
6 | <li>
7 | <a class="size-h4 block text-truncate color-highlight" href="{{ .URL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
8 | <ul class="list-horizontal-text">
9 | <li {{ dynamicRelativeTimeAttrs .LastChanged }}></li>
10 | <li class="shrink min-width-0"><a class="visited-indicator" href="{{ .DiffURL }}" target="_blank" rel="noreferrer">diff:{{ .PreviousHash }}</a></li>
11 | </ul>
12 | </li>
13 | {{ else }}
14 | <li>No watches configured</li>
15 | {{ end}}
16 | </ul>
17 | {{ end }}
18 |
--------------------------------------------------------------------------------
/internal/glance/templates/clock.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | <div class="clock" data-hour-format="{{ .HourFormat }}">
5 | <div class="flex justify-between items-center" data-local-time>
6 | <div>
7 | <div class="color-highlight size-h1" data-date></div>
8 | <div data-year></div>
9 | </div>
10 | <div class="text-right">
11 | <div class="clock-time size-h1" data-time></div>
12 | <div data-weekday></div>
13 | </div>
14 | </div>
15 | {{ if gt (len .Timezones) 0 }}
16 | <hr class="margin-block-10">
17 | <ul class="list list-gap-4">
18 | {{ range .Timezones }}
19 | <li class="flex items-center gap-15" data-time-in-zone="{{ .Timezone }}">
20 | <div class="grow min-width-0">
21 | <div class="text-truncate">{{ if ne .Label "" }}{{ .Label }}{{ else }}{{ .Timezone }}{{ end }}</div>
22 | </div>
23 | <div class="color-subdue" data-time-diff></div>
24 | <div class="size-h4 clock-time shrink-0 text-right" data-time></div>
25 | </li>
26 | {{ end }}
27 | </ul>
28 | {{ end }}
29 | </div>
30 | {{ end }}
31 |
--------------------------------------------------------------------------------
/internal/glance/templates/custom-api.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content-classes" }}{{ if .Frameless }}widget-content-frameless{{ end }}{{ end }}
4 |
5 | {{ define "widget-content" }}
6 | {{ .CompiledHTML }}
7 | {{ end }}
8 |
--------------------------------------------------------------------------------
/internal/glance/templates/dns-stats.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | <div class="widget-small-content-bounds dns-stats">
5 | <div class="flex text-center justify-between dns-stats-totals">
6 | <div>
7 | <div class="color-highlight size-h3">{{ .Stats.TotalQueries | formatNumber }}</div>
8 | <div class="size-h6">QUERIES</div>
9 | </div>
10 | <div>
11 | <div class="color-highlight size-h3">{{ .Stats.BlockedPercent }}%</div>
12 | <div class="size-h6">BLOCKED</div>
13 | </div>
14 | {{ if gt .Stats.ResponseTime 0 }}
15 | <div>
16 | <div class="color-highlight size-h3">{{ .Stats.ResponseTime | formatNumber }}ms</div>
17 | <div class="size-h6">LATENCY</div>
18 | </div>
19 | {{ else }}
20 | <div class="cursor-help" data-popover-type="text" data-popover-text="Total number of blocked domains from all adlists" data-popover-max-width="200px" data-popover-text-align="center">
21 | <div class="color-highlight size-h3">{{ .Stats.DomainsBlocked | formatApproxNumber }}</div>
22 | <div class="size-h6">DOMAINS</div>
23 | </div>
24 | {{ end }}
25 | </div>
26 |
27 | {{ $showGraph := not (or .HideGraph (eq (len .Stats.Series) 0)) }}
28 | {{ if $showGraph }}
29 | <div class="dns-stats-graph margin-top-15">
30 | <div class="dns-stats-graph-gridlines-container">
31 | <svg class="dns-stats-graph-gridlines" shape-rendering="crispEdges" viewBox="0 0 1 100" preserveAspectRatio="none">
32 | <g stroke="var(--color-graph-gridlines)" stroke-width="1">
33 | <line x1="0" y1="1" x2="1" y2="1" vector-effect="non-scaling-stroke" />
34 | <line x1="0" y1="25" x2="1" y2="25" vector-effect="non-scaling-stroke" />
35 | <line x1="0" y1="50" x2="1" y2="50" vector-effect="non-scaling-stroke" />
36 | <line x1="0" y1="75" x2="1" y2="75" vector-effect="non-scaling-stroke" />
37 | <line x1="0" y1="99" x2="1" y2="99" vector-effect="non-scaling-stroke" stroke="var(--color-progress-bar-border)"/>
38 | </g>
39 | </svg>
40 | </div>
41 |
42 | <div class="dns-stats-graph-columns">
43 | {{ range $i, $column := .Stats.Series }}
44 | <div class="dns-stats-graph-column" data-popover-type="html" data-popover-position="above" data-popover-show-delay="500">
45 | <div data-popover-html>
46 | <div class="flex text-center justify-between gap-25">
47 | <div>
48 | <div class="color-highlight size-h3">{{ $column.Queries | formatNumber }}</div>
49 | <div class="size-h6">QUERIES</div>
50 | </div>
51 | <div>
52 | <div class="color-highlight size-h3">{{ $column.PercentBlocked }}%</div>
53 | <div class="size-h6">BLOCKED</div>
54 | </div>
55 | </div>
56 | </div>
57 | {{ if gt $column.PercentTotal 0}}
58 | <div class="dns-stats-graph-bar" style="--bar-height: {{ $column.PercentTotal }}">
59 | {{ if ne $column.Queries $column.Blocked }}
60 | <div class="queries"></div>
61 | {{ end }}
62 | {{ if gt $column.PercentBlocked 0 }}
63 | <div class="blocked" style="--percent: {{ $column.PercentBlocked }}%"></div>
64 | {{ end }}
65 | </div>
66 | {{ end }}
67 | <div class="dns-stats-graph-time">{{ index $.TimeLabels $i }}</div>
68 | </div>
69 | {{ end }}
70 | </div>
71 | </div>
72 | {{ end }}
73 |
74 | {{ if and (not .HideTopDomains) .Stats.TopBlockedDomains }}
75 | <details class="details {{ if $showGraph }}margin-top-40{{ else }}margin-top-15{{ end }}">
76 | <summary class="summary">Top blocked domains</summary>
77 | <ul class="list list-gap-4 list-with-transition size-h5">
78 | {{ range .Stats.TopBlockedDomains }}
79 | <li class="flex justify-between gap-10">
80 | <div class="text-truncate rtl">{{ .Domain }}</div>
81 | <div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
82 | </li>
83 | {{ end }}
84 | </ul>
85 | </details>
86 | {{ end }}
87 | </div>
88 | {{ end }}
89 |
--------------------------------------------------------------------------------
/internal/glance/templates/docker-containers.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{- define "widget-content" }}
4 | <ul class="dynamic-columns list-gap-20 list-with-separator">
5 | {{- range .Containers }}
6 | <li class="docker-container flex items-center gap-15">
7 | <div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem" data-popover-max-width="400px" aria-hidden="true">
8 | <img class="docker-container-icon{{ if .Icon.AutoInvert }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
9 | <div data-popover-html>
10 | <div class="color-highlight text-truncate block">{{ .Image }}</div>
11 | <div>{{ .StateText }}</div>
12 | {{- if .Children }}
13 | <ul class="list list-gap-4 margin-top-10">
14 | {{- range .Children }}
15 | <li class="flex gap-7 items-center">
16 | <div class="margin-bottom-3">{{ template "state-icon" .StateIcon }}</div>
17 | <div class="color-highlight">{{ .Name }} <span class="size-h5 color-base">{{ .StateText }}</span></div>
18 | </li>
19 | {{- end }}
20 | </ul>
21 | {{- end }}
22 | </div>
23 | </div>
24 |
25 | <div class="min-width-0 grow">
26 | {{- if .URL }}
27 | <a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Name }}</a>
28 | {{- else }}
29 | <div class="color-highlight text-truncate size-title-dynamic">{{ .Name }}</div>
30 | {{- end }}
31 | {{- if .Description }}
32 | <div class="text-truncate">{{ .Description }}</div>
33 | {{- end }}
34 | </div>
35 |
36 | <div class="margin-left-auto shrink-0" data-popover-type="text" data-popover-position="above" data-popover-text="{{ .State }}" aria-label="{{ .State }}">
37 | {{ template "state-icon" .StateIcon }}
38 | </div>
39 |
40 | <div class="visually-hidden" aria-label="{{ .StateText }}"></div>
41 | </li>
42 | {{- else }}
43 | <div class="text-center">No containers available to show.</div>
44 | {{- end }}
45 | </ul>
46 | {{- end }}
47 |
48 | {{- define "state-icon" }}
49 | {{- if eq . "ok" }}
50 | <svg class="docker-container-status-icon" fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
51 | <path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
52 | </svg>
53 | {{- else if eq . "warn" }}
54 | <svg class="docker-container-status-icon" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
55 | <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
56 | </svg>
57 | {{- else if eq . "paused" }}
58 | <svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
59 | <path fill-rule="evenodd" d="M2 10a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm5-2.25A.75.75 0 0 1 7.75 7h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Zm4 0a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Z" clip-rule="evenodd" />
60 | </svg>
61 | {{- else }}
62 | <svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
63 | <path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.94 6.94a.75.75 0 1 1-1.061-1.061 3 3 0 1 1 2.871 5.026v.345a.75.75 0 0 1-1.5 0v-.5c0-.72.57-1.172 1.081-1.287A1.5 1.5 0 1 0 8.94 6.94ZM10 15a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
64 | </svg>
65 | {{- end }}
66 | {{- end }}
67 |
--------------------------------------------------------------------------------
/internal/glance/templates/document.html:
--------------------------------------------------------------------------------
1 | <!DOCTYPE html>
2 | <html lang="en" id="top" data-theme="{{ .Request.Theme.Key }}" data-scheme="{{ if .Request.Theme.Light }}light{{ else }}dark{{ end }}">
3 | <head>
4 | {{ block "document-head-before" . }}{{ end }}
5 | <script>
6 | if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');
7 | const pageData = {
8 | /*{{ if .Page }}*/slug: "{{ .Page.Slug }}",/*{{ end }}*/
9 | baseURL: "{{ .App.Config.Server.BaseURL }}",
10 | theme: "{{ .Request.Theme.Key }}",
11 | };
12 | </script>
13 | <title>{{ block "document-title" . }}{{ end }}</title>
14 | <meta charset="UTF-8">
15 | <meta name="color-scheme" content="dark">
16 | <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
17 | <meta name="apple-mobile-web-app-capable" content="yes">
18 | <meta name="mobile-web-app-capable" content="yes">
19 | <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
20 | <meta name="apple-mobile-web-app-title" content="{{ .App.Config.Branding.AppName }}">
21 | <meta name="theme-color" content="{{ .Request.Theme.BackgroundColorAsHex }}">
22 | <link rel="apple-touch-icon" sizes="512x512" href='{{ .App.Config.Branding.AppIconURL }}'>
23 | <link rel="manifest" href='{{ .App.VersionedAssetPath "manifest.json" }}'>
24 | <link rel="icon" type="{{ .App.Config.Branding.FaviconType }}" href="{{ .App.Config.Branding.FaviconURL }}" />
25 | <link rel="stylesheet" href='{{ .App.StaticAssetPath "css/bundle.css" }}'>
26 | <style id="theme-style">{{ .Request.Theme.CSS }}</style>
27 | {{ if .App.Config.Theme.CustomCSSFile }}<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.CreatedAt.Unix }}">{{ end }}
28 | {{ block "document-head-after" . }}{{ end }}
29 | {{ if .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
30 | </head>
31 | <body>
32 | {{ template "document-body" . }}
33 | </body>
34 | </html>
35 |
--------------------------------------------------------------------------------
/internal/glance/templates/extension.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content-classes" }}{{ if .Extension.Frameless }}widget-content-frameless{{ end }}{{ end }}
4 |
5 | {{ define "widget-content" }}
6 | {{ .Extension.Content }}
7 | {{ end }}
8 |
--------------------------------------------------------------------------------
/internal/glance/templates/footer.html:
--------------------------------------------------------------------------------
1 | {{ if not .App.Config.Branding.HideFooter }}
2 | <footer class="footer flex items-center flex-column">
3 | {{ if eq "" .App.Config.Branding.CustomFooter }}
4 | <div>
5 | <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 }}
6 | </div>
7 | {{ else }}
8 | {{ .App.Config.Branding.CustomFooter }}
9 | {{ end }}
10 | </footer>
11 | {{ end }}
12 |
--------------------------------------------------------------------------------
/internal/glance/templates/forum-posts.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{- define "widget-content" }}
4 | <ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
5 | {{- range .Posts }}
6 | <li>
7 | <div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
8 | {{- if $.ShowThumbnails }}
9 | {{- if .IsCrosspost }}
10 | <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)">
11 | <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
12 | </svg>
13 | {{- else if .ThumbnailUrl }}
14 | <img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
15 | {{- else if .TargetUrl }}
16 | <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)">
17 | <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" />
18 | </svg>
19 | {{- else }}
20 | <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)">
21 | <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" />
22 | </svg>
23 | {{- end }}
24 | {{- end }}
25 | <div class="grow min-width-0">
26 | <a href="{{ .DiscussionUrl | safeURL }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
27 | {{- if .Tags }}
28 | <div class="inline-block forum-post-tags-container">
29 | <ul class="attachments">
30 | {{- range .Tags }}
31 | <li>{{ . }}</li>
32 | {{- end }}
33 | </ul>
34 | </div>
35 | {{- end }}
36 | <ul class="list-horizontal-text flex-nowrap text-compact">
37 | <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
38 | <li class="shrink-0">{{ .Score | formatApproxNumber }} points</li>
39 | <li class="shrink-0{{ if .TargetUrl | safeURL }} forum-post-autohide{{ end }}">{{ .CommentCount | formatApproxNumber }} comments</li>
40 | {{- if .TargetUrl }}
41 | <li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
42 | {{- end }}
43 | </ul>
44 | </div>
45 | </div>
46 | </li>
47 | {{- end }}
48 | </ul>
49 | {{- end }}
50 |
--------------------------------------------------------------------------------
/internal/glance/templates/group.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
4 |
5 | {{ define "widget-content" }}
6 | <div class="widget-group-header">
7 | <div class="widget-header gap-20" role="tablist">
8 | {{- range $i, $widget := .Widgets }}
9 | <button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}"{{ if ne "" .TitleURL }} data-title-url="{{ .TitleURL }}"{{ end }} aria-selected="{{ if eq $i 0 }}true{{ else }}false{{ end }}" arial-level="2" role="tab" aria-controls="widget-{{ .GetID }}-tabpanel-{{ $i }}" id="widget-{{ .GetID }}-tab-{{ $i }}">{{ $widget.Title }}</button>
10 | {{- end }}
11 | </div>
12 | </div>
13 |
14 | <div class="widget-group-contents">
15 | {{- range $i, $widget := .Widgets }}
16 | <div class="widget-group-content{{ if eq $i 0 }} widget-group-content-current{{ end }}" id="widget-{{ .GetID }}-tabpanel-{{ $i }}" role="tabpanel" aria-labelledby="widget-{{ .GetID }}-tab-{{ $i }}" aria-hidden="{{ if eq $i 0 }}false{{ else }}true{{ end }}">
17 | {{- .Render -}}
18 | </div>
19 | {{- end }}
20 | </div>
21 | {{ end }}
22 |
--------------------------------------------------------------------------------
/internal/glance/templates/iframe.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
4 |
5 | {{ define "widget-content" }}
6 | <iframe src="{{ .Source }}" width="100%" height="{{ .Height }}px" frameborder="0"></iframe>
7 | {{ end }}
8 |
--------------------------------------------------------------------------------
/internal/glance/templates/login.html:
--------------------------------------------------------------------------------
1 | {{- template "document.html" . }}
2 |
3 | {{- define "document-title" }}Login{{ end }}
4 |
5 | {{- define "document-head-before" }}
6 | <link rel="preload" href='{{ .App.StaticAssetPath "js/templating.js" }}' as="script"/>
7 | <link rel="prefetch" href='{{ .App.StaticAssetPath "js/page.js" }}'/>
8 | {{- end }}
9 |
10 | {{- define "document-head-after" }}
11 | <link rel="stylesheet" href='{{ .App.StaticAssetPath "css/login.css" }}'>
12 | <script type="module" src='{{ .App.StaticAssetPath "js/login.js" }}'></script>
13 | {{- end }}
14 |
15 | {{- define "document-body" }}
16 | <div class="flex flex-column body-content">
17 | <div class="flex grow items-center justify-center" style="padding-bottom: 5rem">
18 | <h1 class="visually-hidden">Login</h1>
19 | <main id="login-container" class="grow login-bounds" style="display: none;">
20 | <div class="animate-entrance">
21 | <label class="form-label widget-header" for="username">Username</label>
22 | <div class="form-input widget-content-frame padding-inline-widget flex gap-10 items-center">
23 | <svg class="form-input-icon" fill="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
24 | <path d="M10 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.465 14.493a1.23 1.23 0 0 0 .41 1.412A9.957 9.957 0 0 0 10 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 0 0-13.074.003Z" />
25 | </svg>
26 | <input type="text" id="username" class="input" placeholder="Enter your username" autocomplete="off">
27 | </div>
28 | </div>
29 |
30 | <div class="animate-entrance">
31 | <label class="form-label widget-header margin-top-20" for="password">Password</label>
32 | <div class="form-input widget-content-frame padding-inline-widget flex gap-10 items-center">
33 | <svg class="form-input-icon" fill="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
34 | <path fill-rule="evenodd" d="M8 7a5 5 0 1 1 3.61 4.804l-1.903 1.903A1 1 0 0 1 9 14H8v1a1 1 0 0 1-1 1H6v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-2a1 1 0 0 1 .293-.707L8.196 8.39A5.002 5.002 0 0 1 8 7Zm5-3a.75.75 0 0 0 0 1.5A1.5 1.5 0 0 1 14.5 7 .75.75 0 0 0 16 7a3 3 0 0 0-3-3Z" clip-rule="evenodd" />
35 | </svg>
36 | <input type="password" id="password" class="input" placeholder="********" autocomplete="off">
37 | <button class="toggle-password-visibility" id="toggle-password-visibility" tabindex="-1"></button>
38 | </div>
39 | </div>
40 |
41 | <div class="login-error-message" id="error-message"></div>
42 |
43 | <button class="login-button animate-entrance" id="login-button">
44 | <div>LOGIN</div>
45 | <svg stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" aria-hidden="true">
46 | <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
47 | </svg>
48 | </button>
49 | </main>
50 | </div>
51 | {{ template "footer.html" . }}
52 | </div>
53 | {{- end }}
54 |
--------------------------------------------------------------------------------
/internal/glance/templates/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "{{ .App.Config.Branding.AppName }}",
3 | "display": "standalone",
4 | "background_color": "{{ .App.Config.Branding.AppBackgroundColor }}",
5 | "theme_color": "{{ .App.Config.Branding.AppBackgroundColor }}",
6 | "scope": "/",
7 | "start_url": "/",
8 | "icons": [
9 | {
10 | "src": "{{ .App.Config.Branding.AppIconURL }}",
11 | "type": "image/png",
12 | "sizes": "512x512"
13 | }
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/internal/glance/templates/markets.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | <div class="dynamic-columns list-gap-20 list-with-separator">
5 | {{ range .Markets }}
6 | <div class="flex items-center gap-15">
7 | <div class="min-width-0">
8 | <a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
9 | <div class="text-truncate">{{ .Name }}</div>
10 | </div>
11 |
12 | <a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
13 | <svg class="market-chart shrink-0" viewBox="0 0 100 50">
14 | <polyline fill="none" stroke="var(--color-text-subdue)" stroke-linejoin="round" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
15 | </svg>
16 | </a>
17 |
18 | <div class="market-values shrink-0">
19 | <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>
20 | <div class="text-right">{{ .Currency }}{{ .Price | formatPriceWithPrecision .PriceHint }}</div>
21 | </div>
22 | </div>
23 | {{ end }}
24 | </div>
25 | {{ end }}
26 |
--------------------------------------------------------------------------------
/internal/glance/templates/monitor-compact.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | {{ if not (and .ShowFailingOnly (not .HasFailing)) }}
5 | <ul class="dynamic-columns list-gap-8">
6 | {{ range .Sites }}
7 | {{ if and $.ShowFailingOnly (eq .StatusStyle "ok" ) }}{{ continue }}{{ end }}
8 | <div class="flex items-center gap-12">
9 | {{ template "site" . }}
10 | </div>
11 | {{ end }}
12 | </ul>
13 | {{ else }}
14 | <div class="flex items-center justify-center gap-10 padding-block-5">
15 | <p>All sites are online</p>
16 | <svg class="shrink-0" style="width: 1.7rem;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
17 | <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" />
18 | </svg>
19 | </div>
20 | {{ end }}
21 | {{ end }}
22 |
23 | {{ define "site" }}
24 | <a class="size-title-dynamic color-highlight text-truncate block grow" href="{{ .URL | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
25 | {{ if not .Status.TimedOut }}<div>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</div>{{ end }}
26 | {{ if eq .StatusStyle "ok" }}
27 | <div class="monitor-site-status-icon-compact" title="{{ .Status.Code }}">
28 | <svg fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
29 | <path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
30 | </svg>
31 | </div>
32 | {{ else }}
33 | <div class="monitor-site-status-icon-compact" title="{{ if .Status.Error }}{{ .Status.Error }}{{ else }}{{ .Status.Code }}{{ end }}">
34 | <svg fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
35 | <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
36 | </svg>
37 | </div>
38 | {{ end }}
39 | {{ end }}
40 |
--------------------------------------------------------------------------------
/internal/glance/templates/monitor.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | {{ if not (and .ShowFailingOnly (not .HasFailing)) }}
5 | <ul class="dynamic-columns list-gap-20 list-with-separator">
6 | {{ range .Sites }}
7 | {{ if and $.ShowFailingOnly (eq .StatusStyle "ok" ) }} {{ continue }} {{ end }}
8 | <div class="monitor-site flex items-center gap-15">
9 | {{ template "site" . }}
10 | </div>
11 | {{ end }}
12 | </ul>
13 | {{ else }}
14 | <div class="flex items-center justify-center gap-10 padding-block-5">
15 | <p>All sites are online</p>
16 | <svg class="shrink-0" style="width: 1.7rem;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
17 | <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" />
18 | </svg>
19 | </div>
20 | {{ end }}
21 | {{ end }}
22 |
23 | {{ define "site" }}
24 | {{ if .Icon.URL }}
25 | <img class="monitor-site-icon{{ if .Icon.AutoInvert }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
26 | {{ end }}
27 | <div class="grow min-width-0">
28 | <a class="size-h3 color-highlight text-truncate block" href="{{ .URL | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
29 | <ul class="list-horizontal-text">
30 | {{ if not .Status.Error }}
31 | <li title="{{ .Status.Code }}">{{ .StatusText }}</li>
32 | <li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
33 | {{ else if .Status.TimedOut }}
34 | <li class="color-negative">Timed Out</li>
35 | {{ else }}
36 | <li class="color-negative" title="{{ .Status.Error }}">ERROR</li>
37 | {{ end }}
38 | </ul>
39 | </div>
40 | {{ if eq .StatusStyle "ok" }}
41 | <div class="monitor-site-status-icon">
42 | <svg fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
43 | <path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
44 | </svg>
45 | </div>
46 | {{ else }}
47 | <div class="monitor-site-status-icon">
48 | <svg fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
49 | <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
50 | </svg>
51 | </div>
52 | {{ end }}
53 | {{ end }}
54 |
--------------------------------------------------------------------------------
/internal/glance/templates/old-calendar.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | <div class="widget-small-content-bounds">
5 | <div class="flex justify-between items-center">
6 | <div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div>
7 | <ul class="list-horizontal-text color-highlight size-h4">
8 | <li>Week {{ .Calendar.CurrentWeekNumber }}</li>
9 | <li>{{ .Calendar.CurrentYear }}</li>
10 | </ul>
11 | </div>
12 |
13 | <div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
14 | {{ if .StartSunday }}
15 | <div class="old-calendar-day">Su</div>
16 | {{ end }}
17 | <div class="old-calendar-day">Mo</div>
18 | <div class="old-calendar-day">Tu</div>
19 | <div class="old-calendar-day">We</div>
20 | <div class="old-calendar-day">Th</div>
21 | <div class="old-calendar-day">Fr</div>
22 | <div class="old-calendar-day">Sa</div>
23 | {{ if not .StartSunday }}
24 | <div class="old-calendar-day">Su</div>
25 | {{ end }}
26 | </div>
27 |
28 | <div class="flex flex-wrap">
29 | {{ range .Calendar.Days }}
30 | <div class="old-calendar-day{{ if eq . $.Calendar.CurrentDay }} old-calendar-day-today{{ end }}">{{ . }}</div>
31 | {{ end }}
32 | </div>
33 | </div>
34 | {{ end }}
35 |
--------------------------------------------------------------------------------
/internal/glance/templates/page-content.html:
--------------------------------------------------------------------------------
1 | {{ if .Page.ShowMobileHeader }}
2 | <div class="mobile-reachability-header">{{ .Page.Title }}</div>
3 | {{ end }}
4 |
5 | {{ if .Page.HeadWidgets }}
6 | <div class="head-widgets">
7 | {{- range .Page.HeadWidgets }}
8 | {{- .Render }}
9 | {{- end }}
10 | </div>
11 | {{ end }}
12 |
13 | <div class="page-columns">
14 | {{- range .Page.Columns }}
15 | <div class="page-column page-column-{{ .Size }}">
16 | {{- range .Widgets }}
17 | {{- .Render }}
18 | {{- end }}
19 | </div>
20 | {{- end }}
21 | </div>
22 |
--------------------------------------------------------------------------------
/internal/glance/templates/reddit-horizontal-cards.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
4 |
5 | {{ define "widget-content" }}
6 | <div class="carousel-container">
7 | <div class="cards-horizontal carousel-items-container">
8 | {{ range .Posts }}
9 | <div class="card widget-content-frame relative">
10 | {{ if ne "" .ThumbnailUrl }}
11 | <div class="reddit-card-thumbnail-container">
12 | <img class="reddit-card-thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
13 | </div>
14 | {{ end }}
15 | <div class="padding-widget flex flex-column grow relative">
16 | {{ if ne "" .TargetUrl }}
17 | <a class="color-highlight size-h5 text-truncate visited-indicator" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a>
18 | {{ else }}
19 | <div class="color-highlight size-h5 text-truncate">/r/{{ $.Subreddit }}</div>
20 | {{ end }}
21 | <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>
22 | <ul class="list-horizontal-text margin-top-7">
23 | <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
24 | <li>{{ .Score | formatApproxNumber }} points</li>
25 | </ul>
26 | </div>
27 | </div>
28 | {{ end }}
29 | </div>
30 | </div>
31 | {{ end }}
32 |
--------------------------------------------------------------------------------
/internal/glance/templates/reddit-vertical-cards.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
4 |
5 | {{ define "widget-content" }}
6 | <div class="cards-vertical">
7 | {{ range .Posts }}
8 | <div class="widget-content-frame relative">
9 | {{ if ne "" .ThumbnailUrl }}
10 | <div class="reddit-card-thumbnail-container">
11 | <img class="reddit-card-thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
12 | </div>
13 | {{ end }}
14 | <div class="padding-widget relative">
15 | {{ if ne "" .TargetUrl }}
16 | <a class="color-highlight size-h5 text-truncate visited-indicator block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a>
17 | {{ else }}
18 | <div class="color-highlight size-h5 text-truncate">/r/{{ $.Subreddit }}</div>
19 | {{ end }}
20 | <a href="{{ .DiscussionUrl }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
21 | <ul class="list-horizontal-text margin-top-7">
22 | <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
23 | <li>{{ .Score | formatApproxNumber }} points</li>
24 | </ul>
25 | </div>
26 | </div>
27 | {{ end }}
28 | </div>
29 | {{ end }}
30 |
--------------------------------------------------------------------------------
/internal/glance/templates/releases.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | <ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
5 | {{ range .Releases }}
6 | <li>
7 | <div class="flex items-center gap-10">
8 | <a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ .NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
9 | {{ if $.ShowSourceIcon }}
10 | <img class="flat-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
11 | {{ end }}
12 | </div>
13 | <ul class="list-horizontal-text">
14 | <li {{ dynamicRelativeTimeAttrs .TimeReleased }}></li>
15 | <li>{{ .Version }}</li>
16 | {{ if gt .Downvotes 3 }}
17 | <li>{{ .Downvotes | formatNumber }} ⚠</li>
18 | {{ end }}
19 | </ul>
20 | </li>
21 | {{ end }}
22 | </ul>
23 | {{ end }}
24 |
--------------------------------------------------------------------------------
/internal/glance/templates/repository.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | <a class="size-h4 color-highlight" href="https://github.com/{{ $.Repository.Name }}" target="_blank" rel="noreferrer">{{ .Repository.Name }}</a>
5 | <ul class="list-horizontal-text">
6 | <li>{{ .Repository.Stars | formatNumber }} stars</li>
7 | <li>{{ .Repository.Forks | formatNumber }} forks</li>
8 | </ul>
9 |
10 | {{ if gt (len .Repository.Commits) 0 }}
11 | <hr class="margin-block-8">
12 | <a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
13 | <div class="flex gap-7 size-h5 size-base-on-mobile margin-top-3">
14 | <ul class="list list-gap-2">
15 | {{ range .Repository.Commits }}
16 | <li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
17 | {{ end }}
18 | </ul>
19 | <ul class="list list-gap-2 min-width-0">
20 | {{ range .Repository.Commits }}
21 | <li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Author }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/commit/{{ .Sha }}">{{ .Message }}</a></li>
22 | {{ end }}
23 | </ul>
24 | </div>
25 | {{ end }}
26 |
27 | {{ if gt (len .Repository.PullRequests) 0 }}
28 | <hr class="margin-block-8">
29 | <a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .Repository.OpenPullRequests | formatNumber }} total)</a>
30 | <div class="flex gap-7 size-h5 size-base-on-mobile margin-top-3">
31 | <ul class="list list-gap-2">
32 | {{ range .Repository.PullRequests }}
33 | <li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
34 | {{ end }}
35 | </ul>
36 | <ul class="list list-gap-2 min-width-0">
37 | {{ range .Repository.PullRequests }}
38 | <li><a class="color-primary-if-not-visited text-truncate block" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
39 | {{ end }}
40 | </ul>
41 | </div>
42 | {{ end }}
43 |
44 | {{ if gt (len .Repository.Issues) 0 }}
45 | <hr class="margin-block-10">
46 | <a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .Repository.OpenIssues | formatNumber }} total)</a>
47 | <div class="flex gap-7 size-h5 size-base-on-mobile margin-top-3">
48 | <ul class="list list-gap-2">
49 | {{ range .Repository.Issues }}
50 | <li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
51 | {{ end }}
52 | </ul>
53 | <ul class="list list-gap-2 min-width-0">
54 | {{ range .Repository.Issues }}
55 | <li><a class="color-primary-if-not-visited text-truncate block" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
56 | {{ end }}
57 | </ul>
58 | </div>
59 | {{ end }}
60 |
61 | {{ end }}
62 |
--------------------------------------------------------------------------------
/internal/glance/templates/rss-detailed-list.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | <ul class="list list-gap-24 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
5 | {{ range .Items }}
6 | <li class="flex gap-15 items-start row-reverse-on-mobile thumbnail-parent">
7 | <div class="thumbnail-container rss-detailed-thumbnail">
8 | {{ if ne "" .ImageURL }}
9 | <img class="thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
10 | {{ else }}
11 | <svg class="scale-half hide-on-mobile" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
12 | <path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
13 | </svg>
14 | {{ end }}
15 | </div>
16 | <div class="grow min-width-0">
17 | <a class="size-h3 color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
18 | <ul class="list-horizontal-text flex-nowrap">
19 | <li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
20 | <li class="min-width-0">
21 | <a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
22 | </li>
23 | </ul>
24 | {{ if ne "" .Description }}
25 | <p class="rss-detailed-description text-truncate-2-lines margin-top-10">{{ .Description }}</p>
26 | {{ end }}
27 | {{ if gt (len .Categories) 0 }}
28 | <ul class="attachments margin-top-10">
29 | {{ range .Categories }}
30 | <li>{{ . }}</li>
31 | {{ end }}
32 | </ul>
33 | {{ end }}
34 | </div>
35 | </li>
36 | {{ else }}
37 | <li>{{ .NoItemsMessage }}</li>
38 | {{ end }}
39 | </ul>
40 | {{ end }}
41 |
--------------------------------------------------------------------------------
/internal/glance/templates/rss-horizontal-cards-2.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
4 |
5 | {{ define "widget-content" }}
6 | {{ if gt (len .Items) 0 }}
7 | <div class="carousel-container">
8 | <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
9 | {{ range .Items }}
10 | <div class="card rss-card-2 widget-content-frame thumbnail-parent">
11 | {{ if ne "" .ImageURL }}
12 | <img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
13 | {{ else }}
14 | <svg class="rss-card-2-image" style="transform: scale(0.35) translateY(-25%)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="var(--color-text-subdue)">
15 | <path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
16 | </svg>
17 | {{ end }}
18 | <div class="rss-card-2-content padding-inline-widget">
19 | <a href="{{ .Link }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
20 | <ul class="list-horizontal-text flex-nowrap margin-top-5">
21 | <li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
22 | <li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
23 | </ul>
24 | </div>
25 | </div>
26 | {{ end }}
27 | </div>
28 | </div>
29 | {{ else }}
30 | <div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
31 | {{ end }}
32 | {{ end }}
33 |
--------------------------------------------------------------------------------
/internal/glance/templates/rss-horizontal-cards.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
4 |
5 | {{ define "widget-content" }}
6 | {{ if gt (len .Items) 0 }}
7 | <div class="carousel-container">
8 | <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
9 | {{ range .Items }}
10 | <div class="card widget-content-frame thumbnail-parent">
11 | {{ if ne "" .ImageURL }}
12 | <img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
13 | {{ else }}
14 | <svg class="rss-card-image" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="var(--color-text-subdue)">
15 | <path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
16 | </svg>
17 | {{ end }}
18 | <div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
19 | <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>
20 | <ul class="list-horizontal-text flex-nowrap margin-top-7">
21 | <li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
22 | <li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
23 | </ul>
24 | </div>
25 | </div>
26 | {{ end }}
27 | </div>
28 | </div>
29 | {{ else }}
30 | <div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
31 | {{ end }}
32 | {{ end }}
33 |
--------------------------------------------------------------------------------
/internal/glance/templates/rss-list.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | <ul class="list list-gap-14 collapsible-container{{ if .SingleLineTitles }} single-line-titles{{ end }}" data-collapse-after="{{ .CollapseAfter }}">
5 | {{ range .Items }}
6 | <li>
7 | <a class="title size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
8 | <ul class="list-horizontal-text flex-nowrap">
9 | <li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
10 | <li class="min-width-0">
11 | <a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
12 | </li>
13 | </ul>
14 | </li>
15 | {{ else }}
16 | <li>{{ .NoItemsMessage }}</li>
17 | {{ end }}
18 | </ul>
19 | {{ end }}
20 |
--------------------------------------------------------------------------------
/internal/glance/templates/search.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
4 |
5 | {{ define "widget-content" }}
6 | <div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}" data-new-tab="{{ .NewTab }}" data-target="{{ .Target }}">
7 | <div class="search-bangs">
8 | {{ range .Bangs }}
9 | <input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
10 | {{ end }}
11 | </div>
12 |
13 | <div class="search-icon-container">
14 | <svg class="search-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
15 | <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
16 | </svg>
17 | </div>
18 |
19 | <input class="search-input" type="text" placeholder="{{ .Placeholder }}" autocomplete="off"{{ if .Autofocus }} autofocus{{ end }}>
20 |
21 | <div class="search-bang"></div>
22 | <kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
23 | </div>
24 | {{ end }}
25 |
--------------------------------------------------------------------------------
/internal/glance/templates/split-column.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
4 |
5 | {{ define "widget-content" }}
6 | <div class="masonry" data-max-columns="{{ .MaxColumns }}">
7 | {{ range .Widgets }}
8 | {{ .Render }}
9 | {{ end }}
10 | </div>
11 | {{ end }}
12 |
--------------------------------------------------------------------------------
/internal/glance/templates/theme-preset-preview.html:
--------------------------------------------------------------------------------
1 | {{- $background := "hsl(240, 8%, 9%)" | safeCSS }}
2 | {{- $primary := "hsl(43, 50%, 70%)" | safeCSS }}
3 | {{- $positive := "hsl(43, 50%, 70%)" | safeCSS }}
4 | {{- $negative := "hsl(0, 70%, 70%)" | safeCSS }}
5 | {{- if .BackgroundColor }}{{ $background = .BackgroundColor.String | safeCSS }}{{ end }}
6 | {{- if .PrimaryColor }}
7 | {{- $primary = .PrimaryColor.String | safeCSS }}
8 | {{- if not .PositiveColor }}
9 | {{- $positive = $primary }}
10 | {{- else }}
11 | {{- $positive = .PositiveColor.String | safeCSS }}
12 | {{- end }}
13 | {{- end }}
14 | {{- if .NegativeColor }}{{ $negative = .NegativeColor.String | safeCSS }}{{ end }}
15 | <button class="theme-preset{{ if .Light }} theme-preset-light{{ end }}" style="--color: {{ $background }}" data-key="{{ .Key }}">
16 | <div class="theme-color" style="--color: {{ $primary }}"></div>
17 | <div class="theme-color" style="--color: {{ $positive }}"></div>
18 | <div class="theme-color" style="--color: {{ $negative }}"></div>
19 | </button>
20 |
--------------------------------------------------------------------------------
/internal/glance/templates/theme-style.gotmpl:
--------------------------------------------------------------------------------
1 | :root {
2 | {{ if .BackgroundColor }}
3 | --bgh: {{ .BackgroundColor.H }};
4 | --bgs: {{ .BackgroundColor.S }}%;
5 | --bgl: {{ .BackgroundColor.L }}%;
6 | {{ end }}
7 | {{ if ne 0.0 .ContrastMultiplier }}--cm: {{ .ContrastMultiplier }};{{ end }}
8 | {{ if ne 0.0 .TextSaturationMultiplier }}--tsm: {{ .TextSaturationMultiplier }};{{ end }}
9 | {{ if .PrimaryColor }}--color-primary: {{ .PrimaryColor.String | safeCSS }};{{ end }}
10 | {{ if .PositiveColor }}--color-positive: {{ .PositiveColor.String | safeCSS }};{{ end }}
11 | {{ if .NegativeColor }}--color-negative: {{ .NegativeColor.String | safeCSS }};{{ end }}
12 | }
13 |
--------------------------------------------------------------------------------
/internal/glance/templates/todo.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | <div class="todo" data-todo-id="{{ .TodoID }}"></div>
5 | {{ end }}
6 |
--------------------------------------------------------------------------------
/internal/glance/templates/twitch-channels.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | <ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
5 | {{ range .Channels }}
6 | <li>
7 | <div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
8 | <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 }}>
9 | {{ if .IsLive }}
10 | <div data-popover-html>
11 | <img class="twitch-stream-preview" src="https://static-cdn.jtvnw.net/previews-ttv/live_user_{{ .Login }}-440x248.jpg" loading="lazy" alt="">
12 | <p class="margin-top-10 color-highlight text-truncate-3-lines">{{ .StreamTitle }}</p>
13 | </div>
14 | {{ end }}
15 | {{ if .Exists }}
16 | <a href="https://twitch.tv/{{ .Login }}" target="_blank" rel="noreferrer">
17 | <img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
18 | </a>
19 | {{ else }}
20 | <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">
21 | <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" />
22 | </svg>
23 | {{ end }}
24 | </div>
25 | <div class="min-width-0">
26 | <a href="https://twitch.tv/{{ .Login }}" class="size-h3{{ if .IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ .Name }}</a>
27 | {{ if .Exists }}
28 | {{ if .IsLive }}
29 | {{ if .Category }}
30 | <a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a>
31 | {{ end }}
32 | <ul class="list-horizontal-text">
33 | <li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
34 | <li>{{ .ViewersCount | formatApproxNumber }} viewers</li>
35 | </ul>
36 | {{ else }}
37 | <div>Offline</div>
38 | {{ end }}
39 | {{ else }}
40 | <div class="color-negative">Not found</div>
41 | {{ end }}
42 | </div>
43 | </div>
44 | </li>
45 | {{ end }}
46 | </ul>
47 | {{ end }}
48 |
--------------------------------------------------------------------------------
/internal/glance/templates/twitch-games-list.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | <ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
5 | {{ range .Categories }}
6 | <li class="twitch-category thumbnail-parent">
7 | <div class="flex gap-10 items-start">
8 | <img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ .AvatarUrl }}" alt="">
9 | <div class="min-width-0">
10 | <a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
11 | <ul class="list-horizontal-text">
12 | <li>{{ .ViewersCount | formatApproxNumber }} viewers</li>
13 | {{ if .IsNew }}
14 | <li class="color-primary">NEW</li>
15 | {{ end }}
16 | </ul>
17 | <ul class="list-horizontal-text flex-nowrap">
18 | {{ range $i, $tag := .Tags }}
19 | {{ if eq $i 0 }}
20 | <li class="shrink-0">{{ $tag.Name }}</li>
21 | {{ else }}
22 | <li class="text-truncate min-width-0">{{ $tag.Name }}</li>
23 | {{ end }}
24 | {{ end }}
25 | </ul>
26 | </div>
27 | </div>
28 | </li>
29 | {{ end }}
30 | </ul>
31 | {{ end }}
32 |
--------------------------------------------------------------------------------
/internal/glance/templates/v0.7-update-notice-page.html:
--------------------------------------------------------------------------------
1 | <!DOCTYPE html>
2 | <html lang="en">
3 | <head>
4 | <meta charset="UTF-8">
5 | <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 | <link rel="stylesheet" href="static/main.css">
7 | <title>Update notice</title>
8 | <style>
9 | body {
10 | display: flex;
11 | align-items: center;
12 | justify-content: center;
13 | }
14 |
15 | .content-bounds {
16 | max-width: 700px;
17 | margin-top: -10rem;
18 | }
19 |
20 | .comfy-line-height {
21 | line-height: 1.9;
22 | }
23 | </style>
24 | </head>
25 | <body>
26 |
27 | <div class="content-bounds color-paragraph">
28 | <p class="uppercase size-h5 color-negative padding-inline-widget">UPDATE NOTICE</p>
29 | <div class="widget-content-frame margin-top-10 padding-widget">
30 | <p class="comfy-line-height">
31 | The default location of glance.yml in the Docker image has
32 | changed since v0.7.0, please see the <a class="color-primary" href="https://github.com/glanceapp/glance/blob/main/docs/v0.7.0-upgrade.md" target="_blank">migration guide</a>
33 | for instructions or visit the <a class="color-primary" href="https://github.com/glanceapp/glance/releases/tag/v0.7.0" target="_blank">release notes</a>
34 | to find out more about why this change was necessary. Sorry for the inconvenience.
35 | </p>
36 |
37 | <p class="margin-top-15 color-base">Migration should take around 5 minutes.</p>
38 | </div>
39 | </div>
40 |
41 | </body>
42 | </html>
43 |
--------------------------------------------------------------------------------
/internal/glance/templates/video-card-contents.html:
--------------------------------------------------------------------------------
1 | {{ define "video-card-contents" }}
2 | <img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
3 | <div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
4 | <a class="text-truncate-2-lines margin-bottom-auto color-primary-if-not-visited" href="{{ .Url | safeURL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
5 | <ul class="list-horizontal-text flex-nowrap margin-top-7">
6 | <li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
7 | <li class="min-width-0">
8 | <a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
9 | </li>
10 | </ul>
11 | </div>
12 | {{ end }}
13 |
--------------------------------------------------------------------------------
/internal/glance/templates/videos-grid.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
4 |
5 | {{ define "widget-content" }}
6 | <div class="cards-grid collapsible-container" data-collapse-after-rows="{{ .CollapseAfterRows }}">
7 | {{ range .Videos }}
8 | <div class="card widget-content-frame thumbnail-parent">
9 | {{ template "video-card-contents" . }}
10 | </div>
11 | {{ end }}
12 | </div>
13 | {{ end }}
14 |
--------------------------------------------------------------------------------
/internal/glance/templates/videos-vertical-list.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{- define "widget-content" }}
4 | <ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
5 | {{- range .Videos }}
6 | <li class="flex thumbnail-parent gap-10 items-center">
7 | <img class="video-horizontal-list-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
8 | <div class="min-width-0">
9 | <a class="block text-truncate color-primary-if-not-visited" href="{{ .Url | safeURL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
10 | <ul class="list-horizontal-text flex-nowrap">
11 | <li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
12 | <li class="min-width-0">
13 | <a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
14 | </li>
15 | </ul>
16 | </div>
17 | </li>
18 | {{- end }}
19 | </ul>
20 | {{- end }}
21 |
--------------------------------------------------------------------------------
/internal/glance/templates/videos.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
4 |
5 | {{ define "widget-content" }}
6 | <div class="carousel-container">
7 | <div class="cards-horizontal carousel-items-container">
8 | {{ range .Videos }}
9 | <div class="card widget-content-frame thumbnail-parent">
10 | {{ template "video-card-contents" . }}
11 | </div>
12 | {{ end }}
13 | </div>
14 | </div>
15 | {{ end }}
16 |
--------------------------------------------------------------------------------
/internal/glance/templates/weather.html:
--------------------------------------------------------------------------------
1 | {{ template "widget-base.html" . }}
2 |
3 | {{ define "widget-content" }}
4 | <div class="widget-small-content-bounds">
5 | <div class="size-h2 color-highlight text-center">{{ .Weather.WeatherCodeAsString }}</div>
6 | <div class="size-h4 text-center">Feels like {{ .Weather.ApparentTemperature }}°{{ if eq .Units "metric" }}C{{ else }}F{{ end }}</div>
7 |
8 | <div class="weather-columns flex margin-top-15 justify-center">
9 | {{ range $i, $column := .Weather.Columns }}
10 | <div class="weather-column{{ if eq $i $.Weather.CurrentColumn }} weather-column-current{{ end }}">
11 | {{ if $column.HasPrecipitation }}
12 | <div class="weather-column-rain"></div>
13 | {{ end }}
14 | {{ if and (ge $i $.Weather.SunriseColumn) (le $i $.Weather.SunsetColumn ) }}
15 | <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>
16 | {{ end }}
17 | <div class="weather-column-value{{ if lt $column.Temperature 0 }} weather-column-value-negative{{ end }}">{{ $column.Temperature | absInt }}</div>
18 | <div class="weather-bar" style='--weather-bar-height: {{ printf "%.2f" $column.Scale }}'></div>
19 | <div class="weather-column-time">{{ index $.TimeLabels $i }}</div>
20 | </div>
21 | {{ end }}
22 | </div>
23 |
24 | {{ if not .HideLocation }}
25 | <div class="flex items-center justify-center margin-top-15 gap-7 size-h5">
26 | <div class="location-icon"></div>
27 | <div class="text-truncate">{{ .Place.Name }},{{ if .ShowAreaName }} {{ .Place.Area }},{{ end }} {{ .Place.Country }}</div>
28 | </div>
29 | {{ end }}
30 | </div>
31 | {{ end }}
32 |
--------------------------------------------------------------------------------
/internal/glance/templates/widget-base.html:
--------------------------------------------------------------------------------
1 | <div class="widget widget-type-{{ .GetType }}{{ if .CSSClass }} {{ .CSSClass }}{{ end }}">
2 | {{- if not .HideHeader }}
3 | <div class="widget-header">
4 | {{- if ne "" .TitleURL }}
5 | <h2><a href="{{ .TitleURL | safeURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a></h2>
6 | {{- else }}
7 | <h2 class="uppercase">{{ .Title }}</h2>
8 | {{- end }}
9 | {{- if .IsWIP }}
10 | <div data-popover-type="html" data-popover-position="above">
11 | <div data-popover-html>
12 | <p class="size-h5">WORK IN PROGRESS</p>
13 | <p class="margin-block-10 color-paragraph">This widget is still in development, certain features may not work as expected or may change drastically.</p>
14 | <a class="color-primary visited-indicator" href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a>
15 | </div>
16 | <svg class="widget-beta-icon cursor-help" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
17 | <path fill-rule="evenodd" d="M19 5.5a4.5 4.5 0 0 1-4.791 4.49c-.873-.055-1.808.128-2.368.8l-6.024 7.23a2.724 2.724 0 1 1-3.837-3.837L9.21 8.16c.672-.56.855-1.495.8-2.368a4.5 4.5 0 0 1 5.873-4.575c.324.105.39.51.15.752L13.34 4.66a.455.455 0 0 0-.11.494 3.01 3.01 0 0 0 1.617 1.617c.17.07.363.02.493-.111l2.692-2.692c.241-.241.647-.174.752.15.14.435.216.9.216 1.382ZM4 17a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
18 | </svg>
19 | </div>
20 | {{- end }}
21 | {{- if and .Error .ContentAvailable }}
22 | <div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
23 | {{- else if .Notice }}
24 | <div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
25 | {{- end }}
26 | </div>
27 | {{- end }}
28 | <div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}">
29 | {{- if .ContentAvailable }}
30 | {{ block "widget-content" . }}{{ end }}
31 | {{- else }}
32 | <div class="widget-error-header">
33 | <div class="color-negative size-h3">ERROR</div>
34 | <svg class="widget-error-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
35 | <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
36 | </svg>
37 | </div>
38 | <p class="break-all">{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}</p>
39 | {{- end}}
40 | </div>
41 | </div>
42 |
--------------------------------------------------------------------------------
/internal/glance/theme.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "fmt"
5 | "html/template"
6 | "net/http"
7 | "time"
8 | )
9 |
10 | var (
11 | themeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
12 | themePresetPreviewTemplate = mustParseTemplate("theme-preset-preview.html")
13 | )
14 |
15 | func (a *application) handleThemeChangeRequest(w http.ResponseWriter, r *http.Request) {
16 | themeKey := r.PathValue("key")
17 |
18 | properties, exists := a.Config.Theme.Presets.Get(themeKey)
19 | if !exists && themeKey != "default" {
20 | w.WriteHeader(http.StatusNotFound)
21 | return
22 | }
23 |
24 | if themeKey == "default" {
25 | properties = &a.Config.Theme.themeProperties
26 | }
27 |
28 | http.SetCookie(w, &http.Cookie{
29 | Name: "theme",
30 | Value: themeKey,
31 | Path: a.Config.Server.BaseURL + "/",
32 | SameSite: http.SameSiteLaxMode,
33 | Expires: time.Now().Add(2 * 365 * 24 * time.Hour),
34 | })
35 |
36 | w.Header().Set("Content-Type", "text/css")
37 | w.Header().Set("X-Scheme", ternary(properties.Light, "light", "dark"))
38 | w.Write([]byte(properties.CSS))
39 | }
40 |
41 | type themeProperties struct {
42 | BackgroundColor *hslColorField `yaml:"background-color"`
43 | PrimaryColor *hslColorField `yaml:"primary-color"`
44 | PositiveColor *hslColorField `yaml:"positive-color"`
45 | NegativeColor *hslColorField `yaml:"negative-color"`
46 | Light bool `yaml:"light"`
47 | ContrastMultiplier float32 `yaml:"contrast-multiplier"`
48 | TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
49 |
50 | Key string `yaml:"-"`
51 | CSS template.CSS `yaml:"-"`
52 | PreviewHTML template.HTML `yaml:"-"`
53 | BackgroundColorAsHex string `yaml:"-"`
54 | }
55 |
56 | func (t *themeProperties) init() error {
57 | css, err := executeTemplateToString(themeStyleTemplate, t)
58 | if err != nil {
59 | return fmt.Errorf("compiling theme style: %v", err)
60 | }
61 | t.CSS = template.CSS(whitespaceAtBeginningOfLinePattern.ReplaceAllString(css, ""))
62 |
63 | previewHTML, err := executeTemplateToString(themePresetPreviewTemplate, t)
64 | if err != nil {
65 | return fmt.Errorf("compiling theme preview: %v", err)
66 | }
67 | t.PreviewHTML = template.HTML(previewHTML)
68 |
69 | if t.BackgroundColor != nil {
70 | t.BackgroundColorAsHex = t.BackgroundColor.ToHex()
71 | } else {
72 | t.BackgroundColorAsHex = "#151519"
73 | }
74 |
75 | return nil
76 | }
77 |
78 | func (t1 *themeProperties) SameAs(t2 *themeProperties) bool {
79 | if t1 == nil && t2 == nil {
80 | return true
81 | }
82 | if t1 == nil || t2 == nil {
83 | return false
84 | }
85 | if t1.Light != t2.Light {
86 | return false
87 | }
88 | if t1.ContrastMultiplier != t2.ContrastMultiplier {
89 | return false
90 | }
91 | if t1.TextSaturationMultiplier != t2.TextSaturationMultiplier {
92 | return false
93 | }
94 | if !t1.BackgroundColor.SameAs(t2.BackgroundColor) {
95 | return false
96 | }
97 | if !t1.PrimaryColor.SameAs(t2.PrimaryColor) {
98 | return false
99 | }
100 | if !t1.PositiveColor.SameAs(t2.PositiveColor) {
101 | return false
102 | }
103 | if !t1.NegativeColor.SameAs(t2.NegativeColor) {
104 | return false
105 | }
106 | return true
107 | }
108 |
--------------------------------------------------------------------------------
/internal/glance/widget-bookmarks.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "html/template"
5 | )
6 |
7 | var bookmarksWidgetTemplate = mustParseTemplate("bookmarks.html", "widget-base.html")
8 |
9 | type bookmarksWidget struct {
10 | widgetBase `yaml:",inline"`
11 | cachedHTML template.HTML `yaml:"-"`
12 | Groups []struct {
13 | Title string `yaml:"title"`
14 | Color *hslColorField `yaml:"color"`
15 | SameTab bool `yaml:"same-tab"`
16 | HideArrow bool `yaml:"hide-arrow"`
17 | Target string `yaml:"target"`
18 | Links []struct {
19 | Title string `yaml:"title"`
20 | URL string `yaml:"url"`
21 | Description string `yaml:"description"`
22 | Icon customIconField `yaml:"icon"`
23 | // we need a pointer to bool to know whether a value was provided,
24 | // however there's no way to dereference a pointer in a template so
25 | // {{ if not .SameTab }} would return true for any non-nil pointer
26 | // which leaves us with no way of checking if the value is true or
27 | // false, hence the duplicated fields below
28 | SameTabRaw *bool `yaml:"same-tab"`
29 | SameTab bool `yaml:"-"`
30 | HideArrowRaw *bool `yaml:"hide-arrow"`
31 | HideArrow bool `yaml:"-"`
32 | Target string `yaml:"target"`
33 | } `yaml:"links"`
34 | } `yaml:"groups"`
35 | }
36 |
37 | func (widget *bookmarksWidget) initialize() error {
38 | widget.withTitle("Bookmarks").withError(nil)
39 |
40 | for g := range widget.Groups {
41 | group := &widget.Groups[g]
42 | for l := range group.Links {
43 | link := &group.Links[l]
44 | if link.SameTabRaw == nil {
45 | link.SameTab = group.SameTab
46 | } else {
47 | link.SameTab = *link.SameTabRaw
48 | }
49 |
50 | if link.HideArrowRaw == nil {
51 | link.HideArrow = group.HideArrow
52 | } else {
53 | link.HideArrow = *link.HideArrowRaw
54 | }
55 |
56 | if link.Target == "" {
57 | if group.Target != "" {
58 | link.Target = group.Target
59 | } else {
60 | if link.SameTab {
61 | link.Target = ""
62 | } else {
63 | link.Target = "_blank"
64 | }
65 | }
66 | }
67 | }
68 | }
69 |
70 | widget.cachedHTML = widget.renderTemplate(widget, bookmarksWidgetTemplate)
71 |
72 | return nil
73 | }
74 |
75 | func (widget *bookmarksWidget) Render() template.HTML {
76 | return widget.cachedHTML
77 | }
78 |
--------------------------------------------------------------------------------
/internal/glance/widget-calendar.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "errors"
5 | "html/template"
6 | "time"
7 | )
8 |
9 | var calendarWidgetTemplate = mustParseTemplate("calendar.html", "widget-base.html")
10 |
11 | var calendarWeekdaysToInt = map[string]time.Weekday{
12 | "sunday": time.Sunday,
13 | "monday": time.Monday,
14 | "tuesday": time.Tuesday,
15 | "wednesday": time.Wednesday,
16 | "thursday": time.Thursday,
17 | "friday": time.Friday,
18 | "saturday": time.Saturday,
19 | }
20 |
21 | type calendarWidget struct {
22 | widgetBase `yaml:",inline"`
23 | FirstDayOfWeek string `yaml:"first-day-of-week"`
24 | FirstDay int `yaml:"-"`
25 | cachedHTML template.HTML `yaml:"-"`
26 | }
27 |
28 | func (widget *calendarWidget) initialize() error {
29 | widget.withTitle("Calendar").withError(nil)
30 |
31 | if widget.FirstDayOfWeek == "" {
32 | widget.FirstDayOfWeek = "monday"
33 | } else if _, ok := calendarWeekdaysToInt[widget.FirstDayOfWeek]; !ok {
34 | return errors.New("invalid first day of week")
35 | }
36 |
37 | widget.FirstDay = int(calendarWeekdaysToInt[widget.FirstDayOfWeek])
38 | widget.cachedHTML = widget.renderTemplate(widget, calendarWidgetTemplate)
39 |
40 | return nil
41 | }
42 |
43 | func (widget *calendarWidget) Render() template.HTML {
44 | return widget.cachedHTML
45 | }
46 |
--------------------------------------------------------------------------------
/internal/glance/widget-clock.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "html/template"
7 | "time"
8 | )
9 |
10 | var clockWidgetTemplate = mustParseTemplate("clock.html", "widget-base.html")
11 |
12 | type clockWidget struct {
13 | widgetBase `yaml:",inline"`
14 | cachedHTML template.HTML `yaml:"-"`
15 | HourFormat string `yaml:"hour-format"`
16 | Timezones []struct {
17 | Timezone string `yaml:"timezone"`
18 | Label string `yaml:"label"`
19 | } `yaml:"timezones"`
20 | }
21 |
22 | func (widget *clockWidget) initialize() error {
23 | widget.withTitle("Clock").withError(nil)
24 |
25 | if widget.HourFormat == "" {
26 | widget.HourFormat = "24h"
27 | } else if widget.HourFormat != "12h" && widget.HourFormat != "24h" {
28 | return errors.New("hour-format must be either 12h or 24h")
29 | }
30 |
31 | for t := range widget.Timezones {
32 | if widget.Timezones[t].Timezone == "" {
33 | return errors.New("missing timezone value")
34 | }
35 |
36 | if _, err := time.LoadLocation(widget.Timezones[t].Timezone); err != nil {
37 | return fmt.Errorf("invalid timezone '%s': %v", widget.Timezones[t].Timezone, err)
38 | }
39 | }
40 |
41 | widget.cachedHTML = widget.renderTemplate(widget, clockWidgetTemplate)
42 |
43 | return nil
44 | }
45 |
46 | func (widget *clockWidget) Render() template.HTML {
47 | return widget.cachedHTML
48 | }
49 |
--------------------------------------------------------------------------------
/internal/glance/widget-container.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "context"
5 | "sync"
6 | "time"
7 | )
8 |
9 | type containerWidgetBase struct {
10 | Widgets widgets `yaml:"widgets"`
11 | }
12 |
13 | func (widget *containerWidgetBase) _initializeWidgets() error {
14 | for i := range widget.Widgets {
15 | if err := widget.Widgets[i].initialize(); err != nil {
16 | return formatWidgetInitError(err, widget.Widgets[i])
17 | }
18 | }
19 |
20 | return nil
21 | }
22 |
23 | func (widget *containerWidgetBase) _update(ctx context.Context) {
24 | var wg sync.WaitGroup
25 | now := time.Now()
26 |
27 | for w := range widget.Widgets {
28 | widget := widget.Widgets[w]
29 |
30 | if !widget.requiresUpdate(&now) {
31 | continue
32 | }
33 |
34 | wg.Add(1)
35 | go func() {
36 | defer wg.Done()
37 | widget.update(ctx)
38 | }()
39 | }
40 |
41 | wg.Wait()
42 | }
43 |
44 | func (widget *containerWidgetBase) _setProviders(providers *widgetProviders) {
45 | for i := range widget.Widgets {
46 | widget.Widgets[i].setProviders(providers)
47 | }
48 | }
49 |
50 | func (widget *containerWidgetBase) _requiresUpdate(now *time.Time) bool {
51 | for i := range widget.Widgets {
52 | if widget.Widgets[i].requiresUpdate(now) {
53 | return true
54 | }
55 | }
56 |
57 | return false
58 | }
59 |
--------------------------------------------------------------------------------
/internal/glance/widget-group.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "html/template"
7 | "time"
8 | )
9 |
10 | var groupWidgetTemplate = mustParseTemplate("group.html", "widget-base.html")
11 |
12 | type groupWidget struct {
13 | widgetBase `yaml:",inline"`
14 | containerWidgetBase `yaml:",inline"`
15 | }
16 |
17 | func (widget *groupWidget) initialize() error {
18 | widget.withError(nil)
19 | widget.HideHeader = true
20 |
21 | for i := range widget.Widgets {
22 | widget.Widgets[i].setHideHeader(true)
23 |
24 | if widget.Widgets[i].GetType() == "group" {
25 | return errors.New("nested groups are not supported")
26 | } else if widget.Widgets[i].GetType() == "split-column" {
27 | return errors.New("split columns inside of groups are not supported")
28 | }
29 | }
30 |
31 | if err := widget.containerWidgetBase._initializeWidgets(); err != nil {
32 | return err
33 | }
34 |
35 | return nil
36 | }
37 |
38 | func (widget *groupWidget) update(ctx context.Context) {
39 | widget.containerWidgetBase._update(ctx)
40 | }
41 |
42 | func (widget *groupWidget) setProviders(providers *widgetProviders) {
43 | widget.containerWidgetBase._setProviders(providers)
44 | }
45 |
46 | func (widget *groupWidget) requiresUpdate(now *time.Time) bool {
47 | return widget.containerWidgetBase._requiresUpdate(now)
48 | }
49 |
50 | func (widget *groupWidget) Render() template.HTML {
51 | return widget.renderTemplate(widget, groupWidgetTemplate)
52 | }
53 |
--------------------------------------------------------------------------------
/internal/glance/widget-hacker-news.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "html/template"
7 | "log/slog"
8 | "net/http"
9 | "strconv"
10 | "strings"
11 | "time"
12 | )
13 |
14 | type hackerNewsWidget struct {
15 | widgetBase `yaml:",inline"`
16 | Posts forumPostList `yaml:"-"`
17 | Limit int `yaml:"limit"`
18 | SortBy string `yaml:"sort-by"`
19 | ExtraSortBy string `yaml:"extra-sort-by"`
20 | CollapseAfter int `yaml:"collapse-after"`
21 | CommentsUrlTemplate string `yaml:"comments-url-template"`
22 | ShowThumbnails bool `yaml:"-"`
23 | }
24 |
25 | func (widget *hackerNewsWidget) initialize() error {
26 | widget.
27 | withTitle("Hacker News").
28 | withTitleURL("https://news.ycombinator.com/").
29 | withCacheDuration(30 * time.Minute)
30 |
31 | if widget.Limit <= 0 {
32 | widget.Limit = 15
33 | }
34 |
35 | if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
36 | widget.CollapseAfter = 5
37 | }
38 |
39 | if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" {
40 | widget.SortBy = "top"
41 | }
42 |
43 | return nil
44 | }
45 |
46 | func (widget *hackerNewsWidget) update(ctx context.Context) {
47 | posts, err := fetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate)
48 |
49 | if !widget.canContinueUpdateAfterHandlingErr(err) {
50 | return
51 | }
52 |
53 | if widget.ExtraSortBy == "engagement" {
54 | posts.calculateEngagement()
55 | posts.sortByEngagement()
56 | }
57 |
58 | if widget.Limit < len(posts) {
59 | posts = posts[:widget.Limit]
60 | }
61 |
62 | widget.Posts = posts
63 | }
64 |
65 | func (widget *hackerNewsWidget) Render() template.HTML {
66 | return widget.renderTemplate(widget, forumPostsTemplate)
67 | }
68 |
69 | type hackerNewsPostResponseJson struct {
70 | Id int `json:"id"`
71 | Score int `json:"score"`
72 | Title string `json:"title"`
73 | TargetUrl string `json:"url,omitempty"`
74 | CommentCount int `json:"descendants"`
75 | TimePosted int64 `json:"time"`
76 | }
77 |
78 | func fetchHackerNewsPostIds(sort string) ([]int, error) {
79 | request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
80 | response, err := decodeJsonFromRequest[[]int](defaultHTTPClient, request)
81 | if err != nil {
82 | return nil, fmt.Errorf("%w: could not fetch list of post IDs", errNoContent)
83 | }
84 |
85 | return response, nil
86 | }
87 |
88 | func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (forumPostList, error) {
89 | requests := make([]*http.Request, len(postIds))
90 |
91 | for i, id := range postIds {
92 | request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil)
93 | requests[i] = request
94 | }
95 |
96 | task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultHTTPClient)
97 | job := newJob(task, requests).withWorkers(30)
98 | results, errs, err := workerPoolDo(job)
99 | if err != nil {
100 | return nil, err
101 | }
102 |
103 | posts := make(forumPostList, 0, len(postIds))
104 |
105 | for i := range results {
106 | if errs[i] != nil {
107 | slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL)
108 | continue
109 | }
110 |
111 | var commentsUrl string
112 |
113 | if commentsUrlTemplate == "" {
114 | commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
115 | } else {
116 | commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
117 | }
118 |
119 | posts = append(posts, forumPost{
120 | Title: results[i].Title,
121 | DiscussionUrl: commentsUrl,
122 | TargetUrl: results[i].TargetUrl,
123 | TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
124 | CommentCount: results[i].CommentCount,
125 | Score: results[i].Score,
126 | TimePosted: time.Unix(results[i].TimePosted, 0),
127 | })
128 | }
129 |
130 | if len(posts) == 0 {
131 | return nil, errNoContent
132 | }
133 |
134 | if len(posts) != len(postIds) {
135 | return posts, fmt.Errorf("%w could not fetch some hacker news posts", errPartialContent)
136 | }
137 |
138 | return posts, nil
139 | }
140 |
141 | func fetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (forumPostList, error) {
142 | postIds, err := fetchHackerNewsPostIds(sort)
143 | if err != nil {
144 | return nil, err
145 | }
146 |
147 | if len(postIds) > limit {
148 | postIds = postIds[:limit]
149 | }
150 |
151 | return fetchHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
152 | }
153 |
--------------------------------------------------------------------------------
/internal/glance/widget-html.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "html/template"
5 | )
6 |
7 | type htmlWidget struct {
8 | widgetBase `yaml:",inline"`
9 | Source template.HTML `yaml:"source"`
10 | }
11 |
12 | func (widget *htmlWidget) initialize() error {
13 | widget.withTitle("").withError(nil)
14 |
15 | return nil
16 | }
17 |
18 | func (widget *htmlWidget) Render() template.HTML {
19 | return widget.Source
20 | }
21 |
--------------------------------------------------------------------------------
/internal/glance/widget-iframe.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "html/template"
7 | "net/url"
8 | )
9 |
10 | var iframeWidgetTemplate = mustParseTemplate("iframe.html", "widget-base.html")
11 |
12 | type iframeWidget struct {
13 | widgetBase `yaml:",inline"`
14 | cachedHTML template.HTML `yaml:"-"`
15 | Source string `yaml:"source"`
16 | Height int `yaml:"height"`
17 | }
18 |
19 | func (widget *iframeWidget) initialize() error {
20 | widget.withTitle("IFrame").withError(nil)
21 |
22 | if widget.Source == "" {
23 | return errors.New("source is required")
24 | }
25 |
26 | if _, err := url.Parse(widget.Source); err != nil {
27 | return fmt.Errorf("parsing URL: %v", err)
28 | }
29 |
30 | if widget.Height == 50 {
31 | widget.Height = 300
32 | } else if widget.Height < 50 {
33 | widget.Height = 50
34 | }
35 |
36 | widget.cachedHTML = widget.renderTemplate(widget, iframeWidgetTemplate)
37 |
38 | return nil
39 | }
40 |
41 | func (widget *iframeWidget) Render() template.HTML {
42 | return widget.cachedHTML
43 | }
44 |
--------------------------------------------------------------------------------
/internal/glance/widget-lobsters.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "context"
5 | "html/template"
6 | "net/http"
7 | "strings"
8 | "time"
9 | )
10 |
11 | type lobstersWidget struct {
12 | widgetBase `yaml:",inline"`
13 | Posts forumPostList `yaml:"-"`
14 | InstanceURL string `yaml:"instance-url"`
15 | CustomURL string `yaml:"custom-url"`
16 | Limit int `yaml:"limit"`
17 | CollapseAfter int `yaml:"collapse-after"`
18 | SortBy string `yaml:"sort-by"`
19 | Tags []string `yaml:"tags"`
20 | ShowThumbnails bool `yaml:"-"`
21 | }
22 |
23 | func (widget *lobstersWidget) initialize() error {
24 | widget.withTitle("Lobsters").withCacheDuration(time.Hour)
25 |
26 | if widget.InstanceURL == "" {
27 | widget.withTitleURL("https://lobste.rs")
28 | } else {
29 | widget.withTitleURL(widget.InstanceURL)
30 | }
31 |
32 | if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") {
33 | widget.SortBy = "hot"
34 | }
35 |
36 | if widget.Limit <= 0 {
37 | widget.Limit = 15
38 | }
39 |
40 | if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
41 | widget.CollapseAfter = 5
42 | }
43 |
44 | return nil
45 | }
46 |
47 | func (widget *lobstersWidget) update(ctx context.Context) {
48 | posts, err := fetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags)
49 |
50 | if !widget.canContinueUpdateAfterHandlingErr(err) {
51 | return
52 | }
53 |
54 | if widget.Limit < len(posts) {
55 | posts = posts[:widget.Limit]
56 | }
57 |
58 | widget.Posts = posts
59 | }
60 |
61 | func (widget *lobstersWidget) Render() template.HTML {
62 | return widget.renderTemplate(widget, forumPostsTemplate)
63 | }
64 |
65 | type lobstersPostResponseJson struct {
66 | CreatedAt string `json:"created_at"`
67 | Title string `json:"title"`
68 | URL string `json:"url"`
69 | Score int `json:"score"`
70 | CommentCount int `json:"comment_count"`
71 | CommentsURL string `json:"comments_url"`
72 | Tags []string `json:"tags"`
73 | }
74 |
75 | type lobstersFeedResponseJson []lobstersPostResponseJson
76 |
77 | func fetchLobstersPostsFromFeed(feedUrl string) (forumPostList, error) {
78 | request, err := http.NewRequest("GET", feedUrl, nil)
79 | if err != nil {
80 | return nil, err
81 | }
82 |
83 | feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultHTTPClient, request)
84 | if err != nil {
85 | return nil, err
86 | }
87 |
88 | posts := make(forumPostList, 0, len(feed))
89 |
90 | for i := range feed {
91 | createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt)
92 |
93 | posts = append(posts, forumPost{
94 | Title: feed[i].Title,
95 | DiscussionUrl: feed[i].CommentsURL,
96 | TargetUrl: feed[i].URL,
97 | TargetUrlDomain: extractDomainFromUrl(feed[i].URL),
98 | CommentCount: feed[i].CommentCount,
99 | Score: feed[i].Score,
100 | TimePosted: createdAt,
101 | Tags: feed[i].Tags,
102 | })
103 | }
104 |
105 | if len(posts) == 0 {
106 | return nil, errNoContent
107 | }
108 |
109 | return posts, nil
110 | }
111 |
112 | func fetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (forumPostList, error) {
113 | var feedUrl string
114 |
115 | if customURL != "" {
116 | feedUrl = customURL
117 | } else {
118 | if instanceURL != "" {
119 | instanceURL = strings.TrimRight(instanceURL, "/") + "/"
120 | } else {
121 | instanceURL = "https://lobste.rs/"
122 | }
123 |
124 | if sortBy == "hot" {
125 | sortBy = "hottest"
126 | } else if sortBy == "new" {
127 | sortBy = "newest"
128 | }
129 |
130 | if len(tags) == 0 {
131 | feedUrl = instanceURL + sortBy + ".json"
132 | } else {
133 | tags := strings.Join(tags, ",")
134 | feedUrl = instanceURL + "t/" + tags + ".json"
135 | }
136 | }
137 |
138 | posts, err := fetchLobstersPostsFromFeed(feedUrl)
139 | if err != nil {
140 | return nil, err
141 | }
142 |
143 | return posts, nil
144 | }
145 |
--------------------------------------------------------------------------------
/internal/glance/widget-old-calendar.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "context"
5 | "html/template"
6 | "time"
7 | )
8 |
9 | var oldCalendarWidgetTemplate = mustParseTemplate("old-calendar.html", "widget-base.html")
10 |
11 | type oldCalendarWidget struct {
12 | widgetBase `yaml:",inline"`
13 | Calendar *calendar
14 | StartSunday bool `yaml:"start-sunday"`
15 | }
16 |
17 | func (widget *oldCalendarWidget) initialize() error {
18 | widget.withTitle("Calendar").withCacheOnTheHour()
19 |
20 | return nil
21 | }
22 |
23 | func (widget *oldCalendarWidget) update(ctx context.Context) {
24 | widget.Calendar = newCalendar(time.Now(), widget.StartSunday)
25 | widget.withError(nil).scheduleNextUpdate()
26 | }
27 |
28 | func (widget *oldCalendarWidget) Render() template.HTML {
29 | return widget.renderTemplate(widget, oldCalendarWidgetTemplate)
30 | }
31 |
32 | type calendar struct {
33 | CurrentDay int
34 | CurrentWeekNumber int
35 | CurrentMonthName string
36 | CurrentYear int
37 | Days []int
38 | }
39 |
40 | // TODO: very inflexible, refactor to allow more customizability
41 | // TODO: allow changing between showing the previous and next week and the entire month
42 | func newCalendar(now time.Time, startSunday bool) *calendar {
43 | year, week := now.ISOWeek()
44 | weekday := now.Weekday()
45 | if !startSunday {
46 | weekday = (weekday + 6) % 7 // Shift Monday to 0
47 | }
48 |
49 | currentMonthDays := daysInMonth(now.Month(), year)
50 |
51 | var previousMonthDays int
52 |
53 | if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
54 | previousMonthDays = daysInMonth(12, year-1)
55 | } else {
56 | previousMonthDays = daysInMonth(previousMonthNumber, year)
57 | }
58 |
59 | startDaysFrom := now.Day() - int(weekday) - 7
60 |
61 | days := make([]int, 21)
62 |
63 | for i := 0; i < 21; i++ {
64 | day := startDaysFrom + i
65 |
66 | if day < 1 {
67 | day = previousMonthDays + day
68 | } else if day > currentMonthDays {
69 | day = day - currentMonthDays
70 | }
71 |
72 | days[i] = day
73 | }
74 |
75 | return &calendar{
76 | CurrentDay: now.Day(),
77 | CurrentWeekNumber: week,
78 | CurrentMonthName: now.Month().String(),
79 | CurrentYear: year,
80 | Days: days,
81 | }
82 | }
83 |
84 | func daysInMonth(m time.Month, year int) int {
85 | return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
86 | }
87 |
--------------------------------------------------------------------------------
/internal/glance/widget-search.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "fmt"
5 | "html/template"
6 | "strings"
7 | )
8 |
9 | var searchWidgetTemplate = mustParseTemplate("search.html", "widget-base.html")
10 |
11 | type SearchBang struct {
12 | Title string
13 | Shortcut string
14 | URL string
15 | }
16 |
17 | type searchWidget struct {
18 | widgetBase `yaml:",inline"`
19 | cachedHTML template.HTML `yaml:"-"`
20 | SearchEngine string `yaml:"search-engine"`
21 | Bangs []SearchBang `yaml:"bangs"`
22 | NewTab bool `yaml:"new-tab"`
23 | Target string `yaml:"target"`
24 | Autofocus bool `yaml:"autofocus"`
25 | Placeholder string `yaml:"placeholder"`
26 | }
27 |
28 | func convertSearchUrl(url string) string {
29 | // Go's template is being stubborn and continues to escape the curlies in the
30 | // URL regardless of what the type of the variable is so this is my way around it
31 | return strings.ReplaceAll(url, "{QUERY}", "!QUERY!")
32 | }
33 |
34 | var searchEngines = map[string]string{
35 | "duckduckgo": "https://duckduckgo.com/?q={QUERY}",
36 | "google": "https://www.google.com/search?q={QUERY}",
37 | "bing": "https://www.bing.com/search?q={QUERY}",
38 | "perplexity": "https://www.perplexity.ai/search?q={QUERY}",
39 | "kagi": "https://kagi.com/search?q={QUERY}",
40 | "startpage": "https://www.startpage.com/search?q={QUERY}",
41 | }
42 |
43 | func (widget *searchWidget) initialize() error {
44 | widget.withTitle("Search").withError(nil)
45 |
46 | if widget.SearchEngine == "" {
47 | widget.SearchEngine = "duckduckgo"
48 | }
49 |
50 | if widget.Placeholder == "" {
51 | widget.Placeholder = "Type here to search…"
52 | }
53 |
54 | if url, ok := searchEngines[widget.SearchEngine]; ok {
55 | widget.SearchEngine = url
56 | }
57 |
58 | widget.SearchEngine = convertSearchUrl(widget.SearchEngine)
59 |
60 | for i := range widget.Bangs {
61 | if widget.Bangs[i].Shortcut == "" {
62 | return fmt.Errorf("search bang #%d has no shortcut", i+1)
63 | }
64 |
65 | if widget.Bangs[i].URL == "" {
66 | return fmt.Errorf("search bang #%d has no URL", i+1)
67 | }
68 |
69 | widget.Bangs[i].URL = convertSearchUrl(widget.Bangs[i].URL)
70 | }
71 |
72 | widget.cachedHTML = widget.renderTemplate(widget, searchWidgetTemplate)
73 | return nil
74 | }
75 |
76 | func (widget *searchWidget) Render() template.HTML {
77 | return widget.cachedHTML
78 | }
79 |
--------------------------------------------------------------------------------
/internal/glance/widget-server-stats.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "context"
5 | "html/template"
6 | "log/slog"
7 | "net/http"
8 | "strconv"
9 | "strings"
10 | "sync"
11 | "time"
12 |
13 | "github.com/glanceapp/glance/pkg/sysinfo"
14 | )
15 |
16 | var serverStatsWidgetTemplate = mustParseTemplate("server-stats.html", "widget-base.html")
17 |
18 | type serverStatsWidget struct {
19 | widgetBase `yaml:",inline"`
20 | Servers []serverStatsRequest `yaml:"servers"`
21 | }
22 |
23 | func (widget *serverStatsWidget) initialize() error {
24 | widget.withTitle("Server Stats").withCacheDuration(15 * time.Second)
25 | widget.widgetBase.WIP = true
26 |
27 | if len(widget.Servers) == 0 {
28 | widget.Servers = []serverStatsRequest{{Type: "local"}}
29 | }
30 |
31 | for i := range widget.Servers {
32 | widget.Servers[i].URL = strings.TrimRight(widget.Servers[i].URL, "/")
33 |
34 | if widget.Servers[i].Timeout == 0 {
35 | widget.Servers[i].Timeout = durationField(3 * time.Second)
36 | }
37 | }
38 |
39 | return nil
40 | }
41 |
42 | func (widget *serverStatsWidget) update(context.Context) {
43 | // Refactor later, most of it may change depending on feedback
44 | var wg sync.WaitGroup
45 |
46 | for i := range widget.Servers {
47 | serv := &widget.Servers[i]
48 |
49 | if serv.Type == "local" {
50 | info, errs := sysinfo.Collect(serv.SystemInfoRequest)
51 |
52 | if len(errs) > 0 {
53 | for i := range errs {
54 | slog.Warn("Getting system info: " + errs[i].Error())
55 | }
56 | }
57 |
58 | serv.IsReachable = true
59 | serv.Info = info
60 | } else {
61 | wg.Add(1)
62 | go func() {
63 | defer wg.Done()
64 | info, err := fetchRemoteServerInfo(serv)
65 | if err != nil {
66 | slog.Warn("Getting remote system info: " + err.Error())
67 | serv.IsReachable = false
68 | serv.Info = &sysinfo.SystemInfo{
69 | Hostname: "Unnamed server #" + strconv.Itoa(i+1),
70 | }
71 | } else {
72 | serv.IsReachable = true
73 | serv.Info = info
74 | }
75 | }()
76 | }
77 | }
78 |
79 | wg.Wait()
80 | widget.withError(nil).scheduleNextUpdate()
81 | }
82 |
83 | func (widget *serverStatsWidget) Render() template.HTML {
84 | return widget.renderTemplate(widget, serverStatsWidgetTemplate)
85 | }
86 |
87 | type serverStatsRequest struct {
88 | *sysinfo.SystemInfoRequest `yaml:",inline"`
89 | Info *sysinfo.SystemInfo `yaml:"-"`
90 | IsReachable bool `yaml:"-"`
91 | StatusText string `yaml:"-"`
92 | Name string `yaml:"name"`
93 | HideSwap bool `yaml:"hide-swap"`
94 | Type string `yaml:"type"`
95 | URL string `yaml:"url"`
96 | Token string `yaml:"token"`
97 | Timeout durationField `yaml:"timeout"`
98 | // Support for other agents
99 | // Provider string `yaml:"provider"`
100 | }
101 |
102 | func fetchRemoteServerInfo(infoReq *serverStatsRequest) (*sysinfo.SystemInfo, error) {
103 | ctx, cancel := context.WithTimeout(context.Background(), time.Duration(infoReq.Timeout))
104 | defer cancel()
105 |
106 | request, _ := http.NewRequestWithContext(ctx, "GET", infoReq.URL+"/api/sysinfo/all", nil)
107 | if infoReq.Token != "" {
108 | request.Header.Set("Authorization", "Bearer "+infoReq.Token)
109 | }
110 |
111 | info, err := decodeJsonFromRequest[*sysinfo.SystemInfo](defaultHTTPClient, request)
112 | if err != nil {
113 | return nil, err
114 | }
115 |
116 | return info, nil
117 | }
118 |
--------------------------------------------------------------------------------
/internal/glance/widget-shared.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "math"
5 | "sort"
6 | "time"
7 | )
8 |
9 | const twitchGqlEndpoint = "https://gql.twitch.tv/gql"
10 | const twitchGqlClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko"
11 |
12 | var forumPostsTemplate = mustParseTemplate("forum-posts.html", "widget-base.html")
13 |
14 | type forumPost struct {
15 | Title string
16 | DiscussionUrl string
17 | TargetUrl string
18 | TargetUrlDomain string
19 | ThumbnailUrl string
20 | CommentCount int
21 | Score int
22 | Engagement float64
23 | TimePosted time.Time
24 | Tags []string
25 | IsCrosspost bool
26 | }
27 |
28 | type forumPostList []forumPost
29 |
30 | const depreciatePostsOlderThanHours = 7
31 | const maxDepreciation = 0.9
32 | const maxDepreciationAfterHours = 24
33 |
34 | func (p forumPostList) calculateEngagement() {
35 | var totalComments int
36 | var totalScore int
37 |
38 | for i := range p {
39 | totalComments += p[i].CommentCount
40 | totalScore += p[i].Score
41 | }
42 |
43 | numberOfPosts := float64(len(p))
44 | averageComments := float64(totalComments) / numberOfPosts
45 | averageScore := float64(totalScore) / numberOfPosts
46 |
47 | for i := range p {
48 | p[i].Engagement = (float64(p[i].CommentCount)/averageComments + float64(p[i].Score)/averageScore) / 2
49 |
50 | elapsed := time.Since(p[i].TimePosted)
51 |
52 | if elapsed < time.Hour*depreciatePostsOlderThanHours {
53 | continue
54 | }
55 |
56 | p[i].Engagement *= 1.0 - (math.Max(elapsed.Hours()-depreciatePostsOlderThanHours, maxDepreciationAfterHours)/maxDepreciationAfterHours)*maxDepreciation
57 | }
58 | }
59 |
60 | func (p forumPostList) sortByEngagement() {
61 | sort.Slice(p, func(i, j int) bool {
62 | return p[i].Engagement > p[j].Engagement
63 | })
64 | }
65 |
--------------------------------------------------------------------------------
/internal/glance/widget-split-column.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "context"
5 | "html/template"
6 | "time"
7 | )
8 |
9 | var splitColumnWidgetTemplate = mustParseTemplate("split-column.html", "widget-base.html")
10 |
11 | type splitColumnWidget struct {
12 | widgetBase `yaml:",inline"`
13 | containerWidgetBase `yaml:",inline"`
14 | MaxColumns int `yaml:"max-columns"`
15 | }
16 |
17 | func (widget *splitColumnWidget) initialize() error {
18 | widget.withError(nil).withTitle("Split Column").setHideHeader(true)
19 |
20 | if err := widget.containerWidgetBase._initializeWidgets(); err != nil {
21 | return err
22 | }
23 |
24 | if widget.MaxColumns < 2 {
25 | widget.MaxColumns = 2
26 | }
27 |
28 | return nil
29 | }
30 |
31 | func (widget *splitColumnWidget) update(ctx context.Context) {
32 | widget.containerWidgetBase._update(ctx)
33 | }
34 |
35 | func (widget *splitColumnWidget) setProviders(providers *widgetProviders) {
36 | widget.containerWidgetBase._setProviders(providers)
37 | }
38 |
39 | func (widget *splitColumnWidget) requiresUpdate(now *time.Time) bool {
40 | return widget.containerWidgetBase._requiresUpdate(now)
41 | }
42 |
43 | func (widget *splitColumnWidget) Render() template.HTML {
44 | return widget.renderTemplate(widget, splitColumnWidgetTemplate)
45 | }
46 |
--------------------------------------------------------------------------------
/internal/glance/widget-todo.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "html/template"
5 | )
6 |
7 | var todoWidgetTemplate = mustParseTemplate("todo.html", "widget-base.html")
8 |
9 | type todoWidget struct {
10 | widgetBase `yaml:",inline"`
11 | cachedHTML template.HTML `yaml:"-"`
12 | TodoID string `yaml:"id"`
13 | }
14 |
15 | func (widget *todoWidget) initialize() error {
16 | widget.withTitle("To-do").withError(nil)
17 |
18 | widget.cachedHTML = widget.renderTemplate(widget, todoWidgetTemplate)
19 | return nil
20 | }
21 |
22 | func (widget *todoWidget) Render() template.HTML {
23 | return widget.cachedHTML
24 | }
25 |
--------------------------------------------------------------------------------
/internal/glance/widget-twitch-top-games.go:
--------------------------------------------------------------------------------
1 | package glance
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "html/template"
8 | "net/http"
9 | "slices"
10 | "strings"
11 | "time"
12 | )
13 |
14 | var twitchGamesWidgetTemplate = mustParseTemplate("twitch-games-list.html", "widget-base.html")
15 |
16 | type twitchGamesWidget struct {
17 | widgetBase `yaml:",inline"`
18 | Categories []twitchCategory `yaml:"-"`
19 | Exclude []string `yaml:"exclude"`
20 | Limit int `yaml:"limit"`
21 | CollapseAfter int `yaml:"collapse-after"`
22 | }
23 |
24 | func (widget *twitchGamesWidget) initialize() error {
25 | widget.
26 | withTitle("Top games on Twitch").
27 | withTitleURL("https://www.twitch.tv/directory?sort=VIEWER_COUNT").
28 | withCacheDuration(time.Minute * 10)
29 |
30 | if widget.Limit <= 0 {
31 | widget.Limit = 10
32 | }
33 |
34 | if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
35 | widget.CollapseAfter = 5
36 | }
37 |
38 | return nil
39 | }
40 |
41 | func (widget *twitchGamesWidget) update(ctx context.Context) {
42 | categories, err := fetchTopGamesFromTwitch(widget.Exclude, widget.Limit)
43 |
44 | if !widget.canContinueUpdateAfterHandlingErr(err) {
45 | return
46 | }
47 |
48 | widget.Categories = categories
49 | }
50 |
51 | func (widget *twitchGamesWidget) Render() template.HTML {
52 | return widget.renderTemplate(widget, twitchGamesWidgetTemplate)
53 | }
54 |
55 | type twitchCategory struct {
56 | Slug string `json:"slug"`
57 | Name string `json:"name"`
58 | AvatarUrl string `json:"avatarURL"`
59 | ViewersCount int `json:"viewersCount"`
60 | Tags []struct {
61 | Name string `json:"tagName"`
62 | } `json:"tags"`
63 | GameReleaseDate string `json:"originalReleaseDate"`
64 | IsNew bool `json:"-"`
65 | }
66 |
67 | type twitchDirectoriesOperationResponse struct {
68 | Data struct {
69 | DirectoriesWithTags struct {
70 | Edges []struct {
71 | Node twitchCategory `json:"node"`
72 | } `json:"edges"`
73 | } `json:"directoriesWithTags"`
74 | } `json:"data"`
75 | }
76 |
77 | const twitchDirectoriesOperationRequestBody = `[
78 | {"operationName": "BrowsePage_AllDirectories","variables": {"limit": %d,"options": {"sort": "VIEWER_COUNT","tags": []}},"extensions": {"persistedQuery": {"version": 1,"sha256Hash": "2f67f71ba89f3c0ed26a141ec00da1defecb2303595f5cda4298169549783d9e"}}}
79 | ]`
80 |
81 | func fetchTopGamesFromTwitch(exclude []string, limit int) ([]twitchCategory, error) {
82 | reader := strings.NewReader(fmt.Sprintf(twitchDirectoriesOperationRequestBody, len(exclude)+limit))
83 | request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader)
84 | request.Header.Add("Client-ID", twitchGqlClientId)
85 | response, err := decodeJsonFromRequest[[]twitchDirectoriesOperationResponse](defaultHTTPClient, request)
86 | if err != nil {
87 | return nil, err
88 | }
89 |
90 | if len(response) == 0 {
91 | return nil, errors.New("no categories could be retrieved")
92 | }
93 |
94 | edges := (response)[0].Data.DirectoriesWithTags.Edges
95 | categories := make([]twitchCategory, 0, len(edges))
96 |
97 | for i := range edges {
98 | if slices.Contains(exclude, edges[i].Node.Slug) {
99 | continue
100 | }
101 |
102 | category := &edges[i].Node
103 | category.AvatarUrl = strings.Replace(category.AvatarUrl, "285x380", "144x192", 1)
104 |
105 | if len(category.Tags) > 2 {
106 | category.Tags = category.Tags[:2]
107 | }
108 |
109 | gameReleasedDate, err := time.Parse("2006-01-02T15:04:05Z", category.GameReleaseDate)
110 |
111 | if err == nil {
112 | if time.Since(gameReleasedDate) < 14*24*time.Hour {
113 | category.IsNew = true
114 | }
115 | }
116 |
117 | categories = append(categories, *category)
118 | }
119 |
120 | if len(categories) > limit {
121 | categories = categories[:limit]
122 | }
123 |
124 | return categories, nil
125 | }
126 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/glanceapp/glance/internal/glance"
7 | )
8 |
9 | func main() {
10 | os.Exit(glance.Main())
11 | }
12 |
--------------------------------------------------------------------------------