The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .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 | ![screenshot](images/themes/teal-city.png)
  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 | ![screenshot](images/themes/catppuccin-frappe.png)
 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 | ![screenshot](images/themes/catppuccin-macchiato.png)
 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 | ![screenshot](images/themes/catppuccin-mocha.png)
 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 | ![screenshot](images/themes/camouflage.png)
 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 | ![screenshot](images/themes/gruvbox.png)
 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 | ![screenshot](images/themes/kanagawa-dark.png)
 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 | ![screenshot](images/themes/tucan.png)
 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 | ![screenshot](images/themes/dracula.png)
 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 | ![screenshot](images/themes/catppuccin-latte.png)
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 | ![screenshot](images/themes/peachy.png)
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 | ![screenshot](images/themes/zebra.png)
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 "(.*?)";



    
    

    
    

    
    
    
    

    
    
    
    




    

    
The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
) 84 | var cssSingleLineCommentPattern = regexp.MustCompile(`(?m)^\s*\/\*.*?\*\/
The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
) 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 | --------------------------------------------------------------------------------