├── .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 | 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 | HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \ 13 | CMD wget --spider -q http://localhost:8080/api/healthz 14 | 15 | EXPOSE 8080/tcp 16 | ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"] 17 | -------------------------------------------------------------------------------- /Dockerfile.goreleaser: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21 2 | 3 | WORKDIR /app 4 | COPY glance . 5 | 6 | HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \ 7 | CMD wget --spider -q http://localhost:8080/api/healthz 8 | 9 | EXPOSE 8080/tcp 10 | ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"] 11 | -------------------------------------------------------------------------------- /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/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/bookmarks-widget-preview.png -------------------------------------------------------------------------------- /docs/images/calendar-legacy-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/calendar-legacy-widget-preview.png -------------------------------------------------------------------------------- /docs/images/calendar-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/calendar-widget-preview.png -------------------------------------------------------------------------------- /docs/images/change-detection-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/change-detection-widget-preview.png -------------------------------------------------------------------------------- /docs/images/clock-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/clock-widget-preview.png -------------------------------------------------------------------------------- /docs/images/column-configuration-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/column-configuration-1.png -------------------------------------------------------------------------------- /docs/images/column-configuration-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/column-configuration-2.png -------------------------------------------------------------------------------- /docs/images/column-configuration-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/column-configuration-3.png -------------------------------------------------------------------------------- /docs/images/contrast-multiplier-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/contrast-multiplier-example.png -------------------------------------------------------------------------------- /docs/images/custom-api-preview-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/custom-api-preview-1.png -------------------------------------------------------------------------------- /docs/images/custom-api-preview-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/custom-api-preview-2.png -------------------------------------------------------------------------------- /docs/images/custom-api-preview-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/custom-api-preview-3.png -------------------------------------------------------------------------------- /docs/images/dns-stats-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/dns-stats-widget-preview.png -------------------------------------------------------------------------------- /docs/images/docker-container-parent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/docker-container-parent.png -------------------------------------------------------------------------------- /docs/images/docker-container-parent2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/docker-container-parent2.png -------------------------------------------------------------------------------- /docs/images/docker-containers-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/docker-containers-preview.png -------------------------------------------------------------------------------- /docs/images/docker-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/docker-widget-preview.png -------------------------------------------------------------------------------- /docs/images/extension-html-reusing-existing-features-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/extension-html-reusing-existing-features-preview.png -------------------------------------------------------------------------------- /docs/images/extension-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/extension-overview.png -------------------------------------------------------------------------------- /docs/images/gaming-page-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/gaming-page-preview.png -------------------------------------------------------------------------------- /docs/images/group-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/group-widget-preview.png -------------------------------------------------------------------------------- /docs/images/hacker-news-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/hacker-news-widget-preview.png -------------------------------------------------------------------------------- /docs/images/head-widgets-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/head-widgets-preview.png -------------------------------------------------------------------------------- /docs/images/lobsters-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/lobsters-widget-preview.png -------------------------------------------------------------------------------- /docs/images/markets-page-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/markets-page-preview.png -------------------------------------------------------------------------------- /docs/images/markets-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/markets-widget-preview.png -------------------------------------------------------------------------------- /docs/images/mobile-header-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/mobile-header-preview.png -------------------------------------------------------------------------------- /docs/images/mobile-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/mobile-preview.png -------------------------------------------------------------------------------- /docs/images/monitor-widget-compact-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/monitor-widget-compact-preview.png -------------------------------------------------------------------------------- /docs/images/monitor-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/monitor-widget-preview.png -------------------------------------------------------------------------------- /docs/images/pages-and-columns-illustration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/pages-and-columns-illustration.png -------------------------------------------------------------------------------- /docs/images/preconfigured-page-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/preconfigured-page-preview.png -------------------------------------------------------------------------------- /docs/images/readme-main-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/readme-main-image.png -------------------------------------------------------------------------------- /docs/images/reddit-field-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/reddit-field-search.png -------------------------------------------------------------------------------- /docs/images/reddit-widget-horizontal-cards-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/reddit-widget-horizontal-cards-preview.png -------------------------------------------------------------------------------- /docs/images/reddit-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/reddit-widget-preview.png -------------------------------------------------------------------------------- /docs/images/reddit-widget-vertical-cards-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/reddit-widget-vertical-cards-preview.png -------------------------------------------------------------------------------- /docs/images/reddit-widget-vertical-list-thumbnails.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/reddit-widget-vertical-list-thumbnails.png -------------------------------------------------------------------------------- /docs/images/releases-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/releases-widget-preview.png -------------------------------------------------------------------------------- /docs/images/reorder-todo-tasks-prevew.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/reorder-todo-tasks-prevew.gif -------------------------------------------------------------------------------- /docs/images/repository-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/repository-preview.png -------------------------------------------------------------------------------- /docs/images/rss-feed-horizontal-cards-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/rss-feed-horizontal-cards-preview.png -------------------------------------------------------------------------------- /docs/images/rss-feed-vertical-list-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/rss-feed-vertical-list-preview.png -------------------------------------------------------------------------------- /docs/images/rss-widget-detailed-list-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/rss-widget-detailed-list-preview.png -------------------------------------------------------------------------------- /docs/images/rss-widget-horizontal-cards-2-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/rss-widget-horizontal-cards-2-preview.png -------------------------------------------------------------------------------- /docs/images/search-widget-bangs-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/search-widget-bangs-preview.png -------------------------------------------------------------------------------- /docs/images/search-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/search-widget-preview.png -------------------------------------------------------------------------------- /docs/images/server-stats-flame-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/server-stats-flame-icon.png -------------------------------------------------------------------------------- /docs/images/server-stats-preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/server-stats-preview.gif -------------------------------------------------------------------------------- /docs/images/split-column-widget-3-columns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/split-column-widget-3-columns.png -------------------------------------------------------------------------------- /docs/images/split-column-widget-4-columns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/split-column-widget-4-columns.png -------------------------------------------------------------------------------- /docs/images/split-column-widget-masonry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/split-column-widget-masonry.png -------------------------------------------------------------------------------- /docs/images/split-column-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/split-column-widget-preview.png -------------------------------------------------------------------------------- /docs/images/startpage-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/startpage-preview.png -------------------------------------------------------------------------------- /docs/images/themes-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/themes-example.png -------------------------------------------------------------------------------- /docs/images/themes/camouflage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/themes/camouflage.png -------------------------------------------------------------------------------- /docs/images/themes/catppuccin-frappe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/themes/catppuccin-frappe.png -------------------------------------------------------------------------------- /docs/images/themes/catppuccin-latte.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/themes/catppuccin-latte.png -------------------------------------------------------------------------------- /docs/images/themes/catppuccin-macchiato.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/themes/catppuccin-macchiato.png -------------------------------------------------------------------------------- /docs/images/themes/catppuccin-mocha.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/themes/catppuccin-mocha.png -------------------------------------------------------------------------------- /docs/images/themes/dracula.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/themes/dracula.png -------------------------------------------------------------------------------- /docs/images/themes/gruvbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/themes/gruvbox.png -------------------------------------------------------------------------------- /docs/images/themes/kanagawa-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/themes/kanagawa-dark.png -------------------------------------------------------------------------------- /docs/images/themes/peachy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/themes/peachy.png -------------------------------------------------------------------------------- /docs/images/themes/teal-city.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/themes/teal-city.png -------------------------------------------------------------------------------- /docs/images/themes/tucan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/themes/tucan.png -------------------------------------------------------------------------------- /docs/images/themes/zebra.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/themes/zebra.png -------------------------------------------------------------------------------- /docs/images/todo-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/todo-widget-preview.png -------------------------------------------------------------------------------- /docs/images/twitch-channels-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/twitch-channels-widget-preview.png -------------------------------------------------------------------------------- /docs/images/twitch-top-games-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/twitch-top-games-widget-preview.png -------------------------------------------------------------------------------- /docs/images/videos-channel-description-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/videos-channel-description-example.png -------------------------------------------------------------------------------- /docs/images/videos-copy-channel-id-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/videos-copy-channel-id-example.png -------------------------------------------------------------------------------- /docs/images/videos-widget-grid-cards-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/videos-widget-grid-cards-preview.png -------------------------------------------------------------------------------- /docs/images/videos-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/videos-widget-preview.png -------------------------------------------------------------------------------- /docs/images/videos-widget-vertical-list-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/videos-widget-vertical-list-preview.png -------------------------------------------------------------------------------- /docs/images/weather-widget-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/docs/images/weather-widget-preview.png -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/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 Hash a password") 54 | fmt.Println(" secret:make Generate a random secret key") 55 | fmt.Println(" sensors:print List all sensors") 56 | fmt.Println(" mountpoint:info Print information about a given mountpoint path") 57 | fmt.Println(" diagnose Run diagnostic checks") 58 | } 59 | 60 | configPath := flags.String("config", "glance.yml", "Set config path") 61 | err := flags.Parse(os.Args[1:]) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | var intent cliIntent 67 | args = flags.Args() 68 | unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " ")) 69 | 70 | if len(args) == 0 { 71 | intent = cliIntentServe 72 | } else if len(args) == 1 { 73 | if args[0] == "config:validate" { 74 | intent = cliIntentConfigValidate 75 | } else if args[0] == "config:print" { 76 | intent = cliIntentConfigPrint 77 | } else if args[0] == "sensors:print" { 78 | intent = cliIntentSensorsPrint 79 | } else if args[0] == "diagnose" { 80 | intent = cliIntentDiagnose 81 | } else if args[0] == "secret:make" { 82 | intent = cliIntentSecretMake 83 | } else { 84 | return nil, unknownCommandErr 85 | } 86 | } else if len(args) == 2 { 87 | if args[0] == "password:hash" { 88 | intent = cliIntentPasswordHash 89 | } else { 90 | return nil, unknownCommandErr 91 | } 92 | } else if len(args) == 2 { 93 | if args[0] == "mountpoint:info" { 94 | intent = cliIntentMountpointInfo 95 | } else { 96 | return nil, unknownCommandErr 97 | } 98 | } else { 99 | return nil, unknownCommandErr 100 | } 101 | 102 | return &cliOptions{ 103 | intent: intent, 104 | configPath: *configPath, 105 | args: args, 106 | }, nil 107 | } 108 | 109 | func cliSensorsPrint() int { 110 | tempSensors, err := sensors.SensorsTemperatures() 111 | if err != nil { 112 | if warns, ok := err.(*sensors.Warnings); ok { 113 | fmt.Printf("Could not retrieve information for some sensors (%v):\n", err) 114 | for _, w := range warns.List { 115 | fmt.Printf(" - %v\n", w) 116 | } 117 | fmt.Println() 118 | } else { 119 | fmt.Printf("Failed to retrieve sensor information: %v\n", err) 120 | return 1 121 | } 122 | } 123 | 124 | if len(tempSensors) == 0 { 125 | fmt.Println("No sensors found") 126 | return 0 127 | } 128 | 129 | fmt.Println("Sensors found:") 130 | for _, sensor := range tempSensors { 131 | fmt.Printf(" %s: %.1f°C\n", sensor.SensorKey, sensor.Temperature) 132 | } 133 | 134 | return 0 135 | } 136 | 137 | func cliMountpointInfo(requestedPath string) int { 138 | usage, err := disk.Usage(requestedPath) 139 | if err != nil { 140 | fmt.Printf("Failed to retrieve info for path %s: %v\n", requestedPath, err) 141 | if warns, ok := err.(*disk.Warnings); ok { 142 | for _, w := range warns.List { 143 | fmt.Printf(" - %v\n", w) 144 | } 145 | } 146 | 147 | return 1 148 | } 149 | 150 | fmt.Println("Path:", usage.Path) 151 | fmt.Println("FS type:", ternary(usage.Fstype == "", "unknown", usage.Fstype)) 152 | fmt.Printf("Used percent: %.1f%%\n", usage.UsedPercent) 153 | 154 | return 0 155 | } 156 | -------------------------------------------------------------------------------- /internal/glance/embed.go: -------------------------------------------------------------------------------- 1 | package glance 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "embed" 7 | "encoding/hex" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "io/fs" 12 | "log" 13 | "path/filepath" 14 | "regexp" 15 | "strconv" 16 | "strings" 17 | "time" 18 | ) 19 | 20 | //go:embed static 21 | var _staticFS embed.FS 22 | 23 | //go:embed templates 24 | var _templateFS embed.FS 25 | 26 | var staticFS, _ = fs.Sub(_staticFS, "static") 27 | var templateFS, _ = fs.Sub(_templateFS, "templates") 28 | 29 | func readAllFromStaticFS(path string) ([]byte, error) { 30 | // For some reason fs.FS only works with forward slashes, so in case we're 31 | // running on Windows or pass paths with backslashes we need to replace them. 32 | path = strings.ReplaceAll(path, "\\", "/") 33 | 34 | file, err := staticFS.Open(path) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | return io.ReadAll(file) 40 | } 41 | 42 | var staticFSHash = func() string { 43 | hash, err := computeFSHash(staticFS) 44 | if err != nil { 45 | log.Printf("Could not compute static assets cache key: %v", err) 46 | return strconv.FormatInt(time.Now().Unix(), 10) 47 | } 48 | 49 | return hash 50 | }() 51 | 52 | func computeFSHash(files fs.FS) (string, error) { 53 | hash := md5.New() 54 | 55 | err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error { 56 | if err != nil { 57 | return err 58 | } 59 | 60 | if d.IsDir() { 61 | return nil 62 | } 63 | 64 | file, err := files.Open(path) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | if _, err := io.Copy(hash, file); err != nil { 70 | return err 71 | } 72 | 73 | return nil 74 | }) 75 | 76 | if err != nil { 77 | return "", err 78 | } 79 | 80 | return hex.EncodeToString(hash.Sum(nil))[:10], nil 81 | } 82 | 83 | var cssImportPattern = regexp.MustCompile(`(?m)^@import "(.*?)";$`) 84 | var cssSingleLineCommentPattern = regexp.MustCompile(`(?m)^\s*\/\*.*?\*\/$`) 85 | 86 | // Yes, we bundle at runtime, give comptime pls 87 | var bundledCSSContents = func() []byte { 88 | const mainFilePath = "css/main.css" 89 | 90 | var recursiveParseImports func(path string, depth int) ([]byte, error) 91 | recursiveParseImports = func(path string, depth int) ([]byte, error) { 92 | if depth > 20 { 93 | return nil, errors.New("maximum import depth reached, is one of your imports circular?") 94 | } 95 | 96 | mainFileContents, err := readAllFromStaticFS(path) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | // Normalize line endings, otherwise the \r's make the regex not match 102 | mainFileContents = bytes.ReplaceAll(mainFileContents, []byte("\r\n"), []byte("\n")) 103 | 104 | mainFileDir := filepath.Dir(path) 105 | var importLastErr error 106 | 107 | parsed := cssImportPattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte { 108 | if importLastErr != nil { 109 | return nil 110 | } 111 | 112 | matches := cssImportPattern.FindSubmatch(match) 113 | if len(matches) != 2 { 114 | importLastErr = fmt.Errorf( 115 | "import didn't return expected number of capture groups: %s, expected 2, got %d", 116 | match, len(matches), 117 | ) 118 | return nil 119 | } 120 | 121 | importFilePath := filepath.Join(mainFileDir, string(matches[1])) 122 | importContents, err := recursiveParseImports(importFilePath, depth+1) 123 | if err != nil { 124 | importLastErr = err 125 | return nil 126 | } 127 | 128 | return importContents 129 | }) 130 | 131 | if importLastErr != nil { 132 | return nil, importLastErr 133 | } 134 | 135 | return parsed, nil 136 | } 137 | 138 | contents, err := recursiveParseImports(mainFilePath, 0) 139 | if err != nil { 140 | panic(fmt.Sprintf("building CSS bundle: %v", err)) 141 | } 142 | 143 | // We could strip a bunch more unnecessary characters, but the biggest 144 | // win comes from removing the whitespace at the beginning of lines 145 | // since that's at least 4 bytes per property, which yielded a ~20% reduction. 146 | contents = cssSingleLineCommentPattern.ReplaceAll(contents, nil) 147 | contents = whitespaceAtBeginningOfLinePattern.ReplaceAll(contents, nil) 148 | contents = bytes.ReplaceAll(contents, []byte("\n"), []byte("")) 149 | 150 | return contents 151 | }() 152 | -------------------------------------------------------------------------------- /internal/glance/static/app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/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/b94647efc9145beb3a47368b30f633cc8e94acc2/internal/glance/static/favicon.png -------------------------------------------------------------------------------- /internal/glance/static/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /internal/glance/static/fonts/JetBrainsMono-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glanceapp/glance/b94647efc9145beb3a47368b30f633cc8e94acc2/internal/glance/static/fonts/JetBrainsMono-Regular.woff2 -------------------------------------------------------------------------------- /internal/glance/static/icons/codeberg.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /internal/glance/static/icons/dockerhub.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /internal/glance/static/icons/github.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /internal/glance/static/icons/gitlab.svg: -------------------------------------------------------------------------------- 1 | 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 = ` 6 | 7 | `; 8 | 9 | const hidePasswordSVG = ` 10 | 11 | 12 | `; 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 + ` ` + label + ``) 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 |
5 | {{- range .Groups }} 6 |
7 | {{- if ne .Title "" }} 8 |
{{ .Title }}
9 | {{- end }} 10 |
    11 | {{- range .Links }} 12 |
  • 13 |
    14 | {{- if ne "" .Icon.URL }} 15 |
    16 | 17 |
    18 | {{- end }} 19 | {{ .Title }} 20 |
    21 | {{- if .Description }} 22 |
    {{ .Description }}
    23 | {{- end }} 24 |
  • 25 | {{- end }} 26 |
