diff --git a/.gitignore b/.gitignore index 062999d..f7e0f6c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ /assets /build /playground -glance.yml +glance*.yml diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c9c5297..7153a4f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -50,9 +50,9 @@ dockers: dockerfile: Dockerfile.goreleaser - image_templates: - - &arm64v7_image "{{ .ProjectName }}:{{ .Tag }}-arm64v7" + - &armv7_image "{{ .ProjectName }}:{{ .Tag }}-armv7" build_flag_templates: - - --platform=linux/arm64/v7 + - --platform=linux/arm/v7 goarch: arm goarm: 7 use: buildx @@ -60,13 +60,10 @@ dockers: docker_manifests: - name_template: "{{ .ProjectName }}:{{ .Tag }}" - image_templates: + image_templates: &multiarch_images - *amd64_image - *arm64v8_image - - *arm64v7_image + - *armv7_image - name_template: "{{ .ProjectName }}:latest" skip_push: auto - image_templates: - - *amd64_image - - *arm64v8_image - - *arm64v7_image + image_templates: *multiarch_images diff --git a/README.md b/README.md index 25db4df..0e8cfb4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

What if you could see everything at a...

Glance

-

InstallConfigurationThemes

+

InstallConfigurationPreconfigured pagesThemesDiscord

![example homepage](docs/images/readme-main-image.png) diff --git a/docs/configuration.md b/docs/configuration.md index 7d4669f..d39c8b5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,6 +3,7 @@ - [Intro](#intro) - [Preconfigured page](#preconfigured-page) - [Server](#server) +- [Branding](#branding) - [Theme](#theme) - [Themes](#themes) - [Pages & Columns](#pages--columns) @@ -13,10 +14,12 @@ - [Lobsters](#lobsters) - [Reddit](#reddit) - [Search](#search-widget) + - [Group](#group) - [Extension](#extension) - [Weather](#weather) - [Monitor](#monitor) - [Releases](#releases) + - [DNS Stats](#dns-stats) - [Repository](#repository) - [Bookmarks](#bookmarks) - [Calendar](#calendar) @@ -123,6 +126,7 @@ server: | ---- | ---- | -------- | ------- | | host | string | no | | | port | number | no | 8080 | +| base-url | string | no | | | assets-path | string | no | | #### `host` @@ -131,6 +135,9 @@ The address which the server will listen on. Setting it to `localhost` means tha #### `port` A number between 1 and 65,535, so long as that port isn't already used by anything else. +#### `base-url` +The base URL that Glance is hosted under. No need to specify this unless you're using a reverse proxy and are hosting Glance under a directory. If that's the case then you can set this value to `/glance` or whatever the directory is called. Note that the forward slash (`/`) in the beginning is required unless you specify the full domain and path. + #### `assets-path` The path to a directory that will be served by the server under the `/assets/` path. This is handy for widgets like the Monitor where you have to specify an icon URL and you want to self host all the icons rather than pointing to an external source. @@ -168,6 +175,42 @@ To be able to point to an asset from your assets path, use the `/assets/` path l icon: /assets/gitea-icon.png ``` +## Branding +You can adjust the various parts of the branding through a top level `branding` property. Example: + +```yaml +branding: + custom-footer: | +

Powered by Glance

+ logo-url: /assets/logo.png + favicon-url: /assets/logo.png +``` + +### Properties + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| hide-footer | bool | no | false | +| custom-footer | string | no | | +| logo-text | string | no | G | +| logo-url | string | no | | +| favicon-url | string | no | | + +#### `hide-footer` +Hides the footer when set to `true`. + +#### `custom-footer` +Specify custom HTML to use for the footer. + +#### `logo-text` +Specify custom text to use instead of the "G" found in the navigation. + +#### `logo-url` +Specify a URL to a custom image to use instead of the "G" found in the navigation. If both `logo-text` and `logo-url` are set, only `logo-url` will be used. + +#### `favicon-url` +Specify a URL to a custom image to use for the favicon. + ## Theme Theming is done through a top level `theme` property. Values for the colors are in [HSL](https://giggster.com/guide/basics/hue-saturation-lightness/) (hue, saturation, lightness) format. You can use a color picker [like this one](https://hslpicker.com/) to convert colors from other formats to HSL. The values are separated by a space and `%` is not required for any of the numbers. @@ -234,6 +277,8 @@ theme: > .widget-type-rss a { > font-size: 1.5rem; > } +> +> In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets. ## Pages & Columns @@ -261,6 +306,8 @@ pages: | ---- | ---- | -------- | ------- | | title | string | yes | | | slug | string | no | | +| width | string | no | | +| hide-desktop-navigation | boolean | no | false | | show-mobile-header | boolean | no | false | | columns | array | yes | | @@ -270,6 +317,21 @@ The name of the page which gets shown in the navigation bar. #### `slug` The URL friendly version of the title which is used to access the page. For example if the title of the page is "RSS Feeds" you can make the page accessible via `localhost:8080/feeds` by setting the slug to `feeds`. If not defined, it will automatically be generated from the title. +#### `width` +The maximum width of the page on desktop. Possible values are `slim` and `wide`. + +* default: `1600px` +* slim: `1100px` +* wide: `1920px` + +> [!NOTE] +> +> When using `slim`, the maximum number of columns allowed for that page is `2`. + + +#### `hide-desktop-navigation` +Whether to show the navigation links at the top of the page on desktop. + #### `show-mobile-header` Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices. @@ -354,7 +416,9 @@ pages: | ---- | ---- | -------- | | type | string | yes | | title | string | no | +| title-url | string | no | | cache | string | no | +| css-class | string | no | #### `type` Used to specify the widget. @@ -362,6 +426,9 @@ Used to specify the widget. #### `title` The title of the widget. If left blank it will be defined by the widget. +#### `title-url` +The URL to go to when clicking on the widget's title. If left blank it will be defined by the widget (if available). + #### `cache` How long to keep the fetched data in memory. The value is a string and must be a number followed by one of s, m, h, d. Examples: @@ -376,6 +443,9 @@ cache: 1d # 1 day > > Not all widgets can have their cache duration modified. The calendar and weather widgets update on the hour and this cannot be changed. +#### `css-class` +Set custom CSS classes for the specific widget instance. + ### RSS Display a list of articles from multiple RSS feeds. @@ -402,10 +472,18 @@ Example: | thumbnail-height | float | no | 10 | | card-height | float | no | 27 | | limit | integer | no | 25 | +| single-line-titles | boolean | no | false | | collapse-after | integer | no | 5 | ##### `style` -Used to change the appearance of the widget. Possible values are `vertical-list` and `horizontal-cards` where the former is intended to be used within a small column and the latter a full column. Below are previews of each style. +Used to change the appearance of the widget. Possible values are: + +* `vertical-list` - suitable for `full` and `small` columns +* `detailed-list` - suitable for `full` columns +* `horizontal-cards` - suitable for `full` columns +* `horizontal-cards-2` - suitable for `full` columns + +Below is a preview of each style: `vertical-list` @@ -447,6 +525,9 @@ If an RSS feed isn't returning item links with a base domain and Glance has fail ##### `limit` The maximum number of articles to show. +##### `single-line-titles` +When set to `true`, truncates the title of each post if it exceeds one line. Only applies when the style is set to `vertical-list`. + ##### `collapse-after` How many articles are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse. @@ -473,6 +554,7 @@ Preview: | limit | integer | no | 25 | | style | string | no | horizontal-cards | | collapse-after-rows | integer | no | 4 | +| include-shorts | boolean | no | false | | video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} | ##### `channels` @@ -572,11 +654,23 @@ Preview: #### Properties | Name | Type | Required | Default | | ---- | ---- | -------- | ------- | +| instance-url | string | no | https://lobste.rs/ | +| custom-url | string | no | | | limit | integer | no | 15 | | collapse-after | integer | no | 5 | | sort-by | string | no | hot | | tags | array | no | | +##### `instance-url` +The base URL for a lobsters instance hosted somewhere other than on lobste.rs. Example: + +```yaml +instance-url: https://www.journalduhacker.net/ +``` + +##### `custom-url` +A custom URL to retrieve lobsters posts from. If this is specified, the `instance-url`, `sort-by` and `tags` properties are ignored. + ##### `limit` The maximum number of posts to show. @@ -609,6 +703,7 @@ Example: | subreddit | string | yes | | | style | string | no | vertical-list | | show-thumbnails | boolean | no | false | +| show-flairs | boolean | no | false | | limit | integer | no | 15 | | collapse-after | integer | no | 5 | | comments-url-template | string | no | https://www.reddit.com/{POST-PATH} | @@ -645,6 +740,9 @@ Shows or hides thumbnails next to the post. This only works if the `style` is `v > > Thumbnails don't work for some subreddits due to Reddit's API not returning the thumbnail URL. No workaround for this yet. +##### `show-flairs` +Shows post flairs when set to `true`. + ##### `limit` The maximum number of posts to show. @@ -724,10 +822,16 @@ Preview: | Ctrl + Enter | Perform search in a new tab | Search input is focused and not empty | | Escape | Leave focus | Search input is focused | +> [!TIP] +> +> You can use the property `new-tab` with a value of `true` if you want to show search results in a new tab by default. Ctrl + Enter will then show results in the same tab. + #### Properties | Name | Type | Required | Default | | ---- | ---- | -------- | ------- | | search-engine | string | no | duckduckgo | +| new-tab | boolean | no | false | +| autofocus | boolean | no | false | | bangs | array | no | | ##### `search-engine` @@ -738,6 +842,12 @@ Either a value from the table below or a URL to a custom search engine. Use `{QU | duckduckgo | `https://duckduckgo.com/?q={QUERY}` | | google | `https://www.google.com/search?q={QUERY}` | +##### `new-tab` +When set to `true`, swaps the shortcuts for showing results in the same or new tab, defaulting to showing results in a new tab. + +##### `new-tab` +When set to `true`, automatically focuses the search input on page load. + ##### `bangs` What now? [Bangs](https://duckduckgo.com/bangs). They're shortcuts that allow you to use the same search box for many different sites. Assuming you have it configured, if for example you start your search input with `!yt` you'd be able to perform a search on YouTube: @@ -772,6 +882,50 @@ url: https://store.steampowered.com/search/?term={QUERY} url: https://www.amazon.com/s?k={QUERY} ``` +### Group +Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget within a group widget. + +Example: + +```yaml +- type: group + widgets: + - type: reddit + subreddit: gamingnews + show-thumbnails: true + collapse-after: 6 + - type: reddit + subreddit: games + - type: reddit + subreddit: pcgaming + show-thumbnails: true +``` + +Preview: + +![](images/group-widget-preview.png) + +#### Sharing properties + +To avoid repetition you can use [YAML anchors](https://support.atlassian.com/bitbucket-cloud/docs/yaml-anchors/) and share properties between widgets. + +Example: + +```yaml +- type: group + define: &shared-properties + type: reddit + show-thumbnails: true + collapse-after: 6 + widgets: + - subreddit: gamingnews + <<: *shared-properties + - subreddit: games + <<: *shared-properties + - subreddit: pcgaming + <<: *shared-properties +``` + ### Extension Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP). @@ -901,13 +1055,13 @@ You can hover over the "ERROR" text to view more information. #### Properties -| Name | Type | Required | -| ---- | ---- | -------- | -| sites | array | yes | -| style | string | no | +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| sites | array | yes | | +| show-failing-only | boolean | no | false | -##### `style` -To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option. +##### `show-failing-only` +Shows only a list of failing sites when set to `true`. ##### `sites` @@ -917,6 +1071,7 @@ Properties for each site: | ---- | ---- | -------- | ------- | | title | string | yes | | | url | string | yes | | +| check-url | string | no | | | icon | string | no | | | allow-insecure | boolean | no | false | | same-tab | boolean | no | false | @@ -927,7 +1082,11 @@ The title used to indicate the site. `url` -The URL which will be requested and its response will determine the status of the site. Optionally, you can specify this using an environment variable with the syntax `${VARIABLE_NAME}`. +The public facing URL of a monitored service, the user will be redirected here. If `check-url` is not specified, this is used as the status check. + +`check-url` + +The URL which will be requested and its response will determine the status of the site. If not specified, the `url` property is used. `icon` @@ -952,17 +1111,19 @@ Whether to ignore invalid/self-signed certificates. Whether to open the link in the same or a new tab. ### Releases -Display a list of releases for specific repositories on Github. Draft releases and prereleases will not be shown. +Display a list of latest releases for specific repositories on Github, GitLab or Docker Hub. Example: ```yaml - type: releases + show-source-icon: true repositories: - - immich-app/immich - go-gitea/gitea - - dani-garcia/vaultwarden - jellyfin/jellyfin + - glanceapp/glance + - gitlab:fdroid/fdroidclient + - dockerhub:gotify/server ``` Preview: @@ -974,12 +1135,41 @@ Preview: | Name | Type | Required | Default | | ---- | ---- | -------- | ------- | | repositories | array | yes | | +| show-source-icon | boolean | no | false | | | token | string | no | | +| gitlab-token | string | no | | | limit | integer | no | 10 | | collapse-after | integer | no | 5 | ##### `repositories` -A list of repositores for which to fetch the latest release for. Only the name/repo is required, not the full URL. +A list of repositores to fetch the latest release for. Only the name/repo is required, not the full URL. A prefix can be specified for repositories hosted elsewhere such as GitLab and Docker Hub. Example: + +```yaml +repositories: + - gitlab:inkscape/inkscape + - dockerhub:glanceapp/glance +``` + +Official images on Docker Hub can be specified by ommiting the owner: + +```yaml +repositories: + - dockerhub:nginx + - dockerhub:node + - dockerhub:alpine +``` + +You can also specify specific tags for Docker Hub images: + +```yaml +repositories: + - dockerhub:nginx:latest + - dockerhub:nginx:stable-alpine +``` + + +##### `show-source-icon` +Shows an icon of the source (GitHub/GitLab/Docker Hub) next to the repository name when set to `true`. ##### `token` Without authentication Github allows for up to 60 requests per hour. You can easily exceed this limit and start seeing errors if you're tracking lots of repositories or your cache time is low. To circumvent this you can [create a read only token from your Github account](https://github.com/settings/personal-access-tokens/new) and provide it here. @@ -1004,12 +1194,65 @@ and then use it in your `glance.yml` like this: This way you can safely check your `glance.yml` in version control without exposing the token. +##### `gitlab-token` +Same as the above but used when fetching GitLab releases. + ##### `limit` The maximum number of releases to show. #### `collapse-after` How many releases are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse. +### DNS Stats +Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home or Pi-hole. + +Example: + +```yaml +- type: dns-stats + service: adguard + url: https://adguard.domain.com/ + username: admin + password: ${ADGUARD_PASSWORD} +``` + +Preview: + +![](images/dns-stats-widget-preview.png) + +> [!NOTE] +> +> When using AdGuard Home the 3rd statistic on top will be the average latency and when using Pi-hole it will be the total number of blocked domains from all adlists. + +#### Properties + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| service | string | no | pihole | +| url | string | yes | | +| username | string | when service is `adguard` | | +| password | string | when service is `adguard` | | +| token | string | when service is `pihole` | | +| hour-format | string | no | 12h | + +##### `service` +Either `adguard` or `pihole`. + +##### `url` +The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`. + +##### `username` +Only required when using AdGuard Home. The username used to log into the admin dashboard. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`. + +##### `password` +Only required when using AdGuard Home. The password used to log into the admin dashboard. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`. + +##### `token` +Only required when using Pi-hole. The API token which can be found in `Settings -> API -> Show API token`. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`. + +##### `hour-format` +Whether to display the relative time in the graph in `12h` or `24h` format. + ### Repository Display general information about a repository as well as a list of the latest open pull requests and issues. @@ -1020,6 +1263,7 @@ Example: repository: glanceapp/glance pull-requests-limit: 5 issues-limit: 3 + commits-limit: 3 ``` Preview: @@ -1034,6 +1278,7 @@ Preview: | token | string | no | | | pull-requests-limit | integer | no | 3 | | issues-limit | integer | no | 3 | +| commits-limit | integer | no | -1 | ##### `repository` The owner and repository name that will have their information displayed. @@ -1047,6 +1292,9 @@ The maximum number of latest open pull requests to show. Set to `-1` to not show ##### `issues-limit` The maximum number of latest open issues to show. Set to `-1` to not show any. +##### `commits-limit` +The maximum number of lastest commits to show from the default branch. Set to `-1` to not show any. + ### Bookmarks Display a list of links which can be grouped. @@ -1096,14 +1344,10 @@ Preview: | Name | Type | Required | | ---- | ---- | -------- | | groups | array | yes | -| style | string | no | ##### `groups` An array of groups which can optionally have a title and a custom color. -##### `style` -To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option. - ###### Properties for each group | Name | Type | Required | Default | | ---- | ---- | -------- | ------- | @@ -1281,7 +1525,6 @@ Preview: | ---- | ---- | -------- | | markets | array | yes | | sort-by | string | no | -| style | string | no | ##### `markets` An array of markets for which to display information about. @@ -1289,9 +1532,6 @@ An array of markets for which to display information about. ##### `sort-by` By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change. -##### `style` -To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option. - ###### Properties for each stock | Name | Type | Required | | ---- | ---- | -------- | diff --git a/docs/images/dns-stats-widget-preview.png b/docs/images/dns-stats-widget-preview.png new file mode 100644 index 0000000..defd139 Binary files /dev/null and b/docs/images/dns-stats-widget-preview.png differ diff --git a/docs/images/gaming-page-preview.png b/docs/images/gaming-page-preview.png new file mode 100644 index 0000000..343d22f Binary files /dev/null and b/docs/images/gaming-page-preview.png differ diff --git a/docs/images/group-widget-preview.png b/docs/images/group-widget-preview.png new file mode 100644 index 0000000..4d1d86b Binary files /dev/null and b/docs/images/group-widget-preview.png differ diff --git a/docs/images/markets-page-preview.png b/docs/images/markets-page-preview.png new file mode 100644 index 0000000..51e9f99 Binary files /dev/null and b/docs/images/markets-page-preview.png differ diff --git a/docs/images/mobile-preview.png b/docs/images/mobile-preview.png index 38acdcf..c27a1d2 100644 Binary files a/docs/images/mobile-preview.png and b/docs/images/mobile-preview.png differ diff --git a/docs/images/readme-main-image.png b/docs/images/readme-main-image.png index 821c35b..cce27fb 100644 Binary files a/docs/images/readme-main-image.png and b/docs/images/readme-main-image.png differ diff --git a/docs/images/releases-widget-preview.png b/docs/images/releases-widget-preview.png index 47acfd0..ec712bb 100644 Binary files a/docs/images/releases-widget-preview.png and b/docs/images/releases-widget-preview.png differ diff --git a/docs/images/startpage-preview.png b/docs/images/startpage-preview.png new file mode 100644 index 0000000..2af87ed Binary files /dev/null and b/docs/images/startpage-preview.png differ diff --git a/docs/preconfigured-pages.md b/docs/preconfigured-pages.md new file mode 100644 index 0000000..b382917 --- /dev/null +++ b/docs/preconfigured-pages.md @@ -0,0 +1,222 @@ +# Preconfigured pages + +Don't want to spend time configuring pages from scratch? No problem! Simply copy the config from the ones below. + +Pull requests with your page configurations are welcome! + +## Startpage + +![](images/startpage-preview.png) + +
+View config (requires Glance v0.6.0 or higher) + +```yaml +- name: Startpage + width: slim + hide-desktop-navigation: true + center-vertically: true + columns: + - size: full + widgets: + - type: search + autofocus: true + + - type: monitor + cache: 1m + title: Services + sites: + - title: Jellyfin + url: https://yourdomain.com/ + icon: si:jellyfin + - title: Gitea + url: https://yourdomain.com/ + icon: si:gitea + - title: qBittorrent # only for Linux ISOs, of course + url: https://yourdomain.com/ + icon: si:qbittorrent + - title: Immich + url: https://yourdomain.com/ + icon: si:immich + - title: AdGuard Home + url: https://yourdomain.com/ + icon: si:adguard + - title: Vaultwarden + url: https://yourdomain.com/ + icon: si:vaultwarden + + - type: bookmarks + groups: + - title: General + links: + - title: Gmail + url: https://mail.google.com/mail/u/0/ + - title: Amazon + url: https://www.amazon.com/ + - title: Github + url: https://github.com/ + - title: Entertainment + links: + - title: YouTube + url: https://www.youtube.com/ + - title: Prime Video + url: https://www.primevideo.com/ + - title: Disney+ + url: https://www.disneyplus.com/ + - title: Social + links: + - title: Reddit + url: https://www.reddit.com/ + - title: Twitter + url: https://twitter.com/ + - title: Instagram + url: https://www.instagram.com/ +``` +
+ +## Markets + +![](images/markets-page-preview.png) + +
+View config (requires Glance v0.6.0 or higher) + +```yaml + - name: Markets + columns: + - size: small + widgets: + - type: markets + title: Indices + markets: + - symbol: SPY + name: S&P 500 + - symbol: DX-Y.NYB + name: Dollar Index + + - type: markets + title: Crypto + markets: + - symbol: BTC-USD + name: Bitcoin + - symbol: ETH-USD + name: Ethereum + + - type: markets + title: Stocks + sort-by: absolute-change + markets: + - symbol: NVDA + name: NVIDIA + - symbol: AAPL + name: Apple + - symbol: MSFT + name: Microsoft + - symbol: GOOGL + name: Google + - symbol: AMD + name: AMD + - symbol: RDDT + name: Reddit + - symbol: AMZN + name: Amazon + - symbol: TSLA + name: Tesla + - symbol: INTC + name: Intel + - symbol: META + name: Meta + + - size: full + widgets: + - type: rss + title: News + style: horizontal-cards + feeds: + - url: https://feeds.bloomberg.com/markets/news.rss + title: Bloomberg + - url: https://moxie.foxbusiness.com/google-publisher/markets.xml + title: Fox Business + - url: https://moxie.foxbusiness.com/google-publisher/technology.xml + title: Fox Business + + - type: group + widgets: + - type: reddit + show-thumbnails: true + subreddit: technology + - type: reddit + show-thumbnails: true + subreddit: wallstreetbets + + - type: videos + style: grid-cards + collapse-after-rows: 3 + channels: + - UCvSXMi2LebwJEM1s4bz5IBA # New Money + - UCV6KDgJskWaEckne5aPA0aQ # Graham Stephan + - UCAzhpt9DmG6PnHXjmJTvRGQ # Federal Reserve + + - size: small + widgets: + - type: rss + title: News + limit: 30 + collapse-after: 13 + feeds: + - url: https://www.ft.com/technology?format=rss + title: Financial Times + - url: https://feeds.a.dj.com/rss/RSSMarketsMain.xml + title: Wall Street Journal +``` +
+ +## Gaming + +![](images/gaming-page-preview.png) + +
+View config (requires Glance v0.6.0 or higher) + +```yaml +- name: Gaming + columns: + - size: small + widgets: + - type: twitch-top-games + limit: 20 + collapse-after: 13 + exclude: + - just-chatting + - pools-hot-tubs-and-beaches + - music + - art + - asmr + + - size: full + widgets: + - type: group + widgets: + - type: reddit + show-thumbnails: true + subreddit: pcgaming + - type: reddit + subreddit: games + + - type: videos + style: grid-cards + collapse-after-rows: 3 + channels: + - UCNvzD7Z-g64bPXxGzaQaa4g # gameranx + - UCZ7AeeVbyslLM_8-nVy2B8Q # Skill Up + - UCHDxYLv8iovIbhrfl16CNyg # GameLinked + - UC9PBzalIcEQCsiIkq36PyUA # Digital Foundry + + - size: small + widgets: + - type: reddit + subreddit: gamingnews + limit: 7 + style: vertical-cards +``` +
diff --git a/go.mod b/go.mod index 502fc70..17aa4d4 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,19 @@ module github.com/glanceapp/glance -go 1.22.3 +go 1.22.5 require ( github.com/mmcdole/gofeed v1.3.0 - golang.org/x/text v0.14.0 + golang.org/x/text v0.16.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/PuerkitoBio/goquery v1.9.1 // indirect + github.com/PuerkitoBio/goquery v1.9.2 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mmcdole/goxpp v1.1.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - golang.org/x/net v0.24.0 // indirect + golang.org/x/net v0.27.0 // indirect ) diff --git a/go.sum b/go.sum index 54489e6..28cb1ae 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI= -github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= +github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= +github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -33,8 +33,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= -golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -54,8 +54,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/assets/files.go b/internal/assets/files.go index bfb2b4c..2c7c09e 100644 --- a/internal/assets/files.go +++ b/internal/assets/files.go @@ -1,8 +1,14 @@ package assets import ( + "crypto/md5" "embed" + "encoding/hex" + "io" "io/fs" + "log/slog" + "strconv" + "time" ) //go:embed static @@ -13,3 +19,38 @@ var _templateFS embed.FS var PublicFS, _ = fs.Sub(_publicFS, "static") var TemplateFS, _ = fs.Sub(_templateFS, "templates") + +func getFSHash(files fs.FS) string { + hash := md5.New() + + err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + file, err := files.Open(path) + + if err != nil { + return err + } + + if _, err := io.Copy(hash, file); err != nil { + return err + } + + return nil + }) + + if err == nil { + return hex.EncodeToString(hash.Sum(nil))[:10] + } + + slog.Warn("Could not compute assets cache", "err", err) + return strconv.FormatInt(time.Now().Unix(), 10) +} + +var PublicFSHash = getFSHash(PublicFS) diff --git a/internal/assets/static/icons/dockerhub.svg b/internal/assets/static/icons/dockerhub.svg new file mode 100644 index 0000000..8669c00 --- /dev/null +++ b/internal/assets/static/icons/dockerhub.svg @@ -0,0 +1 @@ + diff --git a/internal/assets/static/icons/github.svg b/internal/assets/static/icons/github.svg new file mode 100644 index 0000000..6cf48c8 --- /dev/null +++ b/internal/assets/static/icons/github.svg @@ -0,0 +1 @@ + diff --git a/internal/assets/static/icons/gitlab.svg b/internal/assets/static/icons/gitlab.svg new file mode 100644 index 0000000..42e4c97 --- /dev/null +++ b/internal/assets/static/icons/gitlab.svg @@ -0,0 +1 @@ + diff --git a/internal/assets/static/main.js b/internal/assets/static/js/main.js similarity index 89% rename from internal/assets/static/main.js rename to internal/assets/static/js/main.js index 3e10b96..228f57d 100644 --- a/internal/assets/static/main.js +++ b/internal/assets/static/js/main.js @@ -1,3 +1,5 @@ +import { setupPopovers } from './popover.js'; + function throttledDebounce(callback, maxDebounceTimes, debounceDelay) { let debounceTimeout; let timesDebounced = 0; @@ -21,10 +23,10 @@ function throttledDebounce(callback, maxDebounceTimes, debounceDelay) { }; -async function fetchPageContent(pageSlug) { +async function fetchPageContent(pageData) { // TODO: handle non 200 status codes/time outs // TODO: add retries - const response = await fetch(`/api/pages/${pageSlug}/content/`); + const response = await fetch(`${pageData.baseURL}/api/pages/${pageData.slug}/content/`); const content = await response.text(); return content; @@ -107,7 +109,7 @@ function updateRelativeTimeForElements(elements) } } -function setupSearchboxes() { +function setupSearchBoxes() { const searchWidgets = document.getElementsByClassName("search"); if (searchWidgets.length == 0) { @@ -117,6 +119,7 @@ function setupSearchboxes() { for (let i = 0; i < searchWidgets.length; i++) { const widget = searchWidgets[i]; const defaultSearchUrl = widget.dataset.defaultSearchUrl; + const newTab = widget.dataset.newTab === "true"; const inputElement = widget.getElementsByClassName("search-input")[0]; const bangElement = widget.getElementsByClassName("search-bang")[0]; const bangs = widget.querySelectorAll(".search-bangs > input"); @@ -147,14 +150,13 @@ function setupSearchboxes() { query = input; searchUrlTemplate = defaultSearchUrl; } - - if (query.length == 0) { + if (query.length == 0 && currentBang == null) { return; } const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query)); - if (event.ctrlKey) { + if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) { window.open(url, '_blank').focus(); } else { window.location.href = url; @@ -170,9 +172,13 @@ function setupSearchboxes() { } const handleInput = (event) => { - const value = event.target.value.trimStart(); - const words = value.split(" "); + const value = event.target.value.trim(); + if (value in bangsMap) { + changeCurrentBang(bangsMap[value]); + return; + } + const words = value.split(" "); if (words.length >= 2 && words[0] in bangsMap) { changeCurrentBang(bangsMap[words[0]]); return; @@ -246,6 +252,46 @@ function setupDynamicRelativeTime() { }); } +function setupGroups() { + const groups = document.getElementsByClassName("widget-type-group"); + + if (groups.length == 0) { + return; + } + + for (let g = 0; g < groups.length; g++) { + const group = groups[g]; + const titles = group.getElementsByClassName("widget-header")[0].children; + const tabs = group.getElementsByClassName("widget-group-contents")[0].children; + let current = 0; + + for (let t = 0; t < titles.length; t++) { + const title = titles[t]; + title.addEventListener("click", () => { + if (t == current) { + return; + } + + for (let i = 0; i < titles.length; i++) { + titles[i].classList.remove("widget-group-title-current"); + tabs[i].classList.remove("widget-group-content-current"); + } + + if (current < t) { + tabs[t].dataset.direction = "right"; + } else { + tabs[t].dataset.direction = "left"; + } + + current = t; + + title.classList.add("widget-group-title-current"); + tabs[t].classList.add("widget-group-content-current"); + }); + } + } +} + function setupLazyImages() { const images = document.querySelectorAll("img[loading=lazy]"); @@ -544,16 +590,18 @@ function setupClocks() { async function setupPage() { const pageElement = document.getElementById("page"); const pageContentElement = document.getElementById("page-content"); - const pageContent = await fetchPageContent(pageData.slug); + const pageContent = await fetchPageContent(pageData); pageContentElement.innerHTML = pageContent; try { + setupPopovers(); setupClocks() setupCarousels(); - setupSearchboxes(); + setupSearchBoxes(); setupCollapsibleLists(); setupCollapsibleGrids(); + setupGroups(); setupDynamicRelativeTime(); setupLazyImages(); } finally { @@ -569,8 +617,4 @@ async function setupPage() { } } -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", setupPage); -} else { - setupPage(); -} +setupPage(); diff --git a/internal/assets/static/js/popover.js b/internal/assets/static/js/popover.js new file mode 100644 index 0000000..d6578ee --- /dev/null +++ b/internal/assets/static/js/popover.js @@ -0,0 +1,182 @@ +const defaultShowDelayMs = 200; +const defaultHideDelayMs = 500; +const defaultMaxWidth = "300px"; +const defaultDistanceFromTarget = "0px" +const htmlContentSelector = "[data-popover-html]"; + +let activeTarget = null; +let pendingTarget = null; +let cleanupOnHidePopover = null; +let togglePopoverTimeout = null; + +const containerElement = document.createElement("div"); +const containerComputedStyle = getComputedStyle(containerElement); +containerElement.addEventListener("mouseenter", clearTogglePopoverTimeout); +containerElement.addEventListener("mouseleave", handleMouseLeave); +containerElement.classList.add("popover-container"); + +const frameElement = document.createElement("div"); +frameElement.classList.add("popover-frame"); + +const contentElement = document.createElement("div"); +contentElement.classList.add("popover-content"); + +frameElement.append(contentElement); +containerElement.append(frameElement); +document.body.append(containerElement); + +const observer = new ResizeObserver(repositionContainer); + +function handleMouseEnter(event) { + clearTogglePopoverTimeout(); + const target = event.target; + pendingTarget = target; + const showDelay = target.dataset.popoverShowDelay || defaultShowDelayMs; + + if (activeTarget !== null) { + if (activeTarget !== target) { + hidePopover(); + requestAnimationFrame(() => requestAnimationFrame(showPopover)); + } + + return; + } + + togglePopoverTimeout = setTimeout(showPopover, showDelay); +} + +function handleMouseLeave(event) { + clearTogglePopoverTimeout(); + const target = activeTarget || event.target; + togglePopoverTimeout = setTimeout(hidePopover, target.dataset.popoverHideDelay || defaultHideDelayMs); +} + +function clearTogglePopoverTimeout() { + clearTimeout(togglePopoverTimeout); +} + +function showPopover() { + activeTarget = pendingTarget; + pendingTarget = null; + + const popoverType = activeTarget.dataset.popoverType; + + if (popoverType === "text") { + const text = activeTarget.dataset.popoverText; + if (text === undefined || text === "") return; + contentElement.textContent = text; + } else if (popoverType === "html") { + const htmlContent = activeTarget.querySelector(htmlContentSelector); + if (htmlContent === null) return; + /** + * The reason for all of the below shenanigans is that I want to preserve + * all attached event listeners of the original HTML content. This is so I don't have to + * re-setup events for things like lazy images, they'd just work as expected. + */ + const placeholder = document.createComment(""); + htmlContent.replaceWith(placeholder); + contentElement.replaceChildren(htmlContent); + htmlContent.removeAttribute("data-popover-html"); + cleanupOnHidePopover = () => { + htmlContent.setAttribute("data-popover-html", ""); + placeholder.replaceWith(htmlContent); + placeholder.remove(); + }; + } else { + return; + } + + const contentMaxWidth = activeTarget.dataset.popoverMaxWidth || defaultMaxWidth; + + if (activeTarget.dataset.popoverTextAlign !== undefined) { + contentElement.style.textAlign = activeTarget.dataset.popoverTextAlign; + } else { + contentElement.style.removeProperty("text-align"); + } + + contentElement.style.maxWidth = contentMaxWidth; + containerElement.style.display = "block"; + activeTarget.classList.add("popover-active"); + document.addEventListener("keydown", handleHidePopoverOnEscape); + window.addEventListener("resize", repositionContainer); + observer.observe(containerElement); +} + +function repositionContainer() { + const targetBounds = activeTarget.dataset.popoverAnchor !== undefined + ? activeTarget.querySelector(activeTarget.dataset.popoverAnchor).getBoundingClientRect() + : activeTarget.getBoundingClientRect(); + + const containerBounds = containerElement.getBoundingClientRect(); + const containerInlinePadding = parseInt(containerComputedStyle.getPropertyValue("padding-inline")); + const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverOffset || 0.5); + const position = activeTarget.dataset.popoverPosition || "below"; + const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width / 2)); + + if (left < 0) { + containerElement.style.left = 0; + containerElement.style.removeProperty("right"); + containerElement.style.setProperty("--triangle-offset", targetBounds.left - containerInlinePadding + targetBoundsWidthOffset + "px"); + } else if (left + containerBounds.width > window.innerWidth) { + containerElement.style.removeProperty("left"); + containerElement.style.right = 0; + containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + "px"); + } else { + containerElement.style.removeProperty("right"); + containerElement.style.left = left + "px"; + containerElement.style.removeProperty("--triangle-offset"); + } + + const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget; + const topWhenAbove = targetBounds.top + window.scrollY - containerBounds.height; + const topWhenBelow = targetBounds.top + window.scrollY + targetBounds.height; + + if ( + position === "above" && topWhenAbove > window.scrollY || + (position === "below" && topWhenBelow + containerBounds.height > window.scrollY + window.innerHeight) + ) { + containerElement.classList.add("position-above"); + frameElement.style.removeProperty("margin-top"); + frameElement.style.marginBottom = distanceFromTarget; + containerElement.style.top = topWhenAbove + "px"; + } else { + containerElement.classList.remove("position-above"); + frameElement.style.removeProperty("margin-bottom"); + frameElement.style.marginTop = distanceFromTarget; + containerElement.style.top = topWhenBelow + "px"; + } +} + +function hidePopover() { + if (activeTarget === null) return; + + activeTarget.classList.remove("popover-active"); + containerElement.style.display = "none"; + document.removeEventListener("keydown", handleHidePopoverOnEscape); + window.removeEventListener("resize", repositionContainer); + observer.unobserve(containerElement); + + if (cleanupOnHidePopover !== null) { + cleanupOnHidePopover(); + cleanupOnHidePopover = null; + } + + activeTarget = null; +} + +function handleHidePopoverOnEscape(event) { + if (event.key === "Escape") { + hidePopover(); + } +} + +export function setupPopovers() { + const targets = document.querySelectorAll("[data-popover-type]"); + + for (let i = 0; i < targets.length; i++) { + const target = targets[i]; + + target.addEventListener("mouseenter", handleMouseEnter); + target.addEventListener("mouseleave", handleMouseLeave); + } +} diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css index ccad001..c2a4acd 100644 --- a/internal/assets/static/main.css +++ b/internal/assets/static/main.css @@ -34,6 +34,11 @@ --color-separator: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 4% * var(--cm)))); --color-widget-content-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%))); --color-widget-background-highlight: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%))); + --color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 3%)); + --color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 12%))); + --color-progress-border: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 10% * var(--cm)))); + --color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 27% * var(--cm)))); + --color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm)))); --ths: var(--bgh), calc(var(--bgs) * var(--tsm)); --color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%)); @@ -54,8 +59,9 @@ --scheme: 100% -; } -.size-title-dynamic { - font-size: var(--font-size-h4); +.page { + height: 100%; + padding-block: var(--widget-gap); } .page-content, .page.content-ready .page-loading-container { @@ -66,6 +72,10 @@ display: block; } +.page-column-small .size-title-dynamic { + font-size: var(--font-size-h4); +} + .page-column-full .size-title-dynamic { font-size: var(--font-size-h3); } @@ -74,12 +84,18 @@ color: var(--color-primary); } -.text-truncate { +.text-truncate, +.single-line-titles .title +{ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.single-line-titles .title { + display: block; +} + .text-truncate-2-lines, .text-truncate-3-lines { overflow: hidden; text-overflow: ellipsis; @@ -87,8 +103,8 @@ -webkit-box-orient: vertical; } -.text-truncate-3-lines { -webkit-line-clamp: 3; } -.text-truncate-2-lines { -webkit-line-clamp: 2; } +.text-truncate-3-lines { line-clamp: 3; -webkit-line-clamp: 3; } +.text-truncate-2-lines { line-clamp: 2; -webkit-line-clamp: 2; } .visited-indicator:not(.text-truncate)::after, .visited-indicator.text-truncate::before, @@ -110,20 +126,20 @@ color: var(--color-primary); } -.list { --list-half-gap: 0rem; } -.list-gap-2 { --list-half-gap: 0.1rem; } -.list-gap-4 { --list-half-gap: 0.2rem; } -.list-gap-10 { --list-half-gap: 0.5rem; } -.list-gap-14 { --list-half-gap: 0.7rem; } -.list-gap-20 { --list-half-gap: 1rem; } -.list-gap-24 { --list-half-gap: 1.2rem; } -.list-gap-34 { --list-half-gap: 1.7rem; } +.page-columns-transitioned .list-with-transition > * { animation: collapsibleItemReveal .25s backwards; } +.list-with-transition > *:nth-child(2) { animation-delay: 30ms; } +.list-with-transition > *:nth-child(3) { animation-delay: 60ms; } +.list-with-transition > *:nth-child(4) { animation-delay: 90ms; } +.list-with-transition > *:nth-child(5) { animation-delay: 120ms; } +.list-with-transition > *:nth-child(6) { animation-delay: 150ms; } +.list-with-transition > *:nth-child(7) { animation-delay: 180ms; } +.list-with-transition > *:nth-child(8) { animation-delay: 210ms; } .list > *:not(:first-child) { margin-top: calc(var(--list-half-gap) * 2); } -.list-with-separator > *:not(:first-child) { +.list.list-with-separator > *:not(:first-child) { margin-top: var(--list-half-gap); border-top: 1px solid var(--color-separator); padding-top: var(--list-half-gap); @@ -184,6 +200,56 @@ transform: rotate(-90deg); } +.widget-group-header { + overflow-x: auto; + scrollbar-width: thin; +} + +.widget-group-title { + background: none; + font: inherit; + border: none; + text-transform: uppercase; + border-bottom: 1px dotted transparent; + cursor: pointer; + flex-shrink: 0; + transition: color .3s, border-color .3s; + color: var(--color-text-subdue); + line-height: calc(1.6em - 1px); +} + +.widget-group-title:hover:not(.widget-group-title-current) { + color: var(--color-text-base); +} + +.widget-group-title-current { + border-bottom-color: var(--color-text-base-muted); + color: var(--color-text-base); +} + +.widget-group-content { + animation: widgetGroupContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards; +} + +.widget-group-content[data-direction="right"] { + --direction: 5px; +} + +.widget-group-content[data-direction="left"] { + --direction: -5px; +} + +@keyframes widgetGroupContentEntrance { + from { + opacity: 0; + transform: translateX(var(--direction)); + } +} + +.widget-group-content:not(.widget-group-content-current) { + display: none; +} + .widget-content:has(.expand-toggle-button:last-child) { padding-bottom: 0; } @@ -260,9 +326,14 @@ html { scroll-behavior: smooth; } +html, body { + height: 100%; +} + a { text-decoration: none; color: inherit; + overflow-wrap: break-word; } ul { @@ -292,7 +363,6 @@ body { .page-columns { display: flex; gap: var(--widget-gap); - margin: var(--widget-gap) 0; animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards; } @@ -304,13 +374,19 @@ body { } .page-loading-container { - margin: 50px auto; - width: fit-content; + height: 100%; + display: flex; + align-items: center; + justify-content: center; animation: loadingContainerEntrance 200ms backwards; animation-delay: 150ms; font-size: 2rem; } +.page-loading-container > .loading-icon { + translate: 0 -250%; +} + @keyframes loadingContainerEntrance { from { opacity: 0; @@ -333,12 +409,6 @@ body { } } -@keyframes loadingIconSpin { - to { - transform: rotate(360deg); - } -} - .notice-icon { width: 0.7rem; height: 0.7rem; @@ -370,23 +440,155 @@ kbd:active { box-shadow: 0 0 0 0 var(--color-widget-background-highlight); } +.popover-container, [data-popover-html] { + display: none; +} + +.popover-container { + --triangle-size: 10px; + --triangle-offset: 50%; + --triangle-margin: calc(var(--triangle-size) + 3px); + --entrance-y-offset: 8px; + --entrance-direction: calc(var(--entrance-y-offset) * -1); + + z-index: 20; + position: absolute; + padding-top: var(--triangle-margin); + padding-inline: var(--content-bounds-padding); +} + +.popover-container.position-above { + --entrance-direction: var(--entrance-y-offset); + padding-top: 0; + padding-bottom: var(--triangle-margin); +} + +.popover-frame { + --shadow-properties: 0 15px 20px -10px; + --shadow-color: hsla(var(--bghs), calc(var(--bgl) * 0.2), 0.5); + position: relative; + padding: 10px; + background: var(--color-popover-background); + border: 1px solid var(--color-popover-border); + border-radius: 5px; + animation: popoverFrameEntrance 0.3s backwards cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: var(--shadow-properties) var(--shadow-color); +} + +.popover-frame::before { + content: ''; + position: absolute; + width: var(--triangle-size); + height: var(--triangle-size); + transform: rotate(45deg); + background-color: var(--color-popover-background); + border-top-left-radius: 2px; + border-left: 1px solid var(--color-popover-border); + border-top: 1px solid var(--color-popover-border); + left: calc(var(--triangle-offset) - (var(--triangle-size) / 2)); + top: calc(var(--triangle-size) / 2 * -1 - 1px); +} + +.popover-container.position-above .popover-frame::before { + transform: rotate(-135deg); + top: auto; + bottom: calc(var(--triangle-size) / 2 * -1 - 1px); +} + +.popover-container.position-above .popover-frame { + --shadow-properties: 0 10px 20px -10px; +} + +@keyframes popoverFrameEntrance { + from { + opacity: 0; + transform: translateY(var(--entrance-direction)); + } +} + +.summary { + width: 100%; + cursor: pointer; + word-spacing: -0.18em; + user-select: none; + list-style: none; + position: relative; + display: flex; +} + +.details[open] .summary { + margin-bottom: .8rem; +} + +.summary::before { + content: ""; + position: absolute; + inset: -.3rem -.8rem; + border-radius: var(--border-radius); + background-color: var(--color-widget-background-highlight); + opacity: 0; + transition: opacity 0.2s; + z-index: -1; +} + +.details[open] .summary::before, .summary:hover::before { + opacity: 1; +} + +.summary::after { + content: "◀"; + font-size: 1.2em; + position: absolute; + top: 0; + bottom: 0; + line-height: 1.3em; + right: 0; + transition: rotate .5s cubic-bezier(0.22, 1, 0.36, 1); +} + +details[open] .summary::after { + rotate: -90deg; +} + .content-bounds { max-width: 1600px; + width: 100%; margin-inline: auto; padding: 0 var(--content-bounds-padding); } +.page-width-wide .content-bounds { + max-width: 1920px; +} + +.page-width-slim .content-bounds { + max-width: 1100px; +} + +.page-center-vertically .page { + display: flex; + justify-content: center; + flex-direction: column; +} + +/* TODO: refactor, otherwise I hope I never have to change dynamic columns again */ .dynamic-columns { - gap: calc(var(--widget-content-vertical-padding) / 2); + --list-half-gap: 0.5rem; + gap: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding); display: grid; grid-template-columns: repeat(var(--columns-per-row), 1fr); - margin: calc(0px - var(--widget-content-vertical-padding) / 2) calc(0px - var(--widget-content-horizontal-padding) / 2); } .dynamic-columns > * { - padding: calc(var(--widget-content-vertical-padding) / 2) calc(var(--widget-content-horizontal-padding) / 1.5); - background-color: var(--color-background); - border-radius: var(--border-radius); + padding-left: var(--widget-content-horizontal-padding); + border-left: 1px solid var(--color-separator); + min-width: 0; +} + +.dynamic-columns > *:first-child { + padding-top: 0; + border-top: none; + border-left: none; } .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; } @@ -395,23 +597,49 @@ kbd:active { .dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; } .dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; } -@container widget (max-width: 1500px) { +@container widget (max-width: 599px) { + .dynamic-columns { gap: 0; } .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; } + .dynamic-columns > * { + border-left: none; + padding-left: 0; + } + .dynamic-columns > *:not(:first-child) { + margin-top: calc(var(--list-half-gap) * 2); + } + .dynamic-columns.list-with-separator > *:not(:first-child) { + margin-top: var(--list-half-gap); + border-top: 1px solid var(--color-separator); + padding-top: var(--list-half-gap); + } +} +@container widget (min-width: 600px) and (max-width: 849px) { .dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; } + .dynamic-columns > :nth-child(2n-1) { + border-left: none; + padding-left: 0; + } +} +@container widget (min-width: 850px) and (max-width: 1249px) { .dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; } + .dynamic-columns > :nth-child(3n+1) { + border-left: none; + padding-left: 0; + } +} +@container widget (min-width: 1250px) and (max-width: 1499px) { .dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; } + .dynamic-columns > :nth-child(4n+1) { + border-left: none; + padding-left: 0; + } } -@container widget (max-width: 1250px) { - .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; } - .dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; } - .dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; } -} -@container widget (max-width: 850px) { - .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; } - .dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; } -} -@container widget (max-width: 550px) { - .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; } +@container widget (min-width: 1500px) { + .dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; } + .dynamic-columns > :nth-child(5n+1) { + border-left: none; + padding-left: 0; + } } .cards-vertical { @@ -444,6 +672,7 @@ kbd:active { .cards-horizontal { overflow-x: auto; + scrollbar-width: thin; padding-bottom: 1rem; } @@ -467,7 +696,10 @@ kbd:active { @container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } } @container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } } - +.widget-small-content-bounds { + max-width: 350px; + margin: 0 auto; +} .widget-error-header { display: flex; @@ -538,7 +770,7 @@ kbd:active { .widget-header { padding: 0 calc(var(--widget-content-horizontal-padding) + 1px); font-size: var(--font-size-h4); - margin-bottom: 1rem; + margin-bottom: 0.9rem; display: flex; align-items: center; gap: 1rem; @@ -584,6 +816,15 @@ kbd:active { padding-right: var(--widget-content-horizontal-padding); } +.logo:has(img) { + display: flex; + align-items: center; +} + +.logo img { + max-height: 2.7rem; +} + .nav { height: 100%; gap: var(--header-items-gap); @@ -594,7 +835,8 @@ kbd:active { } .footer { - margin-block: calc(var(--widget-gap) * 1.5); + padding-bottom: calc(var(--widget-gap) * 1.5); + padding-top: calc(var(--widget-gap) / 2); animation: loadingContainerEntrance 200ms backwards; animation-delay: 150ms; } @@ -621,9 +863,17 @@ kbd:active { color: var(--color-text-highlight); } +.release-source-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + opacity: 0.4; +} + .market-chart { margin-left: auto; width: 6.5rem; + flex-shrink: 0; } .market-chart svg { @@ -676,6 +926,7 @@ kbd:active { overflow: hidden; display: block; text-overflow: ellipsis; + line-clamp: 2; -webkit-line-clamp: 2; display: -webkit-box; -webkit-box-orient: vertical; @@ -797,6 +1048,7 @@ kbd:active { background-color: var(--color-widget-background-highlight); border-radius: var(--border-radius); padding: 0.5rem; + opacity: 0.7; } .bookmarks-icon { @@ -805,10 +1057,6 @@ kbd:active { opacity: 0.8; } -.simple-icon { - opacity: 0.7; -} - :root:not(.light-scheme) .simple-icon { filter: invert(1); } @@ -819,12 +1067,135 @@ kbd:active { padding: 0.6rem 0; } + .calendar-day-today { border-radius: var(--border-radius); background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%))); color: var(--color-text-highlight); } +.dns-stats-totals { + transition: opacity .3s; + transition-delay: 50ms; +} + +.dns-stats:has(.dns-stats-graph .popover-active) .dns-stats-totals { + opacity: 0.1; + transition-delay: 0s; +} + +.dns-stats-graph { + --graph-height: 70px; + height: var(--graph-height); + position: relative; + margin-bottom: 2.5rem; +} + +.dns-stats-graph-gridlines-container { + position: absolute; + z-index: -1; + inset: 0; +} + +.dns-stats-graph-gridlines { + height: 100%; + width: 100%; +} + +.dns-stats-graph-columns { + display: flex; + height: 100%; +} + +.dns-stats-graph-column { + display: flex; + justify-content: flex-end; + align-items: center; + flex-direction: column; + width: calc(100% / 8); + position: relative; +} + +.dns-stats-graph-column::before { + content: ''; + position: absolute; + inset: 1px 0; + z-index: -1; + opacity: 0; + background: var(--color-text-base); + transition: opacity .2s; +} + +.dns-stats-graph-column:hover::before { + opacity: 0.05; +} + +.dns-stats-graph-bar { + width: 14px; + height: calc((var(--bar-height) / 100) * var(--graph-height)); + border: 1px solid var(--color-progress-border); + border-radius: var(--border-radius) var(--border-radius) 0 0; + display: flex; + background: var(--color-widget-background); + padding: 2px 2px 0 2px; + flex-direction: column; + gap: 2px; + transition: border-color .2s; + min-height: 10px; +} + +.dns-stats-graph-column.popover-active .dns-stats-graph-bar { + border-color: var(--color-text-subdue); + border-bottom-color: var(--color-progress-border); +} + +.dns-stats-graph-bar > * { + border-radius: 2px; + background: var(--color-progress-value); + min-height: 1px; +} + +.dns-stats-graph-bar > .queries { + flex-grow: 1; +} + +.dns-stats-graph-bar > *:last-child { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.dns-stats-graph-bar > .blocked { + background-color: var(--color-negative); +} + +.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time { + opacity: 1; + transform: translateY(0); +} + +.dns-stats-graph-time, .dns-stats-graph-columns:hover .dns-stats-graph-time { + position: absolute; + font-size: var(--font-size-h6); + inset-inline: 0; + text-align: center; + height: 2.5rem; + line-height: 2.5rem; + top: 100%; + user-select: none; + opacity: 0; + transform: translateY(-0.5rem); + transition: opacity .2s, transform .2s; +} + +.dns-stats-graph-column:hover .dns-stats-graph-time { + opacity: 1; + transform: translateY(0); +} + +.dns-stats-graph-columns:hover .dns-stats-graph-column:not(:hover) .dns-stats-graph-time { + opacity: 0; +} + .weather-column { position: relative; display: flex; @@ -833,7 +1204,6 @@ kbd:active { flex-direction: column; width: calc(100% / 12); padding-top: 3px; - max-width: 30px; } .weather-column-value, .weather-columns:hover .weather-column-value { @@ -983,11 +1353,18 @@ kbd:active { transition: filter 0.3s, opacity 0.3s; } +.monitor-site-icon.simple-icon { + opacity: 0.7; +} + .monitor-site:hover .monitor-site-icon { - filter: grayscale(0); opacity: 1; } +.monitor-site:hover .monitor-site-icon:not(.simple-icon) { + filter: grayscale(0); +} + .monitor-site-status-icon { flex-shrink: 0; margin-left: auto; @@ -1142,7 +1519,7 @@ kbd:active { display: none; } - .page-column-full .size-title-dynamic { + .page-column-small .size-title-dynamic { font-size: var(--font-size-h3); } @@ -1167,8 +1544,9 @@ kbd:active { } } - body { - padding-bottom: calc(var(--mobile-navigation-height) + var(--content-bounds-padding)); + .mobile-navigation-offset { + height: var(--mobile-navigation-height); + flex-shrink: 0; } .mobile-navigation { @@ -1201,7 +1579,8 @@ kbd:active { padding: 15px var(--content-bounds-padding); display: flex; align-items: center; - overflow-x: scroll; + overflow-x: auto; + scrollbar-width: thin; gap: 2.5rem; } @@ -1341,6 +1720,7 @@ kbd:active { } .rss-detailed-description { + line-clamp: 3; -webkit-line-clamp: 3; } } @@ -1360,6 +1740,7 @@ kbd:active { .color-positive { color: var(--color-positive); } .color-primary { color: var(--color-primary); } +.cursor-help { cursor: help; } .break-all { word-break: break-all; } .text-left { text-align: left; } .text-right { text-align: right; } @@ -1371,6 +1752,7 @@ kbd:active { .shrink-0 { flex-shrink: 0; } .min-width-0 { min-width: 0; } .max-width-100 { max-width: 100%; } +.height-100 { height: 100%; } .block { display: block; } .inline-block { display: inline-block; } .overflow-hidden { overflow: hidden; } @@ -1392,6 +1774,7 @@ kbd:active { .gap-7 { gap: 0.7rem; } .gap-10 { gap: 1rem; } .gap-15 { gap: 1.5rem; } +.gap-20 { gap: 2rem; } .gap-25 { gap: 2.5rem; } .gap-35 { gap: 3.5rem; } .gap-45 { gap: 4.5rem; } @@ -1401,6 +1784,11 @@ kbd:active { .margin-top-7 { margin-top: 0.7rem; } .margin-top-10 { margin-top: 1rem; } .margin-top-15 { margin-top: 1.5rem; } +.margin-top-20 { margin-top: 2rem; } +.margin-top-25 { margin-top: 2.5rem; } +.margin-top-35 { margin-top: 3.5rem; } +.margin-top-40 { margin-top: 4rem; } +.margin-top-auto { margin-top: auto; } .margin-block-3 { margin-block: 0.3rem; } .margin-block-5 { margin-block: 0.5rem; } .margin-block-7 { margin-block: 0.7rem; } @@ -1412,4 +1800,13 @@ kbd:active { .margin-bottom-10 { margin-bottom: 1rem; } .margin-bottom-15 { margin-bottom: 1.5rem; } .margin-bottom-auto { margin-bottom: auto; } +.padding-block-5 { padding-block: 0.5rem; } .scale-half { transform: scale(0.5); } +.list { --list-half-gap: 0rem; } +.list-gap-2 { --list-half-gap: 0.1rem; } +.list-gap-4 { --list-half-gap: 0.2rem; } +.list-gap-10 { --list-half-gap: 0.5rem; } +.list-gap-14 { --list-half-gap: 0.7rem; } +.list-gap-20 { --list-half-gap: 1rem; } +.list-gap-24 { --list-half-gap: 1.2rem; } +.list-gap-34 { --list-half-gap: 1.7rem; } diff --git a/internal/assets/static/manifest.json b/internal/assets/static/manifest.json index 668b289..42e8213 100644 --- a/internal/assets/static/manifest.json +++ b/internal/assets/static/manifest.json @@ -6,7 +6,7 @@ "start_url": "/", "icons": [ { - "src": "/static/app-icon.png", + "src": "app-icon.png", "type": "image/png", "sizes": "512x512" } diff --git a/internal/assets/templates.go b/internal/assets/templates.go index 53ae871..85abb69 100644 --- a/internal/assets/templates.go +++ b/internal/assets/templates.go @@ -37,6 +37,8 @@ var ( RepositoryTemplate = compileTemplate("repository.html", "widget-base.html") SearchTemplate = compileTemplate("search.html", "widget-base.html") ExtensionTemplate = compileTemplate("extension.html", "widget-base.html") + GroupTemplate = compileTemplate("group.html", "widget-base.html") + DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html") ) var globalTemplateFunctions = template.FuncMap{ @@ -49,19 +51,6 @@ var globalTemplateFunctions = template.FuncMap{ "formatPrice": func(price float64) string { return intl.Sprintf("%.2f", price) }, - "formatTime": func(t time.Time) string { - return t.Format("2006-01-02 15:04:05") - }, - "shouldCollapse": func(i int, collapseAfter int) bool { - if collapseAfter < -1 { - return false - } - - return i >= collapseAfter - }, - "itemAnimationDelay": func(i int, collapseAfter int) string { - return fmt.Sprintf("%dms", (i-collapseAfter)*30) - }, "dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr { return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix())) }, diff --git a/internal/assets/templates/bookmarks.html b/internal/assets/templates/bookmarks.html index a422009..a4e2c97 100644 --- a/internal/assets/templates/bookmarks.html +++ b/internal/assets/templates/bookmarks.html @@ -1,37 +1,23 @@ {{ template "widget-base.html" . }} {{ define "widget-content" }} -{{ if ne .Style "dynamic-columns-experimental" }} - -{{ else }} -
+
{{ range .Groups }}
- {{ template "group" . }} + {{ if ne .Title "" }}
{{ .Title }}
{{ end }} +
    + {{ range .Links }} +
  • + {{ if ne "" .Icon }} +
    + +
    + {{ end }} + {{ .Title }} +
  • + {{ end }} +
{{ end }}
{{ end }} -{{ end }} - -{{ define "group" }} -{{ if ne .Title "" }}
{{ .Title }}
{{ end }} - -{{ end }} diff --git a/internal/assets/templates/calendar.html b/internal/assets/templates/calendar.html index 68fda83..af15e5a 100644 --- a/internal/assets/templates/calendar.html +++ b/internal/assets/templates/calendar.html @@ -1,27 +1,29 @@ {{ template "widget-base.html" . }} {{ define "widget-content" }} -
-
{{ .Calendar.CurrentMonthName }}
- -
+
+
+
{{ .Calendar.CurrentMonthName }}
+
    +
  • Week {{ .Calendar.CurrentWeekNumber }}
  • +
  • {{ .Calendar.CurrentYear }}
  • +
+
-
-
Mo
-
Tu
-
We
-
Th
-
Fr
-
Sa
-
Su
-
+
+
Mo
+
Tu
+
We
+
Th
+
Fr
+
Sa
+
Su
+
-
- {{ range .Calendar.Days }} -
{{ . }}
- {{ end }} +
+ {{ range .Calendar.Days }} +
{{ . }}
+ {{ end }} +
{{ end }} diff --git a/internal/assets/templates/dns-stats.html b/internal/assets/templates/dns-stats.html new file mode 100644 index 0000000..5d83508 --- /dev/null +++ b/internal/assets/templates/dns-stats.html @@ -0,0 +1,85 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} +
+
+
+
{{ .Stats.TotalQueries | formatNumber }}
+
QUERIES
+
+
+
{{ .Stats.BlockedPercent }}%
+
BLOCKED
+
+ {{ if gt .Stats.ResponseTime 0 }} +
+
{{ .Stats.ResponseTime | formatNumber }}ms
+
LATENCY
+
+ {{ else }} +
+
{{ .Stats.DomainsBlocked | formatViewerCount }}
+
DOMAINS
+
+ {{ end }} +
+ +
+
+ + + + + + + + + +
+ +
+ {{ range $i, $column := .Stats.Series }} +
+
+
+
+
{{ $column.Queries | formatNumber }}
+
QUERIES
+
+
+
{{ $column.PercentBlocked }}%
+
BLOCKED
+
+
+
+ {{ if gt $column.PercentTotal 0}} +
+ {{ if ne $column.Queries $column.Blocked }} +
+ {{ end }} + {{ if or (gt $column.Blocked 0) (and (lt $column.PercentTotal 15) (lt $column.PercentBlocked 10)) }} +
+ {{ end }} +
+ {{ end }} +
{{ index $.TimeLabels $i }}
+
+ {{ end }} +
+
+ + {{ if .Stats.TopBlockedDomains }} +
+ Top blocked domains +
    + {{ range .Stats.TopBlockedDomains }} +
  • +
    {{ .Domain }}
    +
    {{ .PercentBlocked }}%
    +
  • + {{ end }} +
+
+ {{ end }} +
+{{ end }} diff --git a/internal/assets/templates/document.html b/internal/assets/templates/document.html index d126d8b..c12a908 100644 --- a/internal/assets/templates/document.html +++ b/internal/assets/templates/document.html @@ -11,12 +11,11 @@ - - - - - - + + + + + {{ block "document-head-after" . }}{{ end }} diff --git a/internal/assets/templates/forum-posts.html b/internal/assets/templates/forum-posts.html index a6fe24d..8a71d22 100644 --- a/internal/assets/templates/forum-posts.html +++ b/internal/assets/templates/forum-posts.html @@ -6,7 +6,11 @@
  • {{ if $.ShowThumbnails }} - {{ if ne .ThumbnailUrl "" }} + {{ if .IsCrosspost }} + + + + {{ else if ne .ThumbnailUrl "" }} {{ else if .HasTargetUrl }} @@ -19,7 +23,7 @@ {{ end }} {{ end }}
    - {{ .Title }} + {{ .Title }} {{ if gt (len .Tags) 0 }}