27 |
28 | {{- end }} 29 |
30 | {{ end }} 31 | -------------------------------------------------------------------------------- /internal/glance/templates/calendar.html: -------------------------------------------------------------------------------- 1 | {{ template "widget-base.html" . }} 2 | 3 | {{ define "widget-content" }} 4 |
5 |
6 |
7 | {{ end }} 8 | -------------------------------------------------------------------------------- /internal/glance/templates/change-detection.html: -------------------------------------------------------------------------------- 1 | {{ template "widget-base.html" . }} 2 | 3 | {{ define "widget-content" }} 4 | 17 | {{ end }} 18 | -------------------------------------------------------------------------------- /internal/glance/templates/clock.html: -------------------------------------------------------------------------------- 1 | {{ template "widget-base.html" . }} 2 | 3 | {{ define "widget-content" }} 4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | {{ if gt (len .Timezones) 0 }} 16 |
17 | 28 | {{ end }} 29 |
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 |
5 |
6 |
7 |
{{ .Stats.TotalQueries | formatNumber }}
8 |
QUERIES
9 |
10 |
11 |
{{ .Stats.BlockedPercent }}%
12 |
BLOCKED
13 |
14 | {{ if gt .Stats.ResponseTime 0 }} 15 |
16 |
{{ .Stats.ResponseTime | formatNumber }}ms
17 |
LATENCY
18 |
19 | {{ else }} 20 |
21 |
{{ .Stats.DomainsBlocked | formatApproxNumber }}
22 |
DOMAINS
23 |
24 | {{ end }} 25 |
26 | 27 | {{ $showGraph := not (or .HideGraph (eq (len .Stats.Series) 0)) }} 28 | {{ if $showGraph }} 29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 | 42 |
43 | {{ range $i, $column := .Stats.Series }} 44 |
45 |
46 |
47 |
48 |
{{ $column.Queries | formatNumber }}
49 |
QUERIES
50 |
51 |
52 |
{{ $column.PercentBlocked }}%
53 |
BLOCKED
54 |
55 |
56 |
57 | {{ if gt $column.PercentTotal 0}} 58 |
59 | {{ if ne $column.Queries $column.Blocked }} 60 |
61 | {{ end }} 62 | {{ if gt $column.PercentBlocked 0 }} 63 |
64 | {{ end }} 65 |
66 | {{ end }} 67 |
{{ index $.TimeLabels $i }}
68 |
69 | {{ end }} 70 |
71 |
72 | {{ end }} 73 | 74 | {{ if and (not .HideTopDomains) .Stats.TopBlockedDomains }} 75 |
76 | Top blocked domains 77 |
    78 | {{ range .Stats.TopBlockedDomains }} 79 |
  • 80 |
    {{ .Domain }}
    81 |
    {{ .PercentBlocked }}%
    82 |
  • 83 | {{ end }} 84 |
85 |
86 | {{ end }} 87 |
88 | {{ end }} 89 | -------------------------------------------------------------------------------- /internal/glance/templates/docker-containers.html: -------------------------------------------------------------------------------- 1 | {{ template "widget-base.html" . }} 2 | 3 | {{- define "widget-content" }} 4 | 46 | {{- end }} 47 | 48 | {{- define "state-icon" }} 49 | {{- if eq . "ok" }} 50 | 53 | {{- else if eq . "warn" }} 54 | 57 | {{- else if eq . "paused" }} 58 | 61 | {{- else }} 62 | 65 | {{- end }} 66 | {{- end }} 67 | -------------------------------------------------------------------------------- /internal/glance/templates/document.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ block "document-head-before" . }}{{ end }} 5 | 13 | {{ block "document-title" . }}{{ end }} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {{ if .App.Config.Theme.CustomCSSFile }}{{ end }} 28 | {{ block "document-head-after" . }}{{ end }} 29 | {{ if .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }} 30 | 31 | 32 | {{ template "document-body" . }} 33 | 34 | 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 | 11 | {{ end }} 12 | -------------------------------------------------------------------------------- /internal/glance/templates/forum-posts.html: -------------------------------------------------------------------------------- 1 | {{ template "widget-base.html" . }} 2 | 3 | {{- define "widget-content" }} 4 | 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 |
7 |
8 | {{- range $i, $widget := .Widgets }} 9 | 10 | {{- end }} 11 |
12 |
13 | 14 |
15 | {{- range $i, $widget := .Widgets }} 16 |
17 | {{- .Render -}} 18 |
19 | {{- end }} 20 |
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 | 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 | 7 | 8 | {{- end }} 9 | 10 | {{- define "document-head-after" }} 11 | 12 | 13 | {{- end }} 14 | 15 | {{- define "document-body" }} 16 |
17 |
18 |

Login

19 |
20 |
21 | 22 |
23 | 26 | 27 |
28 |
29 | 30 |
31 | 32 |
33 | 36 | 37 | 38 |
39 |
40 | 41 | 42 | 43 | 49 |
50 |
51 | {{ template "footer.html" . }} 52 |
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 |
5 | {{ range .Markets }} 6 |
7 |
8 | {{ .Symbol }} 9 |
{{ .Name }}
10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |
{{ printf "%+.2f" .PercentChange }}%
20 |
{{ .Currency }}{{ .Price | formatPriceWithPrecision .PriceHint }}
21 |
22 |
23 | {{ end }} 24 |
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 | 13 | {{ else }} 14 |
15 |

All sites are online

16 | 17 | 18 | 19 |
20 | {{ end }} 21 | {{ end }} 22 | 23 | {{ define "site" }} 24 | {{ .Title }} 25 | {{ if not .Status.TimedOut }}
{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms
{{ end }} 26 | {{ if eq .StatusStyle "ok" }} 27 |
28 | 29 | 30 | 31 |
32 | {{ else }} 33 |
34 | 35 | 36 | 37 |
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 | 13 | {{ else }} 14 |
15 |

All sites are online

16 | 17 | 18 | 19 |
20 | {{ end }} 21 | {{ end }} 22 | 23 | {{ define "site" }} 24 | {{ if .Icon.URL }} 25 | 26 | {{ end }} 27 |
28 | {{ .Title }} 29 | 39 |
40 | {{ if eq .StatusStyle "ok" }} 41 |
42 | 43 | 44 | 45 |
46 | {{ else }} 47 |
48 | 49 | 50 | 51 |
52 | {{ end }} 53 | {{ end }} 54 | -------------------------------------------------------------------------------- /internal/glance/templates/old-calendar.html: -------------------------------------------------------------------------------- 1 | {{ template "widget-base.html" . }} 2 | 3 | {{ define "widget-content" }} 4 |
5 |
6 |
{{ .Calendar.CurrentMonthName }}
7 |
    8 |
  • Week {{ .Calendar.CurrentWeekNumber }}
  • 9 |
  • {{ .Calendar.CurrentYear }}
  • 10 |
11 |
12 | 13 |
14 | {{ if .StartSunday }} 15 |
Su
16 | {{ end }} 17 |
Mo
18 |
Tu
19 |
We
20 |
Th
21 |
Fr
22 |
Sa
23 | {{ if not .StartSunday }} 24 |
Su
25 | {{ end }} 26 |
27 | 28 |
29 | {{ range .Calendar.Days }} 30 |
{{ . }}
31 | {{ end }} 32 |
33 |
34 | {{ end }} 35 | -------------------------------------------------------------------------------- /internal/glance/templates/page-content.html: -------------------------------------------------------------------------------- 1 | {{ if .Page.ShowMobileHeader }} 2 |
{{ .Page.Title }}
3 | {{ end }} 4 | 5 | {{ if .Page.HeadWidgets }} 6 |
7 | {{- range .Page.HeadWidgets }} 8 | {{- .Render }} 9 | {{- end }} 10 |
11 | {{ end }} 12 | 13 |
14 | {{- range .Page.Columns }} 15 |
16 | {{- range .Widgets }} 17 | {{- .Render }} 18 | {{- end }} 19 |
20 | {{- end }} 21 |
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 | 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 |
7 | {{ range .Posts }} 8 |
9 | {{ if ne "" .ThumbnailUrl }} 10 |
11 | 12 |
13 | {{ end }} 14 |
15 | {{ if ne "" .TargetUrl }} 16 | {{ .TargetUrlDomain }} 17 | {{ else }} 18 |
/r/{{ $.Subreddit }}
19 | {{ end }} 20 | {{ .Title }} 21 |
    22 |
  • 23 |
  • {{ .Score | formatApproxNumber }} points
  • 24 |
25 |
26 |
27 | {{ end }} 28 |
29 | {{ end }} 30 | -------------------------------------------------------------------------------- /internal/glance/templates/releases.html: -------------------------------------------------------------------------------- 1 | {{ template "widget-base.html" . }} 2 | 3 | {{ define "widget-content" }} 4 | 23 | {{ end }} 24 | -------------------------------------------------------------------------------- /internal/glance/templates/repository.html: -------------------------------------------------------------------------------- 1 | {{ template "widget-base.html" . }} 2 | 3 | {{ define "widget-content" }} 4 | {{ .Repository.Name }} 5 | 9 | 10 | {{ if gt (len .Repository.Commits) 0 }} 11 |
12 | Last {{ .CommitsLimit }} commits 13 |
14 | 19 | 24 |
25 | {{ end }} 26 | 27 | {{ if gt (len .Repository.PullRequests) 0 }} 28 |
29 | Open pull requests ({{ .Repository.OpenPullRequests | formatNumber }} total) 30 |
31 | 36 | 41 |
42 | {{ end }} 43 | 44 | {{ if gt (len .Repository.Issues) 0 }} 45 |
46 | Open issues ({{ .Repository.OpenIssues | formatNumber }} total) 47 |
48 | 53 | 58 |
59 | {{ end }} 60 | 61 | {{ end }} 62 | -------------------------------------------------------------------------------- /internal/glance/templates/rss-detailed-list.html: -------------------------------------------------------------------------------- 1 | {{ template "widget-base.html" . }} 2 | 3 | {{ define "widget-content" }} 4 | 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 | 29 | {{ else }} 30 |
{{ .NoItemsMessage }}
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 | 29 | {{ else }} 30 |
{{ .NoItemsMessage }}
31 | {{ end }} 32 | {{ end }} 33 | -------------------------------------------------------------------------------- /internal/glance/templates/rss-list.html: -------------------------------------------------------------------------------- 1 | {{ template "widget-base.html" . }} 2 | 3 | {{ define "widget-content" }} 4 | 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 | 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 |
7 | {{ range .Widgets }} 8 | {{ .Render }} 9 | {{ end }} 10 |
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 | 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 |
5 | {{ end }} 6 | -------------------------------------------------------------------------------- /internal/glance/templates/twitch-channels.html: -------------------------------------------------------------------------------- 1 | {{ template "widget-base.html" . }} 2 | 3 | {{ define "widget-content" }} 4 | 47 | {{ end }} 48 | -------------------------------------------------------------------------------- /internal/glance/templates/twitch-games-list.html: -------------------------------------------------------------------------------- 1 | {{ template "widget-base.html" . }} 2 | 3 | {{ define "widget-content" }} 4 | 31 | {{ end }} 32 | -------------------------------------------------------------------------------- /internal/glance/templates/v0.7-update-notice-page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Update notice 8 | 24 | 25 | 26 | 27 |
28 |

UPDATE NOTICE

29 |
30 |

31 | The default location of glance.yml in the Docker image has 32 | changed since v0.7.0, please see the migration guide 33 | for instructions or visit the release notes 34 | to find out more about why this change was necessary. Sorry for the inconvenience. 35 |

36 | 37 |

Migration should take around 5 minutes.

38 |
39 |
40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /internal/glance/templates/video-card-contents.html: -------------------------------------------------------------------------------- 1 | {{ define "video-card-contents" }} 2 | 3 |
4 | {{ .Title }} 5 | 11 |
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 |
7 | {{ range .Videos }} 8 |
9 | {{ template "video-card-contents" . }} 10 |
11 | {{ end }} 12 |
13 | {{ end }} 14 | -------------------------------------------------------------------------------- /internal/glance/templates/videos-vertical-list.html: -------------------------------------------------------------------------------- 1 | {{ template "widget-base.html" . }} 2 | 3 | {{- define "widget-content" }} 4 | 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 | 15 | {{ end }} 16 | -------------------------------------------------------------------------------- /internal/glance/templates/weather.html: -------------------------------------------------------------------------------- 1 | {{ template "widget-base.html" . }} 2 | 3 | {{ define "widget-content" }} 4 |
5 |
{{ .Weather.WeatherCodeAsString }}
6 |
Feels like {{ .Weather.ApparentTemperature }}°{{ if eq .Units "metric" }}C{{ else }}F{{ end }}
7 | 8 |
9 | {{ range $i, $column := .Weather.Columns }} 10 |
11 | {{ if $column.HasPrecipitation }} 12 |
13 | {{ end }} 14 | {{ if and (ge $i $.Weather.SunriseColumn) (le $i $.Weather.SunsetColumn ) }} 15 |
16 | {{ end }} 17 |
{{ $column.Temperature | absInt }}
18 |
19 |
{{ index $.TimeLabels $i }}
20 |
21 | {{ end }} 22 |
23 | 24 | {{ if not .HideLocation }} 25 |
26 |
27 |
{{ .Place.Name }},{{ if .ShowAreaName }} {{ .Place.Area }},{{ end }} {{ .Place.Country }}
28 |
29 | {{ end }} 30 |
31 | {{ end }} 32 | -------------------------------------------------------------------------------- /internal/glance/templates/widget-base.html: -------------------------------------------------------------------------------- 1 |
2 | {{- if not .HideHeader }} 3 |
4 | {{- if ne "" .TitleURL }} 5 |

{{ .Title }}

6 | {{- else }} 7 |

{{ .Title }}

8 | {{- end }} 9 | {{- if .IsWIP }} 10 |
11 |
12 |

WORK IN PROGRESS

13 |

This widget is still in development, certain features may not work as expected or may change drastically.

14 | Report issue 15 |
16 | 17 | 18 | 19 |
20 | {{- end }} 21 | {{- if and .Error .ContentAvailable }} 22 |
23 | {{- else if .Notice }} 24 |
25 | {{- end }} 26 |
27 | {{- end }} 28 |
29 | {{- if .ContentAvailable }} 30 | {{ block "widget-content" . }}{{ end }} 31 | {{- else }} 32 |
33 |
ERROR
34 | 35 | 36 | 37 |
38 |

{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}

39 | {{- end}} 40 |
41 |
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 | --------------------------------------------------------------------------------