diff --git a/docs/configuration.md b/docs/configuration.md index ba527b4..396161a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -4,8 +4,10 @@ - [The config file](#the-config-file) - [Auto reload](#auto-reload) - [Environment variables](#environment-variables) + - [Other ways of providing tokens/passwords/secrets](#other-ways-of-providing-tokenspasswordssecrets) - [Including other config files](#including-other-config-files) - [Config schema](#config-schema) +- [Authentication](#authentication) - [Server](#server) - [Document](#document) - [Branding](#branding) @@ -24,6 +26,7 @@ - [Custom API](#custom-api) - [Extension](#extension) - [Weather](#weather) + - [Todo](#todo) - [Monitor](#monitor) - [Releases](#releases) - [Docker Containers](#docker-containers) @@ -93,14 +96,46 @@ If you need to use the syntax `${NAME}` in your config without it being interpre something: \${NOT_AN_ENV_VAR} ``` +#### Other ways of providing tokens/passwords/secrets + +You can use [Docker secrets](https://docs.docker.com/compose/how-tos/use-secrets/) with the following syntax: + +```yaml +# This will be replaced with the contents of the file /run/secrets/github_token +# so long as the secret `github_token` is provided to the container +token: ${secret:github_token} +``` + +Alternatively, you can load the contents of a file who's path is provided by an environment variable: + +`docker-compose.yml` +```yaml +services: + glance: + image: glanceapp/glance + environment: + - TOKEN_FILE=/home/user/token + volumes: + - /home/user/token:/home/user/token +``` + +`glance.yml` +```yaml +token: ${readFileFromEnv:TOKEN_FILE} +``` + +> [!NOTE] +> +> The contents of the file will be stripped of any leading/trailing whitespace before being used. + ### Including other config files -Including config files from within your main config file is supported. This is done via the `!include` directive along with a relative or absolute path to the file you want to include. If the path is relative, it will be relative to the main config file. Additionally, environment variables can be used within included files, and changes to the included files will trigger an automatic reload. Example: +Including config files from within your main config file is supported. This is done via the `$include` directive along with a relative or absolute path to the file you want to include. If the path is relative, it will be relative to the main config file. Additionally, environment variables can be used within included files, and changes to the included files will trigger an automatic reload. Example: ```yaml pages: - !include: home.yml - !include: videos.yml - !include: homelab.yml + - $include: home.yml + - $include: videos.yml + - $include: homelab.yml ``` The file you are including should not have any additional indentation, its values should be at the top level and the appropriate amount of indentation will be added automatically depending on where the file is included. Example: @@ -113,14 +148,14 @@ pages: columns: - size: full widgets: - !include: rss.yml + $include: rss.yml - name: News columns: - size: full widgets: - type: group widgets: - !include: rss.yml + $include: rss.yml - type: reddit subreddit: news ``` @@ -134,9 +169,9 @@ pages: - url: ${RSS_URL} ``` -The `!include` directive can be used anywhere in the config file, not just in the `pages` property, however it must be on its own line and have the appropriate indentation. +The `$include` directive can be used anywhere in the config file, not just in the `pages` property, however it must be on its own line and have the appropriate indentation. -If you encounter YAML parsing errors when using the `!include` directive, the reported line numbers will likely be incorrect. This is because the inclusion of files is done before the YAML is parsed, as YAML itself does not support file inclusion. To help with debugging in cases like this, you can use the `config:print` command and pipe it into `less -N` to see the full config file with includes resolved and line numbers added: +If you encounter YAML parsing errors when using the `$include` directive, the reported line numbers will likely be incorrect. This is because the inclusion of files is done before the YAML is parsed, as YAML itself does not support file inclusion. To help with debugging in cases like this, you can use the `config:print` command and pipe it into `less -N` to see the full config file with includes resolved and line numbers added: ```sh glance --config /path/to/glance.yml config:print | less -N @@ -154,6 +189,67 @@ This assumes that the config you want to print is in your current working direct For property descriptions, validation and autocompletion of the config within your IDE, @not-first has kindly created a [schema](https://github.com/not-first/glance-schema). Massive thanks to them for this, go check it out and give them a star! +## Authentication + +To make sure that only you and the people you want to share your dashboard with have access to it, you can set up authentication via username and password. This is done through a top level `auth` property. Example: + +```yaml +auth: + secret-key: # this must be set to a random value generated using the secret:make CLI command + users: + admin: + password: 123456 + svilen: + password: 123456 +``` + +To generate a secret key, run the following command: + +```sh +./glance secret:make +``` + +Or with Docker: + +```sh +docker run --rm glanceapp/glance secret:make +``` + +### Using hashed passwords + +If you do not want to store plain passwords in your config file or in environment variables, you can hash your password and provide its hash instead: + +```sh +./glance password:hash mysecretpassword +``` + +Or with Docker: + +```sh +docker run --rm glanceapp/glance password:hash mysecretpassword +``` + +Then, in your config file use the `password-hash` property instead of `password`: + +```yaml +auth: + secret-key: # this must be set to a random value generated using the secret:make CLI command + users: + admin: + password-hash: $2a$10$o6SXqiccI3DDP2dN4ADumuOeIHET6Q4bUMYZD6rT2Aqt6XQ3DyO.6 +``` + +### Preventing brute-force attacks + +Glance will automatically block IP addresses of users who fail to authenticate 5 times in a row in the span of 5 minutes. In order for this feature to work correctly, Glance must know the real IP address of requests. If you're using a reverse proxy such as nginx, Traefik, NPM, etc, you must set the `proxied` property in the `server` configuration to `true`: + +```yaml +server: + proxied: true +``` + +When set to `true`, Glance will use the `X-Forwarded-For` header to determine the original IP address of the request, so make sure that your reverse proxy is correctly configured to send that header. + ## Server Server configuration is done through a top level `server` property. Example: @@ -169,6 +265,7 @@ server: | ---- | ---- | -------- | ------- | | host | string | no | | | port | number | no | 8080 | +| proxied | boolean | no | false | | base-url | string | no | | | assets-path | string | no | | @@ -178,6 +275,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. +#### `proxied` +Set to `true` if you're using a reverse proxy in front of Glance. This will make Glance use the `X-Forwarded-*` headers to determine the original request details. + #### `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. @@ -240,6 +340,9 @@ branding:

Powered by Glance

logo-url: /assets/logo.png favicon-url: /assets/logo.png + app-name: "My Dashboard" + app-icon-url: "/assets/app-icon.png" + app-background-color: "#151519" ``` ### Properties @@ -251,6 +354,9 @@ branding: | logo-text | string | no | G | | logo-url | string | no | | | favicon-url | string | no | | +| app-name | string | no | Glance | +| app-icon-url | string | no | Glance's default icon | +| app-background-color | string | no | Glance's default background color | #### `hide-footer` Hides the footer when set to `true`. @@ -267,6 +373,15 @@ Specify a URL to a custom image to use instead of the "G" found in the navigatio #### `favicon-url` Specify a URL to a custom image to use for the favicon. +#### `app-name` +Specify the name of the web app shown in browser tab and PWA. + +#### `app-icon-url` +Specify URL for PWA and browser tab icon (512x512 PNG). + +#### `app-background-color` +Specify background color for PWA. Must be a valid CSS color. + ## 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. @@ -277,6 +392,19 @@ theme: background-color: 100 20 10 primary-color: 40 90 40 contrast-multiplier: 1.1 + + presets: + gruvbox-dark: + background-color: 0 0 16 + primary-color: 43 59 81 + positive-color: 61 66 44 + negative-color: 6 96 59 + + zebra: + light: true + background-color: 0 0 95 + primary-color: 0 0 10 + negative-color: 0 90 50 ``` ### Available themes @@ -293,6 +421,7 @@ If you don't want to spend time configuring your own theme, there are [several a | contrast-multiplier | number | no | 1 | | text-saturation-multiplier | number | no | 1 | | custom-css-file | string | no | | +| presets | object | no | | #### `light` Whether the scheme is light or dark. This does not change the background color, it inverts the text colors so that they look appropriately on a light background. @@ -337,6 +466,30 @@ theme: > > In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets. +#### `presets` +Define additional theme presets that can be selected from the theme switcher on the page. For each preset, you can specify the same properties as for the default theme, such as `background-color`, `primary-color`, `positive-color`, `negative-color`, `contrast-multiplier`, etc., except for the `custom-css-file` property. + +Example: + +```yaml +theme: + presets: + my-custom-dark-theme: + background-color: 229 19 23 + contrast-multiplier: 1.2 + primary-color: 222 74 74 + positive-color: 96 44 68 + negative-color: 359 68 71 + my-custom-light-theme: + light: true + background-color: 220 23 95 + contrast-multiplier: 1.1 + primary-color: 220 91 54 + positive-color: 109 58 40 + negative-color: 347 87 44 +``` + +To override the default dark and light themes, use the key names `default-dark` and `default-light`. ## Pages & Columns ![illustration of pages and columns](images/pages-and-columns-illustration.png) @@ -364,10 +517,11 @@ pages: | name | string | yes | | | slug | string | no | | | width | string | no | | +| desktop-navigation-width | string | no | | | center-vertically | boolean | no | false | | hide-desktop-navigation | boolean | no | false | -| expand-mobile-page-navigation | boolean | no | false | | show-mobile-header | boolean | no | false | +| head-widgets | array | no | | | columns | array | yes | | #### `name` @@ -377,9 +531,14 @@ The name of the page which gets shown in the navigation bar. 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`. +The maximum width of the page on desktop. Possible values are `default`, `slim` and `wide`. -* default: `1600px` (when no value is specified) +#### `desktop-navigation-width` +The maximum width of the desktop navigation. Useful if you have a few pages that use a different width than the rest and don't want the navigation to jump abruptly when going to and away from those pages. Possible values are `default`, `slim` and `wide`. + +Here are the pixel equivalents for each value: + +* default: `1600px` * slim: `1100px` * wide: `1920px` @@ -393,9 +552,6 @@ When set to `true`, vertically centers the content on the page. Has no effect if #### `hide-desktop-navigation` Whether to show the navigation links at the top of the page on desktop. -#### `expand-mobile-page-navigation` -Whether the mobile page navigation should be expanded by default. - #### `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. @@ -403,6 +559,43 @@ Preview: ![](images/mobile-header-preview.png) +#### `head-widgets` + +Head widgets will be shown at the top of the page, above the columns, and take up the combined width of all columns. You can specify any widget, though some will look better than others, such as the markets, RSS feed with `horizontal-cards` style, and videos widgets. Example: + +![](images/head-widgets-preview.png) + +```yaml +pages: + - name: Home + head-widgets: + - type: markets + hide-header: true + markets: + - symbol: SPY + name: S&P 500 + - symbol: BTC-USD + name: Bitcoin + - symbol: NVDA + name: NVIDIA + - symbol: AAPL + name: Apple + - symbol: MSFT + name: Microsoft + + columns: + - size: small + widgets: + - type: calendar + - size: full + widgets: + - type: hacker-news + - size: small + widgets: + - type: weather + location: London, United Kingdom +``` + ### Columns Columns are defined for each page using a `columns` property. There are two types of columns - `full` and `small`, which refers to their width. A small column takes up a fixed amount of width (300px) and a full column takes up the all of the remaining width. You can have up to 3 columns per page and you must have either 1 or 2 full columns. Example: @@ -481,6 +674,7 @@ pages: | type | string | yes | | title | string | no | | title-url | string | no | +| hide-header | boolean | no | false | | cache | string | no | | css-class | string | no | @@ -493,6 +687,13 @@ 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). +#### `hide-header` +When set to `true`, the header (title) of the widget will be hidden. You cannot hide the header of the group widget. + +> [!NOTE] +> +> If a widget fails to update, a red dot or circle is shown next to the title of that widget indicating that the it is not working. You will not be able to see this if you hide the header. + #### `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: @@ -794,7 +995,10 @@ Display a list of posts from a specific subreddit. > [!WARNING] > -> Reddit does not allow unauthorized API access from VPS IPs, if you're hosting Glance on a VPS you will get a 403 response. As a workaround you can route the traffic from Glance through a VPN or your own HTTP proxy using the `request-url-template` property. +> Reddit does not allow unauthorized API access from VPS IPs, if you're hosting Glance on a VPS you will get a 403 +> response. As a workaround you can either [register an app on Reddit](https://ssl.reddit.com/prefs/apps/) and use the +> generated ID and secret in the widget configuration to authenticate your requests (see `app-auth` property), use a proxy +> (see `proxy` property) or route the traffic from Glance through a VPN. Example: @@ -819,6 +1023,7 @@ Example: | top-period | string | no | day | | search | string | no | | | extra-sort-by | string | no | | +| app-auth | object | no | | ##### `subreddit` The subreddit for which to fetch the posts from. @@ -926,6 +1131,19 @@ Can be used to specify an additional sort which will be applied on top of the al The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts. +##### `app-auth` +```yaml +widgets: + - type: reddit + subreddit: technology + app-auth: + name: ${REDDIT_APP_NAME} + id: ${REDDIT_APP_CLIENT_ID} + secret: ${REDDIT_APP_SECRET} +``` + +To register an app on Reddit, go to [this page](https://ssl.reddit.com/prefs/apps/). + ### Search Widget Display a search bar that can be used to search for specific terms on various search engines. @@ -974,6 +1192,10 @@ 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}` | +| bing | `https://www.bing.com/search?q={QUERY}` | +| perplexity | `https://www.perplexity.ai/search?q={QUERY}` | +| kagi | `https://kagi.com/search?q={QUERY}` | +| startpage | `https://www.startpage.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. @@ -1299,7 +1521,7 @@ Examples: #### Properties | Name | Type | Required | Default | | ---- | ---- | -------- | ------- | -| url | string | yes | | +| url | string | no | | | headers | key (string) & value (string) | no | | | method | string | no | GET | | body-type | string | no | json | @@ -1308,6 +1530,7 @@ Examples: | allow-insecure | boolean | no | false | | skip-json-validation | boolean | no | false | | template | string | yes | | +| options | map | no | | | parameters | key (string) & value (string|array) | no | | | subrequests | map of requests | no | | @@ -1360,6 +1583,95 @@ When set to `true`, skips the JSON validation step. This is useful when the API ##### `template` The template that will be used to display the data. It relies on Go's `html/template` package so it's recommended to go through [its documentation](https://pkg.go.dev/text/template) to understand how to do basic things such as conditionals, loops, etc. In addition, it also uses [tidwall's gjson](https://github.com/tidwall/gjson) package to parse the JSON data so it's worth going through its documentation if you want to use more advanced JSON selectors. You can view additional examples with explanations and function definitions [here](custom-api.md). +##### `options` +A map of options that will be passed to the template and can be used to modify the behavior of the widget. + +
+View examples + +
+ +Instead of defining options within the template and having to modify the template itself like such: + +```yaml +- type: custom-api + template: | + {{ /* User configurable options */ }} + {{ $collapseAfter := 5 }} + {{ $showThumbnails := true }} + {{ $showFlairs := false }} + + +``` + +You can use the `options` property to retrieve and define default values for these variables: + +```yaml +- type: custom-api + template: | + +``` + +This way, you can optionally specify the `collapse-after`, `show-thumbnails` and `show-flairs` properties in the widget configuration: + +```yaml +- type: custom-api + options: + collapse-after: 5 + show-thumbnails: true + show-flairs: false +``` + +Which means you can reuse the same template for multiple widgets with different options: + +```yaml +# Note that `custom-widgets` isn't a special property, it's just used to define the reusable "anchor", see https://support.atlassian.com/bitbucket-cloud/docs/yaml-anchors/ +custom-widgets: + - &example-widget + type: custom-api + template: | + {{ .Options.StringOr "custom-option" "not defined" }} + +pages: + - name: Home + columns: + - size: full + widgets: + - <<: *example-widget + options: + custom-option: "Value 1" + + - <<: *example-widget + options: + custom-option: "Value 2" +``` + +Currently, the available methods on the `.Options` object are: `StringOr`, `IntOr`, `BoolOr` and `FloatOr`. + +
+ ##### `parameters` A list of keys and values that will be sent to the custom-api as query paramters. @@ -1515,6 +1827,44 @@ Otherwise, if set to `false` (which is the default) it'll be displayed as: Greenville, United States ``` +### Todo + +A simple to-do list that allows you to add, edit and delete tasks. The tasks are stored in the browser's local storage. + +Example: + +```yaml +- type: to-do +``` + +Preview: + +![](images/todo-widget-preview.png) + +To reorder tasks, drag and drop them by grabbing the top side of the task: + +![](images/reorder-todo-tasks-prevew.gif) + +To delete a task, hover over it and click on the trash icon. + +#### Properties + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| id | string | no | | + +##### `id` + +The ID of the todo list. If you want to have multiple todo lists, you must specify a different ID for each one. The ID is used to store the tasks in the browser's local storage. This means that if you have multiple todo lists with the same ID, they will share the same tasks. + +#### Keyboard shortcuts +| Keys | Action | Condition | +| ---- | ------ | --------- | +| Enter | Add a task to the bottom of the list | When the "Add a task" field is focused | +| Ctrl + Enter | Add a task to the top of the list | When the "Add a task" field is focused | +| Down Arrow | Focus the last task that was added | When the "Add a task" field is focused | +| Escape | Focus the "Add a task" field | When a task is focused | + ### Monitor Display a list of sites and whether they are reachable (online) or not. This is determined by sending a GET request to the specified URL, if the response is 200 then the site is OK. The time it took to receive a response is also shown in milliseconds. @@ -1540,7 +1890,6 @@ Example: - title: Vaultwarden url: https://vault.yourdomain.com icon: /assets/vaultwarden-logo.png - ``` Preview: @@ -1578,9 +1927,11 @@ Properties for each site: | check-url | string | no | | | error-url | string | no | | | icon | string | no | | +| timeout | string | no | 3s | | allow-insecure | boolean | no | false | | same-tab | boolean | no | false | | alt-status-codes | array | no | | +| basic-auth | object | no | | `title` @@ -1612,6 +1963,10 @@ icon: si:adguard > > Simple Icons are loaded externally and are hosted on `cdn.jsdelivr.net`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally. +`timeout` + +How long to wait for a response from the server before considering it unreachable. The value is a string and must be a number followed by one of s, m, h, d. Example: `5s` for 5 seconds, `1m` for 1 minute, etc. + `allow-insecure` Whether to ignore invalid/self-signed certificates. @@ -1629,6 +1984,16 @@ alt-status-codes: - 403 ``` +`basic-auth` + +HTTP Basic Authentication credentials for protected sites. + +```yaml +basic-auth: + usename: your-username + password: your-password +``` + ### Releases Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub. @@ -1770,6 +2135,19 @@ Configuration of the containers is done via labels applied to each container: glance.description: Movies & shows ``` +Alternatively, you can also define the values within your `glance.yml` via the `containers` property, where the key is the container name and each value is the same as the labels but without the "glance." prefix: + +```yaml +- type: docker-containers + containers: + container_name_1: + title: Container Name + description: Description of the container + url: https://container.domain.com + icon: si:container-icon + hide: false +``` + For services with multiple containers you can specify a `glance.id` on the "main" container and `glance.parent` on each "child" container:
@@ -1821,13 +2199,71 @@ If any of the child containers are down, their status will propagate up to the p | Name | Type | Required | Default | | ---- | ---- | -------- | ------- | | hide-by-default | boolean | no | false | +| format-container-names | boolean | no | false | | sock-path | string | no | /var/run/docker.sock | +| category | string | no | | +| running-only | boolean | no | false | ##### `hide-by-default` Whether to hide the containers by default. If set to `true` you'll have to manually add a `glance.hide: false` label to each container you want to display. By default all containers will be shown and if you want to hide a specific container you can add a `glance.hide: true` label. +##### `format-container-names` +When set to `true`, automatically converts container names such as `container_name_1` into `Container Name 1`. + ##### `sock-path` -The path to the Docker socket. +The path to the Docker socket. This can also be a [remote socket](https://docs.docker.com/engine/daemon/remote-access/) or proxied socket using something like [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy). + +###### `category` +Filter to only the containers which have this category specified via the `glance.category` label. Useful if you want to have multiple containers widgets, each showing a different set of containers. + +
+View example +
+ + +```yaml +services: + jellyfin: + image: jellyfin/jellyfin:latest + labels: + glance.name: Jellyfin + glance.icon: si:jellyfin + glance.url: https://jellyfin.domain.com + glance.category: media + + gitea: + image: gitea/gitea:latest + labels: + glance.name: Gitea + glance.icon: si:gitea + glance.url: https://gitea.domain.com + glance.category: dev-tools + + vaultwarden: + image: vaultwarden/server:latest + labels: + glance.name: Vaultwarden + glance.icon: si:vaultwarden + glance.url: https://vaultwarden.domain.com + glance.category: dev-tools +``` + +Then you can use the `category` property to filter the containers: + +```yaml +- type: docker-containers + title: Dev tool containers + category: dev-tools + +- type: docker-containers + title: Media containers + category: media +``` + +
+ +##### `running-only` +Whether to only show running containers. If set to `true` only containers that are currently running will be displayed. If set to `false` all containers will be displayed regardless of their state. #### Labels | Name | Description | @@ -1840,9 +2276,10 @@ The path to the Docker socket. | glance.hide | Whether to hide the container. If set to `true` the container will not be displayed. Defaults to `false`. | | glance.id | The custom ID of the container. Used to group containers under a single parent. | | glance.parent | The ID of the parent container. Used to group containers under a single parent. | +| glance.category | The category of the container. Used to filter containers by category. | ### DNS Stats -Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home or Pi-hole. +Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home, Pi-hole, or Technitium. Example: @@ -1860,7 +2297,7 @@ Preview: > [!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. +> When using AdGuard Home the 3rd statistic on top will be the average latency and when using Pi-hole or Technitium it will be the total number of blocked domains from all adlists. #### Properties @@ -1877,7 +2314,7 @@ Preview: | hour-format | string | no | 12h | ##### `service` -Either `adguard`, or `pihole` (major version 5 and below) or `pihole-v6` (major version 6 and above). +Either `adguard`, `technitium`, or `pihole` (major version 5 and below) or `pihole-v6` (major version 6 and above). ##### `allow-insecure` Whether to allow invalid/self-signed certificates when making the request to the service. @@ -1891,10 +2328,12 @@ Only required when using AdGuard Home. The username used to log into the admin d ##### `password` Required when using AdGuard Home, where the password is the one used to log into the admin dashboard. -Also requried when using Pi-hole major version 6 and above, where the password is the one used to log into the admin dashboard or the application password, which can be found in `Settings -> Web Interface / API -> Configure app password`. +Also required when using Pi-hole major version 6 and above, where the password is the one used to log into the admin dashboard or the application password, which can be found in `Settings -> Web Interface / API -> Configure app password`. ##### `token` -Only required when using Pi-hole major version 5 or earlier. The API token which can be found in `Settings -> API -> Show API token`. +Required when using Pi-hole major version 5 or earlier. The API token which can be found in `Settings -> API -> Show API token`. + +Also required when using Technitium, an API token can be generated at `Administration -> Sessions -> Create Token`. ##### `hide-graph` Whether to hide the graph showing the number of queries over time. @@ -2137,6 +2576,7 @@ An array of groups which can optionally have a title and a custom color. | ---- | ---- | -------- | ------- | | title | string | yes | | | url | string | yes | | +| description | string | no | | | icon | string | no | | | same-tab | boolean | no | false | | hide-arrow | boolean | no | false | diff --git a/docs/custom-api.md b/docs/custom-api.md index 3a37df2..566aa58 100644 --- a/docs/custom-api.md +++ b/docs/custom-api.md @@ -358,6 +358,52 @@ Output:

John

``` +
+ +In some instances, you may need to make two consecutive API calls, where you use the result of the first call in the second call. To achieve this, you can make additional HTTP requests from within the template itself using the following syntax: + +```yaml +- type: custom-api + url: https://api.example.com/get-id-of-something + template: | + {{ $theID := .JSON.String "id" }} + + {{ + $something := newRequest (concat "https://api.example.com/something/" $theID) + | withParameter "key" "value" + | withHeader "Authorization" "Bearer token" + | getResponse + }} + + {{ $something.JSON.String "title" }} +``` + +Here, `$theID` gets retrieved from the result of the first API call and used in the second API call. The `newRequest` function creates a new request, and the `getResponse` function executes it. You can also use `withParameter` and `withHeader` to optionally add parameters and headers to the request. + +If you need to make a request to a URL that requires dynamic parameters, you can omit the `url` property in the YAML and run the request entirely from within the template itself: + +```yaml +- type: custom-api + title: Events from the last 24h + template: | + {{ + $events := newRequest "https://api.example.com/events" + | withParameter "after" (offsetNow "-24h" | formatTime "rfc3339") + | getResponse + }} + + {{ if eq $events.Response.StatusCode 200 }} + {{ range $events.JSON.Array "events" }} +
{{ .String "title" }}
+
+ {{ end }} + {{ else }} +

Failed to fetch data: {{ $events.Response.Status }}

+ {{ end }} +``` + +*Note that you need to manually check for the correct status code.* + ## Functions The following functions are available on the `JSON` object: @@ -378,6 +424,7 @@ The following helper functions provided by Glance are available: - `offsetNow(offset string) time.Time`: Returns the current time with an offset. The offset can be positive or negative and must be in the format "3h" "-1h" or "2h30m10s". - `duration(str string) time.Duration`: Parses a string such as `1h`, `24h`, `5h30m`, etc into a `time.Duration`. - `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly". +- `formatTime(layout string, s string) time.Time`: Formats a `time.Time` into a string. The layout uses the same format as `parseTime`. - `parseLocalTime(layout string, s string) time.Time`: Same as the above, except in the absence of a timezone, it will use the local timezone instead of UTC. - `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`. - `add(a, b float) float`: Adds two numbers. @@ -399,6 +446,7 @@ The following helper functions provided by Glance are available: - `sortByTime(key string, layout string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a time key in either ascending or descending order. The format must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). - `concat(strings ...string) string`: Concatenates multiple strings together. - `unique(key string, arr []JSON) []JSON`: Returns a unique array of JSON objects based on the given key. +- `percentChange(current float, previous float) float`: Calculates the percentage change between two numbers. The following helper functions provided by Go's `text/template` are available: diff --git a/docs/images/head-widgets-preview.png b/docs/images/head-widgets-preview.png new file mode 100644 index 0000000..3de52fc Binary files /dev/null and b/docs/images/head-widgets-preview.png differ diff --git a/docs/images/reorder-todo-tasks-prevew.gif b/docs/images/reorder-todo-tasks-prevew.gif new file mode 100644 index 0000000..99c6169 Binary files /dev/null and b/docs/images/reorder-todo-tasks-prevew.gif differ diff --git a/docs/images/todo-widget-preview.png b/docs/images/todo-widget-preview.png new file mode 100644 index 0000000..5d7a724 Binary files /dev/null and b/docs/images/todo-widget-preview.png differ diff --git a/docs/logo.png b/docs/logo.png new file mode 100644 index 0000000..7dbc2cc Binary files /dev/null and b/docs/logo.png differ diff --git a/go.mod b/go.mod index 4c19477..ccea58c 100644 --- a/go.mod +++ b/go.mod @@ -5,14 +5,15 @@ go 1.24.2 require ( github.com/fsnotify/fsnotify v1.9.0 github.com/mmcdole/gofeed v1.3.0 - github.com/shirou/gopsutil/v4 v4.25.3 + github.com/shirou/gopsutil/v4 v4.25.4 github.com/tidwall/gjson v1.18.0 - golang.org/x/text v0.24.0 + golang.org/x/crypto v0.38.0 + golang.org/x/text v0.25.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/PuerkitoBio/goquery v1.10.2 // indirect + github.com/PuerkitoBio/goquery v1.10.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/ebitengine/purego v0.8.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect @@ -27,6 +28,6 @@ require ( github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sys v0.32.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sys v0.33.0 // indirect ) diff --git a/go.sum b/go.sum index 9a79559..80c2d6c 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8W github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY= github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8= github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU= +github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -16,7 +18,6 @@ github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8 github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -43,6 +44,8 @@ github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0Zqm github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI= github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE= github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA= +github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw= +github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= @@ -71,6 +74,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -89,6 +96,8 @@ golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 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= @@ -114,6 +123,8 @@ golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -136,6 +147,8 @@ golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 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/glance/auth.go b/internal/glance/auth.go new file mode 100644 index 0000000..e6497a1 --- /dev/null +++ b/internal/glance/auth.go @@ -0,0 +1,343 @@ +package glance + +import ( + "bytes" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "log" + mathrand "math/rand/v2" + "net/http" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +const AUTH_SESSION_COOKIE_NAME = "session_token" +const AUTH_RATE_LIMIT_WINDOW = 5 * time.Minute +const AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5 + +const AUTH_TOKEN_SECRET_LENGTH = 32 +const AUTH_USERNAME_HASH_LENGTH = 32 +const AUTH_SECRET_KEY_LENGTH = AUTH_TOKEN_SECRET_LENGTH + AUTH_USERNAME_HASH_LENGTH +const AUTH_TIMESTAMP_LENGTH = 4 // uint32 +const AUTH_TOKEN_DATA_LENGTH = AUTH_USERNAME_HASH_LENGTH + AUTH_TIMESTAMP_LENGTH + +// How long the token will be valid for +const AUTH_TOKEN_VALID_PERIOD = 14 * 24 * time.Hour // 14 days +// How long the token has left before it should be regenerated +const AUTH_TOKEN_REGEN_BEFORE = 7 * 24 * time.Hour // 7 days + +var loginPageTemplate = mustParseTemplate("login.html", "document.html", "footer.html") + +type doWhenUnauthorized int + +const ( + redirectToLogin doWhenUnauthorized = iota + showUnauthorizedJSON +) + +type failedAuthAttempt struct { + attempts int + first time.Time +} + +func generateSessionToken(username string, secret []byte, now time.Time) (string, error) { + if len(secret) != AUTH_SECRET_KEY_LENGTH { + return "", fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH) + } + + usernameHash, err := computeUsernameHash(username, secret) + if err != nil { + return "", err + } + + data := make([]byte, AUTH_TOKEN_DATA_LENGTH) + copy(data, usernameHash) + expires := now.Add(AUTH_TOKEN_VALID_PERIOD).Unix() + binary.LittleEndian.PutUint32(data[AUTH_USERNAME_HASH_LENGTH:], uint32(expires)) + + h := hmac.New(sha256.New, secret[0:AUTH_TOKEN_SECRET_LENGTH]) + h.Write(data) + + signature := h.Sum(nil) + encodedToken := base64.StdEncoding.EncodeToString(append(data, signature...)) + // encodedToken ends up being (hashed username + expiration timestamp + signature) encoded as base64 + + return encodedToken, nil +} + +func computeUsernameHash(username string, secret []byte) ([]byte, error) { + if len(secret) != AUTH_SECRET_KEY_LENGTH { + return nil, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH) + } + + h := hmac.New(sha256.New, secret[AUTH_TOKEN_SECRET_LENGTH:]) + h.Write([]byte(username)) + + return h.Sum(nil), nil +} + +func verifySessionToken(token string, secretBytes []byte, now time.Time) ([]byte, bool, error) { + tokenBytes, err := base64.StdEncoding.DecodeString(token) + if err != nil { + return nil, false, err + } + + if len(tokenBytes) != AUTH_TOKEN_DATA_LENGTH+32 { + return nil, false, fmt.Errorf("token length is invalid") + } + + if len(secretBytes) != AUTH_SECRET_KEY_LENGTH { + return nil, false, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH) + } + + usernameHashBytes := tokenBytes[0:AUTH_USERNAME_HASH_LENGTH] + timestampBytes := tokenBytes[AUTH_USERNAME_HASH_LENGTH : AUTH_USERNAME_HASH_LENGTH+AUTH_TIMESTAMP_LENGTH] + providedSignatureBytes := tokenBytes[AUTH_TOKEN_DATA_LENGTH:] + + h := hmac.New(sha256.New, secretBytes[0:32]) + h.Write(tokenBytes[0:AUTH_TOKEN_DATA_LENGTH]) + expectedSignatureBytes := h.Sum(nil) + + if !hmac.Equal(expectedSignatureBytes, providedSignatureBytes) { + return nil, false, fmt.Errorf("signature does not match") + } + + expiresTimestamp := int64(binary.LittleEndian.Uint32(timestampBytes)) + if now.Unix() > expiresTimestamp { + return nil, false, fmt.Errorf("token has expired") + } + + return usernameHashBytes, + // True if the token should be regenerated + time.Unix(expiresTimestamp, 0).Add(-AUTH_TOKEN_REGEN_BEFORE).Before(now), + nil +} + +func makeAuthSecretKey(length int) (string, error) { + key := make([]byte, length) + _, err := rand.Read(key) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(key), nil +} + +func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "application/json" { + w.WriteHeader(http.StatusBadRequest) + return + } + + waitOnFailure := 1*time.Second - time.Duration(mathrand.IntN(500))*time.Millisecond + + ip := a.addressOfRequest(r) + + a.authAttemptsMu.Lock() + exceededRateLimit, retryAfter := func() (bool, int) { + attempt, exists := a.failedAuthAttempts[ip] + if !exists { + a.failedAuthAttempts[ip] = &failedAuthAttempt{ + attempts: 1, + first: time.Now(), + } + + return false, 0 + } + + elapsed := time.Since(attempt.first) + if elapsed < AUTH_RATE_LIMIT_WINDOW && attempt.attempts >= AUTH_RATE_LIMIT_MAX_ATTEMPTS { + return true, max(1, int(AUTH_RATE_LIMIT_WINDOW.Seconds()-elapsed.Seconds())) + } + + attempt.attempts++ + return false, 0 + }() + + if exceededRateLimit { + a.authAttemptsMu.Unlock() + time.Sleep(waitOnFailure) + w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) + w.WriteHeader(http.StatusTooManyRequests) + return + } else { + // Clean up old failed attempts + for ipOfAttempt := range a.failedAuthAttempts { + if time.Since(a.failedAuthAttempts[ipOfAttempt].first) > AUTH_RATE_LIMIT_WINDOW { + delete(a.failedAuthAttempts, ipOfAttempt) + } + } + a.authAttemptsMu.Unlock() + } + + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + var creds struct { + Username string `json:"username"` + Password string `json:"password"` + } + + err = json.Unmarshal(body, &creds) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + logAuthFailure := func() { + log.Printf( + "Failed login attempt for user '%s' from %s", + creds.Username, ip, + ) + } + + if len(creds.Username) == 0 || len(creds.Password) == 0 { + time.Sleep(waitOnFailure) + w.WriteHeader(http.StatusUnauthorized) + return + } + + if len(creds.Username) > 50 || len(creds.Password) > 100 { + logAuthFailure() + time.Sleep(waitOnFailure) + w.WriteHeader(http.StatusUnauthorized) + return + } + + u, exists := a.Config.Auth.Users[creds.Username] + if !exists { + logAuthFailure() + time.Sleep(waitOnFailure) + w.WriteHeader(http.StatusUnauthorized) + return + } + + if err := bcrypt.CompareHashAndPassword(u.PasswordHash, []byte(creds.Password)); err != nil { + logAuthFailure() + time.Sleep(waitOnFailure) + w.WriteHeader(http.StatusUnauthorized) + return + } + + token, err := generateSessionToken(creds.Username, a.authSecretKey, time.Now()) + if err != nil { + log.Printf("Could not compute session token during login attempt: %v", err) + time.Sleep(waitOnFailure) + w.WriteHeader(http.StatusUnauthorized) + return + } + + a.setAuthSessionCookie(w, r, token, time.Now().Add(AUTH_TOKEN_VALID_PERIOD)) + + a.authAttemptsMu.Lock() + delete(a.failedAuthAttempts, ip) + a.authAttemptsMu.Unlock() + + w.WriteHeader(http.StatusOK) +} + +func (a *application) isAuthorized(w http.ResponseWriter, r *http.Request) bool { + if !a.RequiresAuth { + return true + } + + token, err := r.Cookie(AUTH_SESSION_COOKIE_NAME) + if err != nil || token.Value == "" { + return false + } + + usernameHash, shouldRegenerate, err := verifySessionToken(token.Value, a.authSecretKey, time.Now()) + if err != nil { + return false + } + + username, exists := a.usernameHashToUsername[string(usernameHash)] + if !exists { + return false + } + + _, exists = a.Config.Auth.Users[username] + if !exists { + return false + } + + if shouldRegenerate { + newToken, err := generateSessionToken(username, a.authSecretKey, time.Now()) + if err != nil { + log.Printf("Could not compute session token during regeneration: %v", err) + return false + } + + a.setAuthSessionCookie(w, r, newToken, time.Now().Add(AUTH_TOKEN_VALID_PERIOD)) + } + + return true +} + +// Handles sending the appropriate response for an unauthorized request and returns true if the request was unauthorized +func (a *application) handleUnauthorizedResponse(w http.ResponseWriter, r *http.Request, fallback doWhenUnauthorized) bool { + if a.isAuthorized(w, r) { + return false + } + + switch fallback { + case redirectToLogin: + http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther) + case showUnauthorizedJSON: + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error": "Unauthorized"}`)) + } + + return true +} + +// Maybe this should be a POST request instead? +func (a *application) handleLogoutRequest(w http.ResponseWriter, r *http.Request) { + a.setAuthSessionCookie(w, r, "", time.Now().Add(-1*time.Hour)) + http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther) +} + +func (a *application) setAuthSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: AUTH_SESSION_COOKIE_NAME, + Value: token, + Expires: expires, + Secure: strings.ToLower(r.Header.Get("X-Forwarded-Proto")) == "https", + Path: a.Config.Server.BaseURL + "/", + SameSite: http.SameSiteLaxMode, + HttpOnly: true, + }) +} + +func (a *application) handleLoginPageRequest(w http.ResponseWriter, r *http.Request) { + if a.isAuthorized(w, r) { + http.Redirect(w, r, a.Config.Server.BaseURL+"/", http.StatusSeeOther) + return + } + + data := &templateData{ + App: a, + } + a.populateTemplateRequestData(&data.Request, r) + + var responseBytes bytes.Buffer + err := loginPageTemplate.Execute(&responseBytes, data) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + w.Write(responseBytes.Bytes()) +} diff --git a/internal/glance/auth_test.go b/internal/glance/auth_test.go new file mode 100644 index 0000000..97e6bc9 --- /dev/null +++ b/internal/glance/auth_test.go @@ -0,0 +1,85 @@ +package glance + +import ( + "bytes" + "encoding/base64" + "testing" + "time" +) + +func TestAuthTokenGenerationAndVerification(t *testing.T) { + secret, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH) + if err != nil { + t.Fatalf("Failed to generate secret key: %v", err) + } + + secretBytes, err := base64.StdEncoding.DecodeString(secret) + if err != nil { + t.Fatalf("Failed to decode secret key: %v", err) + } + + if len(secretBytes) != AUTH_SECRET_KEY_LENGTH { + t.Fatalf("Secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH) + } + + now := time.Now() + username := "admin" + + token, err := generateSessionToken(username, secretBytes, now) + if err != nil { + t.Fatalf("Failed to generate session token: %v", err) + } + + usernameHashBytes, shouldRegen, err := verifySessionToken(token, secretBytes, now) + if err != nil { + t.Fatalf("Failed to verify session token: %v", err) + } + + if shouldRegen { + t.Fatal("Token should not need to be regenerated immediately after generation") + } + + computedUsernameHash, err := computeUsernameHash(username, secretBytes) + if err != nil { + t.Fatalf("Failed to compute username hash: %v", err) + } + + if !bytes.Equal(usernameHashBytes, computedUsernameHash) { + t.Fatal("Username hash does not match the expected value") + } + + // Test token regeneration + timeRightAfterRegenPeriod := now.Add(AUTH_TOKEN_VALID_PERIOD - AUTH_TOKEN_REGEN_BEFORE + 2*time.Second) + _, shouldRegen, err = verifySessionToken(token, secretBytes, timeRightAfterRegenPeriod) + if err != nil { + t.Fatalf("Token verification should not fail during regeneration period, err: %v", err) + } + + if !shouldRegen { + t.Fatal("Token should have been marked for regeneration") + } + + // Test token expiration + _, _, err = verifySessionToken(token, secretBytes, now.Add(AUTH_TOKEN_VALID_PERIOD+2*time.Second)) + if err == nil { + t.Fatal("Expected token verification to fail after token expiration") + } + + // Test tampered token + decodedToken, err := base64.StdEncoding.DecodeString(token) + if err != nil { + t.Fatalf("Failed to decode token: %v", err) + } + + // If any of the bytes are off by 1, the token should be considered invalid + for i := range len(decodedToken) { + tampered := make([]byte, len(decodedToken)) + copy(tampered, decodedToken) + tampered[i] += 1 + + _, _, err = verifySessionToken(base64.StdEncoding.EncodeToString(tampered), secretBytes, now) + if err == nil { + t.Fatalf("Expected token verification to fail for tampered token at index %d", i) + } + } +} diff --git a/internal/glance/cli.go b/internal/glance/cli.go index 0a76e66..5544b8b 100644 --- a/internal/glance/cli.go +++ b/internal/glance/cli.go @@ -20,6 +20,8 @@ const ( cliIntentDiagnose cliIntentSensorsPrint cliIntentMountpointInfo + cliIntentSecretMake + cliIntentPasswordHash ) type cliOptions struct { @@ -46,12 +48,15 @@ func parseCliOptions() (*cliOptions, error) { flags.PrintDefaults() fmt.Println("\nCommands:") - fmt.Println(" config:validate Validate the config file") - fmt.Println(" config:print Print the parsed config file with embedded includes") - fmt.Println(" sensors:print List all sensors") - fmt.Println(" mountpoint:info Print information about a given mountpoint path") - fmt.Println(" diagnose Run diagnostic checks") + fmt.Println(" config:validate Validate the config file") + fmt.Println(" config:print Print the parsed config file with embedded includes") + fmt.Println(" password:hash Hash a password") + fmt.Println(" secret:make Generate a random secret key") + fmt.Println(" sensors:print List all sensors") + fmt.Println(" mountpoint:info Print information about a given mountpoint path") + fmt.Println(" diagnose Run diagnostic checks") } + configPath := flags.String("config", "glance.yml", "Set config path") err := flags.Parse(os.Args[1:]) if err != nil { @@ -73,6 +78,14 @@ func parseCliOptions() (*cliOptions, error) { intent = cliIntentSensorsPrint } else if args[0] == "diagnose" { intent = cliIntentDiagnose + } else if args[0] == "secret:make" { + intent = cliIntentSecretMake + } else { + return nil, unknownCommandErr + } + } else if len(args) == 2 { + if args[0] == "password:hash" { + intent = cliIntentPasswordHash } else { return nil, unknownCommandErr } diff --git a/internal/glance/config-fields.go b/internal/glance/config-fields.go index e2ece3f..d368140 100644 --- a/internal/glance/config-fields.go +++ b/internal/glance/config-fields.go @@ -3,6 +3,7 @@ package glance import ( "crypto/tls" "fmt" + "html/template" "net/http" "net/url" "regexp" @@ -13,7 +14,7 @@ import ( "gopkg.in/yaml.v3" ) -var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`) +var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?([\d\.]+)(?: |,)+([\d\.]+)%?(?: |,)+([\d\.]+)%?\)?$`) const ( hslHueMax = 360 @@ -22,13 +23,27 @@ const ( ) type hslColorField struct { - Hue uint16 - Saturation uint8 - Lightness uint8 + H float64 + S float64 + L float64 } func (c *hslColorField) String() string { - return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness) + return fmt.Sprintf("hsl(%.1f, %.1f%%, %.1f%%)", c.H, c.S, c.L) +} + +func (c *hslColorField) ToHex() string { + return hslToHex(c.H, c.S, c.L) +} + +func (c1 *hslColorField) SameAs(c2 *hslColorField) bool { + if c1 == nil && c2 == nil { + return true + } + if c1 == nil || c2 == nil { + return false + } + return c1.H == c2.H && c1.S == c2.S && c1.L == c2.L } func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error { @@ -44,7 +59,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error { return fmt.Errorf("invalid HSL color format: %s", value) } - hue, err := strconv.ParseUint(matches[1], 10, 16) + hue, err := strconv.ParseFloat(matches[1], 64) if err != nil { return err } @@ -53,7 +68,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error { return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax) } - saturation, err := strconv.ParseUint(matches[2], 10, 8) + saturation, err := strconv.ParseFloat(matches[2], 64) if err != nil { return err } @@ -62,7 +77,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error { return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax) } - lightness, err := strconv.ParseUint(matches[3], 10, 8) + lightness, err := strconv.ParseFloat(matches[3], 64) if err != nil { return err } @@ -71,9 +86,9 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error { return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax) } - c.Hue = uint16(hue) - c.Saturation = uint8(saturation) - c.Lightness = uint8(lightness) + c.H = hue + c.S = saturation + c.L = lightness return nil } @@ -115,7 +130,7 @@ func (d *durationField) UnmarshalYAML(node *yaml.Node) error { } type customIconField struct { - URL string + URL template.URL IsFlatIcon bool // TODO: along with whether the icon is flat, we also need to know // whether the icon is black or white by default in order to properly @@ -123,17 +138,23 @@ type customIconField struct { } func newCustomIconField(value string) customIconField { + const autoInvertPrefix = "auto-invert " field := customIconField{} prefix, icon, found := strings.Cut(value, ":") if !found { - field.URL = value + if strings.HasPrefix(value, autoInvertPrefix) { + field.IsFlatIcon = true + value = strings.TrimPrefix(value, autoInvertPrefix) + } + + field.URL = template.URL(value) return field } switch prefix { case "si": - field.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg" + field.URL = template.URL("https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg") field.IsFlatIcon = true case "di", "sh": // syntax: di:[.svg|.png] @@ -152,12 +173,12 @@ func newCustomIconField(value string) customIconField { } if prefix == "di" { - field.URL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext + field.URL = template.URL("https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext) } else { - field.URL = "https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext + field.URL = template.URL("https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext) } default: - field.URL = value + field.URL = template.URL(value) } return field diff --git a/internal/glance/config.go b/internal/glance/config.go index 0d424a2..84714d0 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -2,8 +2,10 @@ package glance import ( "bytes" + "errors" "fmt" "html/template" + "iter" "log" "maps" "os" @@ -17,50 +19,69 @@ import ( "gopkg.in/yaml.v3" ) +const CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT = 20 + +const ( + configVarTypeEnv = "env" + configVarTypeSecret = "secret" + configVarTypeFileFromEnv = "readFileFromEnv" +) + type config struct { Server struct { - Host string `yaml:"host"` - Port uint16 `yaml:"port"` - AssetsPath string `yaml:"assets-path"` - BaseURL string `yaml:"base-url"` - StartedAt time.Time `yaml:"-"` // used in custom css file + Host string `yaml:"host"` + Port uint16 `yaml:"port"` + Proxied bool `yaml:"proxied"` + AssetsPath string `yaml:"assets-path"` + BaseURL string `yaml:"base-url"` } `yaml:"server"` + Auth struct { + SecretKey string `yaml:"secret-key"` + Users map[string]*user `yaml:"users"` + } `yaml:"auth"` + Document struct { Head template.HTML `yaml:"head"` } `yaml:"document"` Theme struct { - BackgroundColor *hslColorField `yaml:"background-color"` - PrimaryColor *hslColorField `yaml:"primary-color"` - PositiveColor *hslColorField `yaml:"positive-color"` - NegativeColor *hslColorField `yaml:"negative-color"` - Light bool `yaml:"light"` - ContrastMultiplier float32 `yaml:"contrast-multiplier"` - TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"` - CustomCSSFile string `yaml:"custom-css-file"` + themeProperties `yaml:",inline"` + CustomCSSFile string `yaml:"custom-css-file"` + Presets orderedYAMLMap[string, *themeProperties] `yaml:"presets"` } `yaml:"theme"` Branding struct { - HideFooter bool `yaml:"hide-footer"` - CustomFooter template.HTML `yaml:"custom-footer"` - LogoText string `yaml:"logo-text"` - LogoURL string `yaml:"logo-url"` - FaviconURL string `yaml:"favicon-url"` + HideFooter bool `yaml:"hide-footer"` + CustomFooter template.HTML `yaml:"custom-footer"` + LogoText string `yaml:"logo-text"` + LogoURL string `yaml:"logo-url"` + FaviconURL string `yaml:"favicon-url"` + FaviconType string `yaml:"-"` + AppName string `yaml:"app-name"` + AppIconURL string `yaml:"app-icon-url"` + AppBackgroundColor string `yaml:"app-background-color"` } `yaml:"branding"` Pages []page `yaml:"pages"` } +type user struct { + Password string `yaml:"password"` + PasswordHashString string `yaml:"password-hash"` + PasswordHash []byte `yaml:"-"` +} + type page struct { - Title string `yaml:"name"` - Slug string `yaml:"slug"` - Width string `yaml:"width"` - ShowMobileHeader bool `yaml:"show-mobile-header"` - ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"` - HideDesktopNavigation bool `yaml:"hide-desktop-navigation"` - CenterVertically bool `yaml:"center-vertically"` - Columns []struct { + Title string `yaml:"name"` + Slug string `yaml:"slug"` + Width string `yaml:"width"` + DesktopNavigationWidth string `yaml:"desktop-navigation-width"` + ShowMobileHeader bool `yaml:"show-mobile-header"` + HideDesktopNavigation bool `yaml:"hide-desktop-navigation"` + CenterVertically bool `yaml:"center-vertically"` + HeadWidgets widgets `yaml:"head-widgets"` + Columns []struct { Size string `yaml:"size"` Widgets widgets `yaml:"widgets"` } `yaml:"columns"` @@ -69,7 +90,7 @@ type page struct { } func newConfigFromYAML(contents []byte) (*config, error) { - contents, err := parseConfigEnvVariables(contents) + contents, err := parseConfigVariables(contents) if err != nil { return nil, err } @@ -87,6 +108,12 @@ func newConfigFromYAML(contents []byte) (*config, error) { } for p := range config.Pages { + for w := range config.Pages[p].HeadWidgets { + if err := config.Pages[p].HeadWidgets[w].initialize(); err != nil { + return nil, formatWidgetInitError(err, config.Pages[p].HeadWidgets[w]) + } + } + for c := range config.Pages[p].Columns { for w := range config.Pages[p].Columns[c].Widgets { if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil { @@ -99,23 +126,33 @@ func newConfigFromYAML(contents []byte) (*config, error) { return config, nil } -// TODO: change the pattern so that it doesn't match commented out lines -var configEnvVariablePattern = regexp.MustCompile(`(^|.)\$\{([A-Z0-9_]+)\}`) +var envVariableNamePattern = regexp.MustCompile(`^[A-Z0-9_]+$`) +var configVariablePattern = regexp.MustCompile(`(^|.)\$\{(?:([a-zA-Z]+):)?([a-zA-Z0-9_-]+)\}`) -func parseConfigEnvVariables(contents []byte) ([]byte, error) { +// Parses variables defined in the config such as: +// ${API_KEY} - gets replaced with the value of the API_KEY environment variable +// \${API_KEY} - escaped, gets used as is without the \ in the config +// ${secret:api_key} - value gets loaded from /run/secrets/api_key +// ${readFileFromEnv:PATH_TO_SECRET} - value gets loaded from the file path specified in the environment variable PATH_TO_SECRET +// +// TODO: don't match against commented out sections, not sure exactly how since +// variables can be placed anywhere and used to modify the YAML structure itself +func parseConfigVariables(contents []byte) ([]byte, error) { var err error - replaced := configEnvVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte { + replaced := configVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte { if err != nil { return nil } - groups := configEnvVariablePattern.FindSubmatch(match) - if len(groups) != 3 { + groups := configVariablePattern.FindSubmatch(match) + if len(groups) != 4 { + // we can't handle this match, this shouldn't happen unless the number of groups + // in the regex has been changed without updating the below code return match } - prefix, key := string(groups[1]), string(groups[2]) + prefix := string(groups[1]) if prefix == `\` { if len(match) >= 2 { return match[1:] @@ -124,13 +161,20 @@ func parseConfigEnvVariables(contents []byte) ([]byte, error) { } } - value, found := os.LookupEnv(key) - if !found { - err = fmt.Errorf("environment variable %s not found", key) + typeAsString, variableName := string(groups[2]), string(groups[3]) + variableType := ternary(typeAsString == "", configVarTypeEnv, typeAsString) + + parsedValue, returnOriginal, localErr := parseConfigVariableOfType(variableType, variableName) + if localErr != nil { + err = fmt.Errorf("parsing variable: %v", localErr) return nil } - return []byte(prefix + value) + if returnOriginal { + return match + } + + return []byte(prefix + parsedValue) }) if err != nil { @@ -140,33 +184,90 @@ func parseConfigEnvVariables(contents []byte) ([]byte, error) { return replaced, nil } +// When the bool return value is true, it indicates that the caller should use the original value +func parseConfigVariableOfType(variableType, variableName string) (string, bool, error) { + switch variableType { + case configVarTypeEnv: + if !envVariableNamePattern.MatchString(variableName) { + return "", true, nil + } + + v, found := os.LookupEnv(variableName) + if !found { + return "", false, fmt.Errorf("environment variable %s not found", variableName) + } + + return v, false, nil + case configVarTypeSecret: + secretPath := filepath.Join("/run/secrets", variableName) + secret, err := os.ReadFile(secretPath) + if err != nil { + return "", false, fmt.Errorf("reading secret file: %v", err) + } + + return strings.TrimSpace(string(secret)), false, nil + case configVarTypeFileFromEnv: + if !envVariableNamePattern.MatchString(variableName) { + return "", true, nil + } + + filePath, found := os.LookupEnv(variableName) + if !found { + return "", false, fmt.Errorf("readFileFromEnv: environment variable %s not found", variableName) + } + + if !filepath.IsAbs(filePath) { + return "", false, fmt.Errorf("readFileFromEnv: file path %s is not absolute", filePath) + } + + fileContents, err := os.ReadFile(filePath) + if err != nil { + return "", false, fmt.Errorf("readFileFromEnv: reading file from %s: %v", variableName, err) + } + + return strings.TrimSpace(string(fileContents)), false, nil + default: + return "", true, nil + } +} + func formatWidgetInitError(err error, w widget) error { return fmt.Errorf("%s widget: %v", w.GetType(), err) } -var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`) +var configIncludePattern = regexp.MustCompile(`(?m)^([ \t]*)(?:-[ \t]*)?(?:!|\$)include:[ \t]*(.+)$`) func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) { + return recursiveParseYAMLIncludes(mainFilePath, nil, 0) +} + +func recursiveParseYAMLIncludes(mainFilePath string, includes map[string]struct{}, depth int) ([]byte, map[string]struct{}, error) { + if depth > CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT { + return nil, nil, fmt.Errorf("recursion depth limit of %d reached", CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT) + } + mainFileContents, err := os.ReadFile(mainFilePath) if err != nil { - return nil, nil, fmt.Errorf("reading main YAML file: %w", err) + return nil, nil, fmt.Errorf("reading %s: %w", mainFilePath, err) } mainFileAbsPath, err := filepath.Abs(mainFilePath) if err != nil { - return nil, nil, fmt.Errorf("getting absolute path of main YAML file: %w", err) + return nil, nil, fmt.Errorf("getting absolute path of %s: %w", mainFilePath, err) } mainFileDir := filepath.Dir(mainFileAbsPath) - includes := make(map[string]struct{}) + if includes == nil { + includes = make(map[string]struct{}) + } var includesLastErr error - mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte { + mainFileContents = configIncludePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte { if includesLastErr != nil { return nil } - matches := includePattern.FindSubmatch(match) + matches := configIncludePattern.FindSubmatch(match) if len(matches) != 3 { includesLastErr = fmt.Errorf("invalid include match: %v", matches) return nil @@ -181,13 +282,14 @@ func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) var fileContents []byte var err error - fileContents, err = os.ReadFile(includeFilePath) + includes[includeFilePath] = struct{}{} + + fileContents, includes, err = recursiveParseYAMLIncludes(includeFilePath, includes, depth+1) if err != nil { - includesLastErr = fmt.Errorf("reading included file %s: %w", includeFilePath, err) + includesLastErr = err return nil } - includes[includeFilePath] = struct{}{} return []byte(prefixStringLines(indent, string(fileContents))) }) @@ -308,7 +410,7 @@ func configFilesWatcher( // wait for file to maybe get created again // see https://github.com/glanceapp/glance/pull/358 - for i := 0; i < 10; i++ { + for range 10 { if _, err := os.Stat(event.Name); err == nil { break } @@ -340,11 +442,39 @@ func configFilesWatcher( }, nil } +// TODO: Refactor, we currently validate in two different places, this being +// one of them, which doesn't modify the data and only checks for logical errors +// and then again when creating the application which does modify the data and do +// further validation. Would be better if validation was done in a single place. func isConfigStateValid(config *config) error { if len(config.Pages) == 0 { return fmt.Errorf("no pages configured") } + if len(config.Auth.Users) > 0 && config.Auth.SecretKey == "" { + return fmt.Errorf("secret-key must be set when users are configured") + } + + for username := range config.Auth.Users { + if username == "" { + return fmt.Errorf("user has no name") + } + + if len(username) < 3 { + return errors.New("usernames must be at least 3 characters") + } + + user := config.Auth.Users[username] + + if user.Password == "" { + if user.PasswordHashString == "" { + return fmt.Errorf("user %s must have a password or a password-hash set", username) + } + } else if len(user.Password) < 6 { + return fmt.Errorf("the password for %s must be at least 6 characters", username) + } + } + if config.Server.AssetsPath != "" { if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) { return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath) @@ -352,36 +482,46 @@ func isConfigStateValid(config *config) error { } for i := range config.Pages { - if config.Pages[i].Title == "" { + page := &config.Pages[i] + + if page.Title == "" { return fmt.Errorf("page %d has no name", i+1) } - if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") { + if page.Width != "" && (page.Width != "wide" && page.Width != "slim" && page.Width != "default") { return fmt.Errorf("page %d: width can only be either wide or slim", i+1) } - if len(config.Pages[i].Columns) == 0 { + if page.DesktopNavigationWidth != "" { + if page.DesktopNavigationWidth != "wide" && page.DesktopNavigationWidth != "slim" && page.DesktopNavigationWidth != "default" { + return fmt.Errorf("page %d: desktop-navigation-width can only be either wide or slim", i+1) + } + } + + if len(page.Columns) == 0 { return fmt.Errorf("page %d has no columns", i+1) } - if config.Pages[i].Width == "slim" { - if len(config.Pages[i].Columns) > 2 { + if page.Width == "slim" { + if len(page.Columns) > 2 { return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1) } } else { - if len(config.Pages[i].Columns) > 3 { + if len(page.Columns) > 3 { return fmt.Errorf("page %d has more than 3 columns", i+1) } } columnSizesCount := make(map[string]int) - for j := range config.Pages[i].Columns { - if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" { + for j := range page.Columns { + column := &page.Columns[j] + + if column.Size != "small" && column.Size != "full" { return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1) } - columnSizesCount[config.Pages[i].Columns[j].Size]++ + columnSizesCount[page.Columns[j].Size]++ } full := columnSizesCount["full"] @@ -393,3 +533,103 @@ func isConfigStateValid(config *config) error { return nil } + +// Read-only way to store ordered maps from a YAML structure +type orderedYAMLMap[K comparable, V any] struct { + keys []K + data map[K]V +} + +func newOrderedYAMLMap[K comparable, V any](keys []K, values []V) (*orderedYAMLMap[K, V], error) { + if len(keys) != len(values) { + return nil, fmt.Errorf("keys and values must have the same length") + } + + om := &orderedYAMLMap[K, V]{ + keys: make([]K, len(keys)), + data: make(map[K]V, len(keys)), + } + + copy(om.keys, keys) + + for i := range keys { + om.data[keys[i]] = values[i] + } + + return om, nil +} + +func (om *orderedYAMLMap[K, V]) Items() iter.Seq2[K, V] { + return func(yield func(K, V) bool) { + for _, key := range om.keys { + value, ok := om.data[key] + if !ok { + continue + } + if !yield(key, value) { + return + } + } + } +} + +func (om *orderedYAMLMap[K, V]) Get(key K) (V, bool) { + value, ok := om.data[key] + return value, ok +} + +func (self *orderedYAMLMap[K, V]) Merge(other *orderedYAMLMap[K, V]) *orderedYAMLMap[K, V] { + merged := &orderedYAMLMap[K, V]{ + keys: make([]K, 0, len(self.keys)+len(other.keys)), + data: make(map[K]V, len(self.data)+len(other.data)), + } + + merged.keys = append(merged.keys, self.keys...) + maps.Copy(merged.data, self.data) + + for _, key := range other.keys { + if _, exists := self.data[key]; !exists { + merged.keys = append(merged.keys, key) + } + } + maps.Copy(merged.data, other.data) + + return merged +} + +func (om *orderedYAMLMap[K, V]) UnmarshalYAML(node *yaml.Node) error { + if node.Kind != yaml.MappingNode { + return fmt.Errorf("orderedMap: expected mapping node, got %d", node.Kind) + } + + if len(node.Content)%2 != 0 { + return fmt.Errorf("orderedMap: expected even number of content items, got %d", len(node.Content)) + } + + om.keys = make([]K, len(node.Content)/2) + om.data = make(map[K]V, len(node.Content)/2) + + for i := 0; i < len(node.Content); i += 2 { + keyNode := node.Content[i] + valueNode := node.Content[i+1] + + var key K + if err := keyNode.Decode(&key); err != nil { + return fmt.Errorf("orderedMap: decoding key: %v", err) + } + + if _, ok := om.data[key]; ok { + return fmt.Errorf("orderedMap: duplicate key %v", key) + } + + var value V + if err := valueNode.Decode(&value); err != nil { + return fmt.Errorf("orderedMap: decoding value: %v", err) + } + + (*om).keys[i/2] = key + (*om).data[key] = value + } + + return nil +} diff --git a/internal/glance/embed.go b/internal/glance/embed.go index 7bb07c9..e09caa8 100644 --- a/internal/glance/embed.go +++ b/internal/glance/embed.go @@ -1,13 +1,19 @@ package glance import ( + "bytes" "crypto/md5" "embed" "encoding/hex" + "errors" + "fmt" "io" "io/fs" "log" + "path/filepath" + "regexp" "strconv" + "strings" "time" ) @@ -20,6 +26,19 @@ var _templateFS embed.FS var staticFS, _ = fs.Sub(_staticFS, "static") var templateFS, _ = fs.Sub(_templateFS, "templates") +func readAllFromStaticFS(path string) ([]byte, error) { + // For some reason fs.FS only works with forward slashes, so in case we're + // running on Windows or pass paths with backslashes we need to replace them. + path = strings.ReplaceAll(path, "\\", "/") + + file, err := staticFS.Open(path) + if err != nil { + return nil, err + } + + return io.ReadAll(file) +} + var staticFSHash = func() string { hash, err := computeFSHash(staticFS) if err != nil { @@ -60,3 +79,73 @@ func computeFSHash(files fs.FS) (string, error) { return hex.EncodeToString(hash.Sum(nil))[:10], nil } + +var cssImportPattern = regexp.MustCompile(`(?m)^@import "(.*?)";$`) +var cssSingleLineCommentPattern = regexp.MustCompile(`(?m)^\s*\/\*.*?\*\/$`) + +// Yes, we bundle at runtime, give comptime pls +var bundledCSSContents = func() []byte { + const mainFilePath = "css/main.css" + + var recursiveParseImports func(path string, depth int) ([]byte, error) + recursiveParseImports = func(path string, depth int) ([]byte, error) { + if depth > 20 { + return nil, errors.New("maximum import depth reached, is one of your imports circular?") + } + + mainFileContents, err := readAllFromStaticFS(path) + if err != nil { + return nil, err + } + + // Normalize line endings, otherwise the \r's make the regex not match + mainFileContents = bytes.ReplaceAll(mainFileContents, []byte("\r\n"), []byte("\n")) + + mainFileDir := filepath.Dir(path) + var importLastErr error + + parsed := cssImportPattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte { + if importLastErr != nil { + return nil + } + + matches := cssImportPattern.FindSubmatch(match) + if len(matches) != 2 { + importLastErr = fmt.Errorf( + "import didn't return expected number of capture groups: %s, expected 2, got %d", + match, len(matches), + ) + return nil + } + + importFilePath := filepath.Join(mainFileDir, string(matches[1])) + importContents, err := recursiveParseImports(importFilePath, depth+1) + if err != nil { + importLastErr = err + return nil + } + + return importContents + }) + + if importLastErr != nil { + return nil, importLastErr + } + + return parsed, nil + } + + contents, err := recursiveParseImports(mainFilePath, 0) + if err != nil { + panic(fmt.Sprintf("building CSS bundle: %v", err)) + } + + // We could strip a bunch more unnecessary characters, but the biggest + // win comes from removing the whitespace at the beginning of lines + // since that's at least 4 bytes per property, which yielded a ~20% reduction. + contents = cssSingleLineCommentPattern.ReplaceAll(contents, nil) + contents = whitespaceAtBeginningOfLinePattern.ReplaceAll(contents, nil) + contents = bytes.ReplaceAll(contents, []byte("\n"), []byte("")) + + return contents +}() diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 8fb3e40..4004583 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -3,50 +3,149 @@ package glance import ( "bytes" "context" + "encoding/base64" "fmt" - "html/template" "log" "net/http" "path/filepath" + "slices" "strconv" "strings" "sync" "time" + + "golang.org/x/crypto/bcrypt" ) var ( - pageTemplate = mustParseTemplate("page.html", "document.html") - pageContentTemplate = mustParseTemplate("page-content.html") - pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl") + pageTemplate = mustParseTemplate("page.html", "document.html", "footer.html") + pageContentTemplate = mustParseTemplate("page-content.html") + manifestTemplate = mustParseTemplate("manifest.json") ) +const STATIC_ASSETS_CACHE_DURATION = 24 * time.Hour + +var reservedPageSlugs = []string{"login", "logout"} + type application struct { - Version string - Config config - ParsedThemeStyle template.HTML + Version string + CreatedAt time.Time + Config config + + parsedManifest []byte slugToPage map[string]*page widgetByID map[uint64]widget + + RequiresAuth bool + authSecretKey []byte + usernameHashToUsername map[string]string + authAttemptsMu sync.Mutex + failedAuthAttempts map[string]*failedAuthAttempt } -func newApplication(config *config) (*application, error) { +func newApplication(c *config) (*application, error) { app := &application{ Version: buildVersion, - Config: *config, + CreatedAt: time.Now(), + Config: *c, slugToPage: make(map[string]*page), widgetByID: make(map[uint64]widget), } + config := &app.Config + + // + // Init auth + // + + if len(config.Auth.Users) > 0 { + secretBytes, err := base64.StdEncoding.DecodeString(config.Auth.SecretKey) + if err != nil { + return nil, fmt.Errorf("decoding secret-key: %v", err) + } + + if len(secretBytes) != AUTH_SECRET_KEY_LENGTH { + return nil, fmt.Errorf("secret-key must be exactly %d bytes", AUTH_SECRET_KEY_LENGTH) + } + + app.usernameHashToUsername = make(map[string]string) + app.failedAuthAttempts = make(map[string]*failedAuthAttempt) + app.RequiresAuth = true + + for username := range config.Auth.Users { + user := config.Auth.Users[username] + usernameHash, err := computeUsernameHash(username, secretBytes) + if err != nil { + return nil, fmt.Errorf("computing username hash for user %s: %v", username, err) + } + app.usernameHashToUsername[string(usernameHash)] = username + + if user.PasswordHashString != "" { + user.PasswordHash = []byte(user.PasswordHashString) + user.PasswordHashString = "" + } else { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("hashing password for user %s: %v", username, err) + } + + user.Password = "" + user.PasswordHash = hashedPassword + } + } + + app.authSecretKey = secretBytes + } + + // + // Init themes + // + + themeKeys := make([]string, 0, 2) + themeProps := make([]*themeProperties, 0, 2) + + defaultDarkTheme, ok := config.Theme.Presets.Get("default-dark") + if ok && !config.Theme.SameAs(defaultDarkTheme) || !config.Theme.SameAs(&themeProperties{}) { + themeKeys = append(themeKeys, "default-dark") + themeProps = append(themeProps, &themeProperties{}) + } + + themeKeys = append(themeKeys, "default-light") + themeProps = append(themeProps, &themeProperties{ + Light: true, + BackgroundColor: &hslColorField{240, 13, 95}, + PrimaryColor: &hslColorField{230, 100, 30}, + NegativeColor: &hslColorField{0, 70, 50}, + ContrastMultiplier: 1.3, + TextSaturationMultiplier: 0.5, + }) + + themePresets, err := newOrderedYAMLMap(themeKeys, themeProps) + if err != nil { + return nil, fmt.Errorf("creating theme presets: %v", err) + } + config.Theme.Presets = *themePresets.Merge(&config.Theme.Presets) + + for key, properties := range config.Theme.Presets.Items() { + properties.Key = key + if err := properties.init(); err != nil { + return nil, fmt.Errorf("initializing preset theme %s: %v", key, err) + } + } + + config.Theme.Key = "default" + if err := config.Theme.init(); err != nil { + return nil, fmt.Errorf("initializing default theme: %v", err) + } + + // + // Init pages + // app.slugToPage[""] = &config.Pages[0] providers := &widgetProviders{ - assetResolver: app.AssetPath, - } - - var err error - app.ParsedThemeStyle, err = executeTemplateToHTML(pageThemeStyleTemplate, &app.Config.Theme) - if err != nil { - return nil, fmt.Errorf("parsing theme style: %v", err) + assetResolver: app.StaticAssetPath, } for p := range config.Pages { @@ -57,8 +156,20 @@ func newApplication(config *config) (*application, error) { page.Slug = titleToSlug(page.Title) } + if slices.Contains(reservedPageSlugs, page.Slug) { + return nil, fmt.Errorf("page slug \"%s\" is reserved", page.Slug) + } + app.slugToPage[page.Slug] = page + if page.Width == "default" { + page.Width = "" + } + + if page.DesktopNavigationWidth == "" && page.DesktopNavigationWidth != "default" { + page.DesktopNavigationWidth = page.Width + } + for c := range page.Columns { column := &page.Columns[c] @@ -75,18 +186,39 @@ func newApplication(config *config) (*application, error) { } } - config = &app.Config - config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/") - config.Theme.CustomCSSFile = app.transformUserDefinedAssetPath(config.Theme.CustomCSSFile) + config.Theme.CustomCSSFile = app.resolveUserDefinedAssetPath(config.Theme.CustomCSSFile) + config.Branding.LogoURL = app.resolveUserDefinedAssetPath(config.Branding.LogoURL) - if config.Branding.FaviconURL == "" { - config.Branding.FaviconURL = app.AssetPath("favicon.png") - } else { - config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL) + config.Branding.FaviconURL = ternary( + config.Branding.FaviconURL == "", + app.StaticAssetPath("favicon.svg"), + app.resolveUserDefinedAssetPath(config.Branding.FaviconURL), + ) + + config.Branding.FaviconType = ternary( + strings.HasSuffix(config.Branding.FaviconURL, ".svg"), + "image/svg+xml", + "image/png", + ) + + if config.Branding.AppName == "" { + config.Branding.AppName = "Glance" } - config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL) + if config.Branding.AppIconURL == "" { + config.Branding.AppIconURL = app.StaticAssetPath("app-icon.png") + } + + if config.Branding.AppBackgroundColor == "" { + config.Branding.AppBackgroundColor = config.Theme.BackgroundColorAsHex + } + + manifest, err := executeTemplateToString(manifestTemplate, templateData{App: app}) + if err != nil { + return nil, fmt.Errorf("parsing manifest.json: %v", err) + } + app.parsedManifest = []byte(manifest) return app, nil } @@ -97,6 +229,20 @@ func (p *page) updateOutdatedWidgets() { var wg sync.WaitGroup context := context.Background() + for w := range p.HeadWidgets { + widget := p.HeadWidgets[w] + + if !widget.requiresUpdate(&now) { + continue + } + + wg.Add(1) + go func() { + defer wg.Done() + widget.update(context) + }() + } + for c := range p.Columns { for w := range p.Columns[c].Widgets { widget := p.Columns[c].Widgets[w] @@ -116,7 +262,7 @@ func (p *page) updateOutdatedWidgets() { wg.Wait() } -func (a *application) transformUserDefinedAssetPath(path string) string { +func (a *application) resolveUserDefinedAssetPath(path string) string { if strings.HasPrefix(path, "/assets/") { return a.Config.Server.BaseURL + path } @@ -124,26 +270,49 @@ func (a *application) transformUserDefinedAssetPath(path string) string { return path } -type pageTemplateData struct { - App *application - Page *page +type templateRequestData struct { + Theme *themeProperties +} + +type templateData struct { + App *application + Page *page + Request templateRequestData +} + +func (a *application) populateTemplateRequestData(data *templateRequestData, r *http.Request) { + theme := &a.Config.Theme.themeProperties + + selectedTheme, err := r.Cookie("theme") + if err == nil { + preset, exists := a.Config.Theme.Presets.Get(selectedTheme.Value) + if exists { + theme = preset + } + } + + data.Theme = theme } func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) { page, exists := a.slugToPage[r.PathValue("page")] - if !exists { a.handleNotFound(w, r) return } - pageData := pageTemplateData{ + if a.handleUnauthorizedResponse(w, r, redirectToLogin) { + return + } + + data := templateData{ Page: page, App: a, } + a.populateTemplateRequestData(&data.Request, r) var responseBytes bytes.Buffer - err := pageTemplate.Execute(&responseBytes, pageData) + err := pageTemplate.Execute(&responseBytes, data) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) @@ -155,13 +324,16 @@ func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) { page, exists := a.slugToPage[r.PathValue("page")] - if !exists { a.handleNotFound(w, r) return } - pageData := pageTemplateData{ + if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) { + return + } + + pageData := templateData{ Page: page, } @@ -185,6 +357,35 @@ func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Re w.Write(responseBytes.Bytes()) } +func (a *application) addressOfRequest(r *http.Request) string { + remoteAddrWithoutPort := func() string { + for i := len(r.RemoteAddr) - 1; i >= 0; i-- { + if r.RemoteAddr[i] == ':' { + return r.RemoteAddr[:i] + } + } + + return r.RemoteAddr + } + + if !a.Config.Server.Proxied { + return remoteAddrWithoutPort() + } + + // This should probably be configurable or look for multiple headers, not just this one + forwardedFor := r.Header.Get("X-Forwarded-For") + if forwardedFor == "" { + return remoteAddrWithoutPort() + } + + ips := strings.Split(forwardedFor, ",") + if len(ips) == 0 || ips[0] == "" { + return remoteAddrWithoutPort() + } + + return ips[0] +} + func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) { // TODO: add proper not found page w.WriteHeader(http.StatusNotFound) @@ -192,47 +393,81 @@ func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) { } func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) { - widgetValue := r.PathValue("widget") + // TODO: this requires a rework of the widget update logic so that rather + // than locking the entire page we lock individual widgets + w.WriteHeader(http.StatusNotImplemented) - widgetID, err := strconv.ParseUint(widgetValue, 10, 64) - if err != nil { - a.handleNotFound(w, r) - return - } + // widgetValue := r.PathValue("widget") - widget, exists := a.widgetByID[widgetID] + // widgetID, err := strconv.ParseUint(widgetValue, 10, 64) + // if err != nil { + // a.handleNotFound(w, r) + // return + // } - if !exists { - a.handleNotFound(w, r) - return - } + // widget, exists := a.widgetByID[widgetID] - widget.handleRequest(w, r) + // if !exists { + // a.handleNotFound(w, r) + // return + // } + + // widget.handleRequest(w, r) } -func (a *application) AssetPath(asset string) string { +func (a *application) StaticAssetPath(asset string) string { return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset } +func (a *application) VersionedAssetPath(asset string) string { + return a.Config.Server.BaseURL + asset + + "?v=" + strconv.FormatInt(a.CreatedAt.Unix(), 10) +} + func (a *application) server() (func() error, func() error) { - // TODO: add gzip support, static files must have their gzipped contents cached - // TODO: add HTTPS support mux := http.NewServeMux() mux.HandleFunc("GET /{$}", a.handlePageRequest) mux.HandleFunc("GET /{page}", a.handlePageRequest) mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest) + mux.HandleFunc("POST /api/set-theme/{key}", a.handleThemeChangeRequest) mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest) mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + if a.RequiresAuth { + mux.HandleFunc("GET /login", a.handleLoginPageRequest) + mux.HandleFunc("GET /logout", a.handleLogoutRequest) + mux.HandleFunc("POST /api/authenticate", a.handleAuthenticationAttempt) + } + mux.Handle( fmt.Sprintf("GET /static/%s/{path...}", staticFSHash), - http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)), + http.StripPrefix( + "/static/"+staticFSHash, + fileServerWithCache(http.FS(staticFS), STATIC_ASSETS_CACHE_DURATION), + ), ) + assetCacheControlValue := fmt.Sprintf( + "public, max-age=%d", + int(STATIC_ASSETS_CACHE_DURATION.Seconds()), + ) + + mux.HandleFunc(fmt.Sprintf("GET /static/%s/css/bundle.css", staticFSHash), func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Cache-Control", assetCacheControlValue) + w.Header().Add("Content-Type", "text/css; charset=utf-8") + w.Write(bundledCSSContents) + }) + + mux.HandleFunc("GET /manifest.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Cache-Control", assetCacheControlValue) + w.Header().Add("Content-Type", "application/json") + w.Write(a.parsedManifest) + }) + var absAssetsPath string if a.Config.Server.AssetsPath != "" { absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath) @@ -246,7 +481,6 @@ func (a *application) server() (func() error, func() error) { } start := func() error { - a.Config.Server.StartedAt = time.Now() log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n", a.Config.Server.Host, a.Config.Server.Port, diff --git a/internal/glance/main.go b/internal/glance/main.go index 67a980c..6d73a83 100644 --- a/internal/glance/main.go +++ b/internal/glance/main.go @@ -6,6 +6,8 @@ import ( "log" "net/http" "os" + + "golang.org/x/crypto/bcrypt" ) var buildVersion = "dev" @@ -55,12 +57,43 @@ func Main() int { return cliMountpointInfo(options.args[1]) case cliIntentDiagnose: runDiagnostic() + case cliIntentSecretMake: + key, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH) + if err != nil { + fmt.Printf("Failed to make secret key: %v\n", err) + return 1 + } + + fmt.Println(key) + case cliIntentPasswordHash: + password := options.args[1] + + if password == "" { + fmt.Println("Password cannot be empty") + return 1 + } + + if len(password) < 6 { + fmt.Println("Password must be at least 6 characters long") + return 1 + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + fmt.Printf("Failed to hash password: %v\n", err) + return 1 + } + + fmt.Println(string(hashedPassword)) } return 0 } func serveApp(configPath string) error { + // TODO: refactor if this gets any more complex, the current implementation is + // difficult to reason about due to all of the callbacks and simultaneous operations, + // use a single goroutine and a channel to initiate synchronous changes to the server exitChannel := make(chan struct{}) hadValidConfigOnStartup := false var stopServer func() error @@ -79,16 +112,23 @@ func serveApp(configPath string) error { } return - } else if !hadValidConfigOnStartup { - hadValidConfigOnStartup = true } app, err := newApplication(config) if err != nil { log.Printf("Failed to create application: %v", err) + + if !hadValidConfigOnStartup { + close(exitChannel) + } + return } + if !hadValidConfigOnStartup { + hadValidConfigOnStartup = true + } + if stopServer != nil { if err := stopServer(); err != nil { log.Printf("Error while trying to stop server: %v", err) diff --git a/internal/glance/static/app-icon.png b/internal/glance/static/app-icon.png index 54fc413..fdf8906 100644 Binary files a/internal/glance/static/app-icon.png and b/internal/glance/static/app-icon.png differ diff --git a/internal/glance/static/css/forum-posts.css b/internal/glance/static/css/forum-posts.css new file mode 100644 index 0000000..e58ac6e --- /dev/null +++ b/internal/glance/static/css/forum-posts.css @@ -0,0 +1,19 @@ +.forum-post-list-thumbnail { + flex-shrink: 0; + width: 6rem; + height: 4.1rem; + border-radius: var(--border-radius); + object-fit: cover; + border: 1px solid var(--color-separator); + margin-top: 0.1rem; +} + +.forum-post-tags-container { + transform: translateY(-0.15rem); +} + +@container widget (max-width: 550px) { + .forum-post-autohide { + display: none; + } +} diff --git a/internal/glance/static/css/login.css b/internal/glance/static/css/login.css new file mode 100644 index 0000000..0afcd1a --- /dev/null +++ b/internal/glance/static/css/login.css @@ -0,0 +1,155 @@ +.login-bounds { + max-width: 500px; + padding: 0 2rem; +} + +.form-label { + text-transform: uppercase; + margin-bottom: 0.5rem; +} + +.form-input { + transition: border-color .2s; +} + +.form-input input { + border: 0; + background: none; + width: 100%; + height: 5.2rem; + font: inherit; + outline: none; + color: var(--color-text-highlight); +} + +.form-input-icon { + width: 2rem; + height: 2rem; + margin-top: -0.1rem; + opacity: 0.5; +} + +.form-input input[type="password"] { + letter-spacing: 0.3rem; + font-size: 0.9em; +} + +.form-input input[type="password"]::placeholder { + letter-spacing: 0; + font-size: var(--font-size-base); +} + +.form-input:hover { + border-color: var(--color-progress-border); +} + +.form-input:focus-within { + border-color: var(--color-primary); + transition-duration: .7s; +} + +.login-button { + width: 100%; + display: block; + padding: 1rem; + background: none; + border: 1px solid var(--color-text-subdue); + border-radius: var(--border-radius); + color: var(--color-text-paragraph); + cursor: pointer; + font: inherit; + font-size: var(--font-size-h4); + display: flex; + gap: .5rem; + align-items: center; + justify-content: center; + transition: all .3s, margin-top 0s; + margin-top: 3rem; +} + +.login-button:not(:disabled) { + box-shadow: 0 0 10px 1px var(--color-separator); +} + +.login-error-message:not(:empty) + .login-button { + margin-top: 2rem; +} + +.login-button:focus, .login-button:hover { + outline: none; + border-color: var(--color-primary); + color: var(--color-primary); +} + +.login-button:disabled { + border-color: var(--color-separator); + color: var(--color-text-subdue); + cursor: not-allowed; +} + +.login-button svg { + width: 1.7rem; + height: 1.7rem; + transition: transform .2s; +} + +.login-button:not(:disabled):hover svg, .login-button:not(:disabled):focus svg { + transform: translateX(.5rem); +} + +.animate-entrance { + animation: fieldReveal 0.7s backwards; + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); +} + +.animate-entrance:nth-child(1) { animation-delay: .1s; } +.animate-entrance:nth-child(2) { animation-delay: .2s; } +.animate-entrance:nth-child(4) { animation-delay: .3s; } + +@keyframes fieldReveal { + from { + opacity: 0.0001; + transform: translateY(4rem); + } +} + +.login-error-message { + color: var(--color-negative); + font-size: var(--font-size-base); + padding: 1.3rem calc(var(--widget-content-horizontal-padding) + 1px); + position: relative; + margin-top: 2rem; + animation: errorMessageEntrance 0.4s backwards cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes errorMessageEntrance { + from { + opacity: 0; + transform: scale(1.1); + } +} + +.login-error-message:empty { + display: none; +} + +.login-error-message::before { + content: ""; + position: absolute; + inset: 0; + border-radius: var(--border-radius); + background: var(--color-negative); + opacity: 0.05; + z-index: -1; +} + +.footer { + animation-delay: .4s; + animation-duration: 1s; +} + +.toggle-password-visibility { + background: none; + border: none; + cursor: pointer; +} diff --git a/internal/glance/static/css/main.css b/internal/glance/static/css/main.css new file mode 100644 index 0000000..8d0d779 --- /dev/null +++ b/internal/glance/static/css/main.css @@ -0,0 +1,66 @@ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('../fonts/JetBrainsMono-Regular.woff2') format('woff2'); +} + +:root { + font-size: 10px; + + --scheme: ; + --bgh: 240; + --bgs: 8%; + --bgl: 9%; + --bghs: var(--bgh), var(--bgs); + --cm: 1; + --tsm: 1; + + --widget-gap: 23px; + --widget-content-vertical-padding: 15px; + --widget-content-horizontal-padding: 17px; + --widget-content-padding: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding); + --content-bounds-padding: 15px; + --border-radius: 5px; + --mobile-navigation-height: 50px; + + --color-primary: hsl(43, 50%, 70%); + --color-positive: var(--color-primary); + --color-negative: hsl(0, 70%, 70%); + --color-background: hsl(var(--bghs), var(--bgl)); + --color-widget-background-hsl-values: var(--bghs), calc(var(--bgl) + 1%); + --color-widget-background: hsl(var(--color-widget-background-hsl-values)); + --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)) + 26% * var(--cm)))); + --color-vertical-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 28% * 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-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%)); + --color-text-paragraph: hsl(var(--ths), calc(var(--scheme) var(--cm) * 73%)); + --color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%)); + --color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%)); + --color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%)); + + --font-size-h1: 1.7rem; + --font-size-h2: 1.6rem; + --font-size-h3: 1.5rem; + --font-size-h4: 1.4rem; + --font-size-base: 1.3rem; + --font-size-h5: 1.2rem; + --font-size-h6: 1.1rem; +} + +/* Do not change the order of the below imports unless you know what you're doing */ + +@import "site.css"; +@import "widgets.css"; +@import "popover.css"; +@import "utils.css"; +@import "mobile.css"; diff --git a/internal/glance/static/css/mobile.css b/internal/glance/static/css/mobile.css new file mode 100644 index 0000000..2b71a24 --- /dev/null +++ b/internal/glance/static/css/mobile.css @@ -0,0 +1,235 @@ +@media (max-width: 1190px) { + .header-container { + display: none; + } + + .page-column-small .size-title-dynamic { + font-size: var(--font-size-h3); + } + + .page-column-small { + width: 100%; + flex-shrink: 1; + } + + .page-column { + display: none; + animation: columnEntrance .0s cubic-bezier(0.25, 1, 0.5, 1) backwards; + } + + .page-columns-transitioned .page-column { + animation-duration: .3s; + } + + @keyframes columnEntrance { + from { + opacity: 0; + transform: scaleX(0.95); + } + } + + .mobile-navigation-offset { + height: var(--mobile-navigation-height); + flex-shrink: 0; + } + + .mobile-navigation { + display: block; + position: fixed; + bottom: 0; + transform: translateY(calc(100% - var(--mobile-navigation-height))); + left: var(--content-bounds-padding); + right: var(--content-bounds-padding); + z-index: 11; + background-color: var(--color-widget-background); + border: 1px solid var(--color-widget-content-border); + border-bottom: 0; + border-radius: var(--border-radius) var(--border-radius) 0 0; + transition: transform .3s; + } + + .mobile-navigation-actions > * { + padding-block: 1.1rem; + padding-inline: var(--content-bounds-padding); + cursor: pointer; + transition: background-color 50ms; + } + + .mobile-navigation-actions > *:active { + background-color: var(--color-widget-background-highlight); + } + + .mobile-navigation:has(.mobile-navigation-page-links-input:checked) .hamburger-icon { + --spacing: 7px; + color: var(--color-primary); + height: 2px; + } + + .mobile-navigation:has(.mobile-navigation-page-links-input:checked) { + transform: translateY(0); + } + + .mobile-navigation-page-links { + border-top: 1px solid var(--color-widget-content-border); + border-bottom: 1px solid var(--color-widget-content-border); + padding: 20px var(--content-bounds-padding); + display: flex; + align-items: center; + overflow-x: auto; + scrollbar-width: thin; + gap: 2.5rem; + } + + .mobile-navigation-icons { + display: flex; + justify-content: space-around; + align-items: center; + } + + body:has(.mobile-navigation-input[value="0"]:checked) .page-columns > :nth-child(1), + body:has(.mobile-navigation-input[value="1"]:checked) .page-columns > :nth-child(2), + body:has(.mobile-navigation-input[value="2"]:checked) .page-columns > :nth-child(3) { + display: block; + } + + .mobile-navigation-label { + display: flex; + flex: 1; + max-width: 50px; + height: var(--mobile-navigation-height); + justify-content: center; + align-items: center; + cursor: pointer; + font-size: 15px; + line-height: var(--mobile-navigation-height); + } + + .mobile-navigation-pill { + display: block; + background: var(--color-text-base); + height: 10px; + width: 10px; + border-radius: 10px; + transition: width .3s, background-color .3s; + } + + .mobile-navigation-label:hover > .mobile-navigation-pill { + background-color: var(--color-text-highlight); + } + + .mobile-navigation-label:hover { + color: var(--color-text-highlight); + } + + .mobile-navigation-input:checked + .mobile-navigation-pill { + background: var(--color-primary); + width: 30px; + } + + .mobile-navigation-input, .mobile-navigation-page-links-input { + display: none; + } + + .hamburger-icon { + --spacing: 4px; + width: 1em; + height: 1px; + background-color: currentColor; + transition: color .3s, box-shadow .3s; + box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor; + } + + .expand-toggle-button.container-expanded { + bottom: var(--mobile-navigation-height); + } + + .cards-grid + .expand-toggle-button.container-expanded { + /* hides content that peeks through the rounded borders of the mobile navigation */ + box-shadow: 0 var(--border-radius) 0 0 var(--color-background); + } + + .weather-column-rain::before { + background-size: 7px 7px; + } + + .ios .search-input { + /* so that iOS Safari does not zoom the page when the input is focused */ + font-size: 16px; + } +} + +@media (max-width: 1190px) and (display-mode: standalone) { + :root { + --safe-area-inset-bottom: env(safe-area-inset-bottom, 0); + } + + .ios .body-content { + height: 100dvh; + } + + .expand-toggle-button.container-expanded { + bottom: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom)); + } + + .mobile-navigation { + transform: translateY(calc(100% - var(--mobile-navigation-height) - var(--safe-area-inset-bottom))); + padding-bottom: var(--safe-area-inset-bottom); + } + + .mobile-navigation-icons { + padding-bottom: var(--safe-area-inset-bottom); + transition: padding-bottom .3s; + } + + .mobile-navigation-offset { + height: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom)); + } + + .mobile-navigation-icons:has(.mobile-navigation-page-links-input:checked) { + padding-bottom: 0; + } +} + +@media (display-mode: standalone) { + body { + padding-top: env(safe-area-inset-top, 0); + } +} + +@media (max-width: 550px) { + :root { + font-size: 9.4px; + --widget-gap: 15px; + --widget-content-vertical-padding: 10px; + --widget-content-horizontal-padding: 10px; + --content-bounds-padding: 10px; + } + + .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; } + + .row-reverse-on-mobile { + flex-direction: row-reverse; + } + + .hide-on-mobile, .thumbnail-container:has(> .hide-on-mobile) { + display: none + } + + .mobile-reachability-header { + display: block; + font-size: 3rem; + padding: 10vh 1rem; + text-align: center; + color: var(--color-text-highlight); + animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards; + } + + .rss-detailed-thumbnail > * { + height: 6rem; + } + + .rss-detailed-description { + line-clamp: 3; + -webkit-line-clamp: 3; + } +} diff --git a/internal/glance/static/css/popover.css b/internal/glance/static/css/popover.css new file mode 100644 index 0000000..232f29c --- /dev/null +++ b/internal/glance/static/css/popover.css @@ -0,0 +1,65 @@ +.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)); + } +} diff --git a/internal/glance/static/css/site.css b/internal/glance/static/css/site.css new file mode 100644 index 0000000..fbf3c8a --- /dev/null +++ b/internal/glance/static/css/site.css @@ -0,0 +1,398 @@ +:root[data-scheme=light] { + --scheme: 100% -; +} + +.page { + height: 100%; + padding-block: var(--widget-gap); +} + +.page-content, .page.content-ready .page-loading-container { + display: none; +} + +.page.content-ready > .page-content { + display: block; + animation: pageContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards; +} + +.page-column-small .size-title-dynamic { + font-size: var(--font-size-h4); +} + +.page-column-full .size-title-dynamic { + font-size: var(--font-size-h3); +} + +pre { + font: inherit; +} + + +input[type="text"] { + width: 100%; + border: 0; + background: none; + font: inherit; + color: inherit; +} + +button { + font: inherit; + border: 0; + cursor: pointer; + background: none; + color: inherit; +} + +::selection { + background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%))); + color: var(--color-text-highlight); +} + +::-webkit-scrollbar-thumb { + background: var(--color-text-subdue); + border-radius: var(--border-radius); +} + +::-webkit-scrollbar { + background: var(--color-background); + height: 5px; + width: 10px; +} + +*:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 0.1rem; + border-radius: var(--border-radius); +} + +*, *::before, *::after { + box-sizing: border-box; +} + +* { + padding: 0; + margin: 0; +} + +hr { + border: 0; + height: 1px; + background-color: var(--color-separator); +} + +img, svg { + display: block; + max-width: 100%; +} + +img[loading=lazy].loaded:not(.finished-transition) { + transition: opacity .4s; +} + +img[loading=lazy].cached:not(.finished-transition) { + transition: none; +} + +img[loading=lazy]:not(.loaded, .cached) { + opacity: 0; +} + +html { + scrollbar-color: var(--color-text-subdue) transparent; + scroll-behavior: smooth; +} + +html, body, .body-content { + height: 100%; +} + +h1, h2, h3, h4, h5 { + font: inherit; +} + +a { + text-decoration: none; + color: inherit; + overflow-wrap: break-word; +} + +ul { + list-style: none; +} + +body { + font-size: 1.3rem; + font-family: 'JetBrains Mono', monospace; + font-variant-ligatures: none; + line-height: 1.6; + color: var(--color-text-base); + background-color: var(--color-background); + overflow-y: scroll; +} + +.page-column-small { + width: 300px; + flex-shrink: 0; +} + +.page-column-full { + width: 100%; + min-width: 0; +} + +.page-columns { + display: flex; + gap: var(--widget-gap); +} + +@keyframes pageContentEntrance { + from { + opacity: 0; + transform: translateY(10px); + } +} + +.page-loading-container { + 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 { + /* Using 0.001 instead of 0 fixes a random 1s freeze on Chrome on page load when all */ + /* elements have opacity 0 and are animated in. I don't want to be a web dev anymore. */ + opacity: 0.001; + } +} + +.loading-icon { + min-width: 1.5em; + width: 1.5em; + height: 1.5em; + border: 0.25em solid hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 12%))); + border-top-color: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 40%))); + border-radius: 50%; + animation: loadingIconSpin 800ms infinite linear; +} + +@keyframes loadingIconSpin { + to { + transform: rotate(360deg); + } +} + +.notice-icon { + width: 0.7rem; + height: 0.7rem; + border-radius: 50%; +} + +.notice-icon-major { + background: var(--color-negative); +} + +.notice-icon-minor { + border: 1px solid var(--color-negative); +} + +kbd { + font: inherit; + padding: 0.1rem 0.8rem; + border-radius: var(--border-radius); + border: 2px solid var(--color-widget-background-highlight); + box-shadow: 0 2px 0 var(--color-widget-background-highlight); + user-select: none; + transition: transform .1s, box-shadow .1s; + font-size: var(--font-size-h5); + cursor: pointer; +} + +kbd:active { + transform: translateY(2px); + box-shadow: 0 0 0 0 var(--color-widget-background-highlight); +} + +.content-bounds { + max-width: 1600px; + width: 100%; + margin-inline: auto; + padding: 0 var(--content-bounds-padding); +} + +.content-bounds-wide { + max-width: 1920px; +} + +.content-bounds-slim { + max-width: 1100px; +} + +.page.center-vertically { + display: flex; + justify-content: center; + flex-direction: column; +} + +.header-container { + margin-top: calc(var(--widget-gap) / 2); + --header-height: 45px; + --header-items-gap: 2.5rem; +} + +.header { + display: flex; + height: var(--header-height); + gap: var(--header-items-gap); +} + +.logo { + height: 100%; + flex-shrink: 0; + line-height: var(--header-height); + font-size: 2rem; + color: var(--color-text-highlight); + border-right: 1px solid var(--color-widget-content-border); + padding-right: var(--widget-content-horizontal-padding); +} + +.logo:has(img, svg) { + display: flex; + align-items: center; +} + +.logo img { + max-height: 2.7rem; +} + +.nav { + overflow-x: auto; + min-width: 0; + height: 100%; + gap: var(--header-items-gap); +} + +.nav .nav-item { + line-height: var(--header-height); +} + +.footer { + padding-bottom: calc(var(--widget-gap) * 1.5); + padding-top: calc(var(--widget-gap) / 2); + animation: loadingContainerEntrance 200ms backwards; + animation-delay: 150ms; +} + +.mobile-navigation, .mobile-reachability-header { + display: none; +} + +.nav-item { + display: block; + height: 100%; + border-bottom: 2px solid transparent; + transition: color .3s, border-color .3s; + font-size: var(--font-size-h3); + flex-shrink: 0; +} + +.nav-item:not(.nav-item-current):hover { + border-bottom-color: var(--color-text-subdue); + color: var(--color-text-highlight); +} + +.nav-item.nav-item-current { + border-bottom-color: var(--color-primary); + color: var(--color-text-highlight); +} + +.logout-button { + width: 2rem; + height: 2rem; + stroke: var(--color-text-subdue); + transition: stroke .2s; +} + +.logout-button:hover, .logout-button:focus { + stroke: var(--color-text-highlight); +} + +.theme-choices { + --presets-per-row: 2; + display: grid; + grid-template-columns: repeat(var(--presets-per-row), 1fr); + align-items: center; + gap: 1.35rem; +} + +.theme-choices:has(> :nth-child(3)) { + --presets-per-row: 3; +} + +.theme-preset { + background-color: var(--color); + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + height: 2rem; + padding-inline: 0.5rem; + border-radius: 0.3rem; + border: none; + cursor: pointer; + position: relative; +} + +.theme-choices .theme-preset::before { + content: ''; + position: absolute; + inset: -.4rem; + border-radius: .7rem; + border: 2px solid transparent; + transition: border-color .3s; +} + +.theme-choices .theme-preset:hover::before { + border-color: var(--color-text-subdue); +} + +.theme-choices .theme-preset.current::before { + border-color: var(--color-text-base); +} + +.theme-preset-light { + gap: 0.3rem; + height: 1.8rem; +} + +.theme-color { + background-color: var(--color); + width: 0.9rem; + height: 0.9rem; + border-radius: 0.2rem; +} + +.theme-preset-light .theme-color { + width: 1rem; + height: 1rem; + border-radius: 0.3rem; +} + +.current-theme-preview { + opacity: 0.4; + transition: opacity .3s; +} + +.theme-picker.popover-active .current-theme-preview, .theme-picker:hover { + opacity: 1; +} diff --git a/internal/glance/static/css/utils.css b/internal/glance/static/css/utils.css new file mode 100644 index 0000000..3bbb48c --- /dev/null +++ b/internal/glance/static/css/utils.css @@ -0,0 +1,637 @@ +.masonry { + display: flex; + gap: var(--widget-gap); +} + +.masonry-column { + flex: 1; + display: flex; + flex-direction: column; +} + +.widget-small-content-bounds { + max-width: 350px; + margin: 0 auto; +} + +.visually-hidden { + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.list-horizontal-text { + display: flex; + list-style: none; + flex-wrap: wrap; + align-items: center; +} + +.list-horizontal-text > *:not(:last-child)::after { + content: '•' / ""; + color: var(--color-text-subdue); + margin: 0 0.4rem; + position: relative; + top: 0.1rem; +} + +.summary { + width: 100%; + cursor: pointer; + word-spacing: -0.18em; + user-select: none; + list-style: none; + position: relative; + display: flex; + z-index: 1; +} + +.summary::-webkit-details-marker { + display: none; +} + +.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; +} + +.details:not([open]) .list-with-transition { + display: none; +} + +.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; +} + +/* TODO: refactor, otherwise I hope I never have to change dynamic columns again */ +.dynamic-columns { + --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); +} + +.dynamic-columns > * { + 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; } +.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; } +.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; } +.dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; } +.dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; } + +@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 (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 { + flex-direction: column; +} + +.cards-horizontal { + --cards-per-row: 6.5; +} + +.cards-horizontal, .cards-vertical { + --cards-gap: calc(var(--widget-content-vertical-padding) * 0.7); + display: flex; + gap: var(--cards-gap); +} + +.card { + display: flex; + flex-direction: column; +} + +.cards-horizontal .card { + flex-shrink: 0; + width: calc(100% / var(--cards-per-row) - var(--cards-gap) * (var(--cards-per-row) - 1) / var(--cards-per-row)); +} + +.cards-grid .card { + min-width: 0; +} + +.cards-horizontal { + overflow-x: auto; + scrollbar-width: thin; + padding-bottom: 1rem; +} + +.cards-grid { + --cards-per-row: 6; + display: grid; + grid-template-columns: repeat(var(--cards-per-row), 1fr); + gap: calc(var(--widget-content-vertical-padding) * 0.7); +} + +@container widget (max-width: 1300px) { .cards-horizontal { --cards-per-row: 5.5; } } +@container widget (max-width: 1100px) { .cards-horizontal { --cards-per-row: 4.5; } } +@container widget (max-width: 850px) { .cards-horizontal { --cards-per-row: 3.5; } } +@container widget (max-width: 750px) { .cards-horizontal { --cards-per-row: 3.5; } } +@container widget (max-width: 650px) { .cards-horizontal { --cards-per-row: 2.5; } } +@container widget (max-width: 450px) { .cards-horizontal { --cards-per-row: 2.3; } } + +@container widget (max-width: 1300px) { .cards-grid { --cards-per-row: 5; } } +@container widget (max-width: 1100px) { .cards-grid { --cards-per-row: 4; } } +@container widget (max-width: 850px) { .cards-grid { --cards-per-row: 3; } } +@container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } } +@container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } } + +.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; + display: -webkit-box; + -webkit-box-orient: vertical; +} + +.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 { + content: '↗' / ""; + margin-left: 0.5em; + display: inline-block; + position: relative; + top: 0.15em; + color: var(--color-text-base); +} + +.visited-indicator.text-truncate { + direction: rtl; + text-align: left; +} + +.visited-indicator:not(:visited)::before, .visited-indicator:not(:visited)::after { + color: var(--color-primary); +} + +.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.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); +} + +.collapsible-container:not(.container-expanded) > .collapsible-item { + display: none; +} + +.collapsible-item { + animation: collapsibleItemReveal .25s backwards; +} + +@keyframes collapsibleItemReveal { + from { + opacity: 0; + transform: translateY(10px); + } +} + +.expand-toggle-button { + font: inherit; + border: 0; + cursor: pointer; + display: block; + width: 100%; + text-align: left; + color: var(--color-text-base); + text-transform: uppercase; + font-size: var(--font-size-h4); + padding: var(--widget-content-vertical-padding) 0; + background: var(--color-widget-background); +} + +.expand-toggle-button.container-expanded { + position: sticky; + /* -1px to hide 1px gap on chrome */ + bottom: -1px; +} + +.expand-toggle-button-icon { + display: inline-block; + margin-left: 1rem; + position: relative; + top: -.2rem; +} + +.expand-toggle-button-icon::before { + content: '' / ""; + font-size: 0.8rem; + transform: rotate(90deg); + line-height: 1; + display: inline-block; + transition: transform 0.3s; +} + +.expand-toggle-button.container-expanded .expand-toggle-button-icon::before { + transform: rotate(-90deg); +} + +.cards-grid.collapsible-container + .expand-toggle-button { + text-align: center; + margin-top: 0.5rem; + background-color: var(--color-background); +} + +.widget-content:has(.expand-toggle-button:last-child) { + padding-bottom: 0; +} + +.carousel-container { + position: relative; +} + +.carousel-container::before, .carousel-container::after { + content: ''; + position: absolute; + width: 2rem; + top: 0; + bottom: 1rem; + z-index: 10; + opacity: 0; + pointer-events: none; + transition-duration: 0.2s; +} + +.carousel-container::before { + background: linear-gradient(to right, var(--color-background), transparent); +} + +.carousel-container::after { + right: 0; + background: linear-gradient(to left, var(--color-background), transparent); +} + +.carousel-container.show-left-cutoff::before, .carousel-container.show-right-cutoff::after { + opacity: 1; +} + +.attachments { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +:root:not([data-scheme=light]) .flat-icon { + filter: invert(1); +} + +.attachments > * { + border-radius: var(--border-radius); + padding: 0.1rem 0.5rem; + font-size: var(--font-size-h6); + background-color: var(--color-separator); +} + +.progress-bar { + border: 1px solid var(--color-progress-border); + border-radius: var(--border-radius); + display: flex; + flex-direction: column; + gap: 2px; + padding: 2px; + height: 1.5rem; + /* naughty, but oh so beautiful */ + margin-inline: -3px; +} + +.progress-bar-combined { + height: 3rem; +} + +.popover-active > .progress-bar { + transition: border-color .3s; + border-color: var(--color-text-subdue); +} + +.progress-value { + --half-border-radius: calc(var(--border-radius) / 2); + border-radius: 0 var(--half-border-radius) var(--half-border-radius) 0; + background: var(--color-progress-value); + width: calc(var(--percent) * 1%); + min-width: 1px; + flex: 1; +} + +.progress-value:first-child { + border-top-left-radius: var(--half-border-radius); +} + +.progress-value:last-child { + border-bottom-left-radius: var(--half-border-radius); +} + +.progress-value-notice { + background: linear-gradient(to right, var(--color-progress-value) 65%, var(--color-negative)); +} + +.value-separator { + min-width: 2rem; + margin-inline: 0.8rem; + flex: 1; + height: calc(1em * 1.1); + border-bottom: 1px dotted var(--color-text-subdue); +} + +.thumbnail { + filter: grayscale(0.2) contrast(0.9); + opacity: 0.8; + transition: filter 0.2s, opacity .2s; +} + +.thumbnail-container { + flex-shrink: 0; + border: 1px solid var(--color-separator); + border-radius: var(--border-radius); +} + +.thumbnail-container > * { + border-radius: var(--border-radius); + object-fit: cover; +} + +.thumbnail-parent:hover .thumbnail { + opacity: 1; + filter: none; +} + + +.hide-scrollbars { + scrollbar-width: none; +} + +/* Hide on Safari and Chrome */ +.hide-scrollbars::-webkit-scrollbar { + display: none; +} + +.ui-icon { + width: 2.3rem; + height: 2.3rem; + display: block; + flex-shrink: 0; +} + +.size-h1 { font-size: var(--font-size-h1); } +.size-h2 { font-size: var(--font-size-h2); } +.size-h3 { font-size: var(--font-size-h3); } +.size-h4 { font-size: var(--font-size-h4); } +.size-base { font-size: var(--font-size-base); } +.size-h5 { font-size: var(--font-size-h5); } +.size-h6 { font-size: var(--font-size-h6); } + +.color-highlight { color: var(--color-text-highlight); } +.color-paragraph { color: var(--color-text-paragraph); } +.color-base { color: var(--color-text-base); } +.color-subdue { color: var(--color-text-subdue); } +.color-negative { color: var(--color-negative); } +.color-positive { color: var(--color-positive); } +.color-primary { color: var(--color-primary); } + +.color-primary-if-not-visited:not(:visited) { + color: var(--color-primary); +} + +.drag-and-drop-container { + position: relative; +} + +.drag-and-drop-decoy { + outline: 1px dashed var(--color-primary); + opacity: 0.25; + border-radius: var(--border-radius); +} + +.drag-and-drop-draggable { + position: absolute; + cursor: grabbing !important; +} + +.drag-and-drop-draggable:empty { + display: none; +} + +.drag-and-drop-draggable * { + cursor: grabbing !important; +} + +.auto-scaling-textarea-container { + position: relative; +} + +.auto-scaling-textarea { + position: absolute; + inset: 0; + background: none; + border: none; + font: inherit; + resize: none; + color: inherit; + overflow: hidden; +} + +.auto-scaling-textarea:focus { + outline: none; +} + +.auto-scaling-textarea-mimic { + white-space: pre-wrap; + min-height: 1lh; + user-select: none; + word-wrap: break-word; + font: inherit; + visibility: hidden; +} + +.cursor-help { cursor: help; } +.rounded { border-radius: var(--border-radius); } +.break-all { word-break: break-all; } +.text-left { text-align: left; } +.text-right { text-align: right; } +.text-center { text-align: center; } +.text-elevate { margin-top: -0.2em; } +.text-compact { word-spacing: -0.18em; } +.text-very-compact { word-spacing: -0.35em; } +.rtl { direction: rtl; } +.shrink { flex-shrink: 1; } +.shrink-0 { flex-shrink: 0; } +.min-width-0 { min-width: 0; } +.max-width-100 { max-width: 100%; } +.block { display: block; } +.inline-block { display: inline-block; } +.overflow-hidden { overflow: hidden; } +.relative { position: relative; } +.flex { display: flex; } +.flex-1 { flex: 1; } +.flex-wrap { flex-wrap: wrap; } +.flex-nowrap { flex-wrap: nowrap; } +.justify-between { justify-content: space-between; } +.justify-stretch { justify-content: stretch; } +.justify-evenly { justify-content: space-evenly; } +.justify-center { justify-content: center; } +.justify-end { justify-content: end; } +.uppercase { text-transform: uppercase; } +.grow { flex-grow: 1; } +.flex-column { flex-direction: column; } +.items-center { align-items: center; } +.self-center { align-self: center; } +.items-start { align-items: start; } +.items-end { align-items: end; } +.gap-5 { gap: 0.5rem; } +.gap-7 { gap: 0.7rem; } +.gap-10 { gap: 1rem; } +.gap-12 { gap: 1.2rem; } +.gap-15 { gap: 1.5rem; } +.gap-20 { gap: 2rem; } +.gap-25 { gap: 2.5rem; } +.gap-35 { gap: 3.5rem; } +.gap-45 { gap: 4.5rem; } +.gap-55 { gap: 5.5rem; } +.margin-left-auto { margin-left: auto; } +.margin-top-3 { margin-top: 0.3rem; } +.margin-top-5 { margin-top: 0.5rem; } +.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; } +.margin-block-8 { margin-block: 0.8rem; } +.margin-block-10 { margin-block: 1rem; } +.margin-block-15 { margin-block: 1.5rem; } +.margin-bottom-3 { margin-bottom: 0.3rem; } +.margin-bottom-5 { margin-bottom: 0.5rem; } +.margin-bottom-7 { margin-bottom: 0.7rem; } +.margin-bottom-10 { margin-bottom: 1rem; } +.margin-bottom-15 { margin-bottom: 1.5rem; } +.margin-bottom-auto { margin-bottom: auto; } +.margin-bottom-widget { margin-bottom: var(--widget-content-vertical-padding); } +.padding-widget { padding: var(--widget-content-padding); } +.padding-block-widget { padding-block: var(--widget-content-vertical-padding); } +.padding-inline-widget { padding-inline: var(--widget-content-horizontal-padding); } +.pointer-events-none { pointer-events: none; } +.select-none { user-select: none; } +.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-8 { --list-half-gap: 0.4rem; } +.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; } + +@media (max-width: 1190px) { + .size-base-on-mobile { font-size: var(--font-size-base); } +} diff --git a/internal/glance/static/css/widget-bookmarks.css b/internal/glance/static/css/widget-bookmarks.css new file mode 100644 index 0000000..7f2dabd --- /dev/null +++ b/internal/glance/static/css/widget-bookmarks.css @@ -0,0 +1,31 @@ +.bookmarks-group { + --bookmarks-group-color: var(--color-primary); +} + +.bookmarks-group-title { + color: var(--bookmarks-group-color); +} + +.bookmarks-link:not(.bookmarks-link-no-arrow)::after { + content: '↗' / ""; + margin-left: 0.5em; + display: inline-block; + position: relative; + top: 0.15em; + color: var(--bookmarks-group-color); +} + +.bookmarks-icon-container { + margin-block: 0.1rem; + background-color: var(--color-widget-background-highlight); + border-radius: var(--border-radius); + padding: 0.5rem; + opacity: 0.7; + flex-shrink: 0; +} + +.bookmarks-icon { + width: 20px; + height: 20px; + opacity: 0.8; +} diff --git a/internal/glance/static/css/widget-calendar.css b/internal/glance/static/css/widget-calendar.css new file mode 100644 index 0000000..f9b0d9d --- /dev/null +++ b/internal/glance/static/css/widget-calendar.css @@ -0,0 +1,71 @@ +.old-calendar-day { + width: calc(100% / 7); + text-align: center; + padding: 0.6rem 0; +} + +.old-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); +} + +.calendar-dates { + text-align: center; + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.calendar-date { + padding: 0.4rem 0; + color: var(--color-text-base); + position: relative; + border-radius: var(--border-radius); + background: none; + border: none; + font: inherit; +} + +.calendar-current-date { + border-radius: var(--border-radius); + background-color: var(--color-popover-border); + color: var(--color-text-highlight); +} + +.calendar-spillover-date { + color: var(--color-text-subdue); +} + +.calendar-header-button { + position: relative; + cursor: pointer; + width: 2rem; + height: 2rem; + z-index: 1; + background: none; + border: none; +} + +.calendar-header-button::before { + content: ''; + position: absolute; + inset: -0.2rem; + border-radius: var(--border-radius); + background-color: var(--color-text-subdue); + opacity: 0; + transition: opacity 0.2s; + z-index: -1; +} + +.calendar-header-button:hover::before { + opacity: 0.4; +} + +.calendar-undo-button { + display: inline-block; + vertical-align: text-top; + width: 2rem; + height: 2rem; + margin-left: 0.7rem; +} diff --git a/internal/glance/static/css/widget-clock.css b/internal/glance/static/css/widget-clock.css new file mode 100644 index 0000000..47a1a2f --- /dev/null +++ b/internal/glance/static/css/widget-clock.css @@ -0,0 +1,7 @@ +.clock-time { + min-width: 8ch; +} + +.clock-time span { + color: var(--color-text-highlight); +} diff --git a/internal/glance/static/css/widget-dns-stats.css b/internal/glance/static/css/widget-dns-stats.css new file mode 100644 index 0000000..15a1d42 --- /dev/null +++ b/internal/glance/static/css/widget-dns-stats.css @@ -0,0 +1,120 @@ +.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; + 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; + 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-vertical-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); + flex-basis: calc(var(--percent) - 1px); +} + +.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; +} diff --git a/internal/glance/static/css/widget-docker-containers.css b/internal/glance/static/css/widget-docker-containers.css new file mode 100644 index 0000000..ae08788 --- /dev/null +++ b/internal/glance/static/css/widget-docker-containers.css @@ -0,0 +1,26 @@ +.docker-container-icon { + display: block; + filter: grayscale(0.4); + object-fit: contain; + aspect-ratio: 1 / 1; + width: 2.7rem; + opacity: 0.8; + transition: filter 0.3s, opacity 0.3s; +} + +.docker-container-icon.flat-icon { + opacity: 0.7; +} + +.docker-container:hover .docker-container-icon { + opacity: 1; +} + +.docker-container:hover .docker-container-icon:not(.flat-icon) { + filter: grayscale(0); +} + +.docker-container-status-icon { + width: 2rem; + height: 2rem; +} diff --git a/internal/glance/static/css/widget-group.css b/internal/glance/static/css/widget-group.css new file mode 100644 index 0000000..378853b --- /dev/null +++ b/internal/glance/static/css/widget-group.css @@ -0,0 +1,49 @@ +.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; +} diff --git a/internal/glance/static/css/widget-markets.css b/internal/glance/static/css/widget-markets.css new file mode 100644 index 0000000..f670a25 --- /dev/null +++ b/internal/glance/static/css/widget-markets.css @@ -0,0 +1,13 @@ +.market-chart { + margin-left: auto; + width: 6.5rem; + flex-shrink: 0; +} + +.market-chart svg { + width: 100%; +} + +.market-values { + min-width: 8rem; +} diff --git a/internal/glance/static/css/widget-monitor.css b/internal/glance/static/css/widget-monitor.css new file mode 100644 index 0000000..8bc629b --- /dev/null +++ b/internal/glance/static/css/widget-monitor.css @@ -0,0 +1,36 @@ +.monitor-site-icon { + display: block; + opacity: 0.8; + filter: grayscale(0.4); + object-fit: contain; + aspect-ratio: 1 / 1; + width: 3.2rem; + position: relative; + top: -0.1rem; + transition: filter 0.3s, opacity 0.3s; +} + +.monitor-site-icon.flat-icon { + opacity: 0.7; +} + +.monitor-site:hover .monitor-site-icon { + opacity: 1; +} + +.monitor-site:hover .monitor-site-icon:not(.flat-icon) { + filter: grayscale(0); +} + +.monitor-site-status-icon { + flex-shrink: 0; + margin-left: auto; + width: 2rem; + height: 2rem; +} + +.monitor-site-status-icon-compact { + width: 1.8rem; + height: 1.8rem; + flex-shrink: 0; +} diff --git a/internal/glance/static/css/widget-reddit.css b/internal/glance/static/css/widget-reddit.css new file mode 100644 index 0000000..5b62e0e --- /dev/null +++ b/internal/glance/static/css/widget-reddit.css @@ -0,0 +1,22 @@ +.reddit-card-thumbnail { + width: 100%; + height: 100%; + object-fit: cover; + object-position: 0% 20%; + opacity: 0.15; + filter: blur(1px); +} + +.reddit-card-thumbnail-container { + position: absolute; + inset: 0; + overflow: hidden; + border-radius: var(--border-radius); +} + +.reddit-card-thumbnail-container::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(0deg, var(--color-widget-background) 10%, transparent); +} diff --git a/internal/glance/static/css/widget-releases.css b/internal/glance/static/css/widget-releases.css new file mode 100644 index 0000000..7def4cf --- /dev/null +++ b/internal/glance/static/css/widget-releases.css @@ -0,0 +1,6 @@ +.release-source-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + opacity: 0.4; +} diff --git a/internal/glance/static/css/widget-rss.css b/internal/glance/static/css/widget-rss.css new file mode 100644 index 0000000..34b7114 --- /dev/null +++ b/internal/glance/static/css/widget-rss.css @@ -0,0 +1,56 @@ +.rss-card-image { + height: var(--rss-thumbnail-height, 10rem); + object-fit: cover; + border-radius: var(--border-radius) var(--border-radius) 0 0; +} + +.rss-card-2 { + position: relative; + height: var(--rss-card-height, 27rem); + overflow: hidden; +} + +.rss-card-2::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: linear-gradient( + 0deg, + var(--color-widget-background), + hsla(var(--color-widget-background-hsl-values), 0.8) 6rem, transparent 14rem + ); + z-index: 2; +} + +.rss-card-2-image { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; + /* +1px is required to fix some weird graphical bug where the image overflows on the bottom in firefox */ + border-radius: calc(var(--border-radius) + 1px); + opacity: 0.9; + z-index: 1; +} + +.rss-card-2-content { + position: absolute; + inset-inline: 0; + bottom: var(--widget-content-vertical-padding); + z-index: 3; +} + +.rss-detailed-description { + max-width: 55rem; + color: var(--color-text-base-muted); +} + +.rss-detailed-thumbnail { + margin-top: 0.3rem; +} + +.rss-detailed-thumbnail > * { + aspect-ratio: 3 / 2; + height: 8.7rem; +} diff --git a/internal/glance/static/css/widget-search.css b/internal/glance/static/css/widget-search.css new file mode 100644 index 0000000..ebf5cbb --- /dev/null +++ b/internal/glance/static/css/widget-search.css @@ -0,0 +1,79 @@ +.search-icon { + width: 2.3rem; +} + +.search-icon-container { + position: relative; + flex-shrink: 0; +} + +/* gives a wider hit area for the 3 people that will notice the animation : ) */ +.search-icon-container::before { + content: ''; + position: absolute; + inset: -1rem; +} + +.search-icon-container:hover > .search-icon { + animation: searchIconHover 2.9s forwards; +} + +@keyframes searchIconHover { + 0%, 39% { translate: 0 0; } + 20% { scale: 1.3; } + 40% { scale: 1; } + 50% { translate: -30% 30%; } + 70% { translate: 30% -30%; } + 90% { translate: -30% -30%; } + 100% { translate: 0 0; } +} + +.search { + transition: border-color .2s; + position: relative; +} + +.search:hover { + border-color: var(--color-text-subdue); +} + +.search:focus-within { + border-color: var(--color-primary); +} + +.search-input { + border: 0; + background: none; + width: 100%; + height: 6rem; + font: inherit; + outline: none; + color: var(--color-text-highlight); +} + +.search-input::placeholder { + color: var(--color-text-base-muted); + opacity: 1; +} + +.search-bangs { display: none; } + +.search-bang { + border-radius: calc(var(--border-radius) * 2); + background: var(--color-widget-background-highlight); + padding: 0.3rem 1rem; + flex-shrink: 0; + font-size: var(--font-size-h5); + animation: searchBangsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards; +} + +@keyframes searchBangsEntrance { + 0% { + opacity: 0; + transform: translateX(-10px); + } +} + +.search-bang:empty { + display: none; +} diff --git a/internal/glance/static/css/widget-server-stats.css b/internal/glance/static/css/widget-server-stats.css new file mode 100644 index 0000000..11f9ab5 --- /dev/null +++ b/internal/glance/static/css/widget-server-stats.css @@ -0,0 +1,81 @@ +.widget-type-server-info { + position: relative; +} + +.server + .server { + margin-top: 3rem; +} + +.server { + gap: 1rem; + display: flex; + flex-direction: column; +} + +.server-info { + align-items: center; + display: flex; + justify-content: space-between; + gap: 1.5rem; + flex-shrink: 1; + min-width: 0; +} + +.server-details { + min-width: 0; +} + +.server-icon { + height: 3rem; + width: 3rem; +} + +.server-spicy-cpu-icon { + height: 1em; + align-self: center; + margin-left: 0.4em; + margin-bottom: 0.2rem; +} + +.server-stats { + display: flex; + gap: 1.5rem; + margin-top: 0.5rem; +} + +.server-stat-unavailable { + opacity: 0.5; +} + +@container widget (min-width: 650px) { + .server { + gap: 2rem; + flex-direction: row; + align-items: center; + } + + .server + .server { + margin-top: 1rem; + } + + .server-info { + flex-direction: row-reverse; + justify-content: unset; + margin-right: auto; + z-index: 1; + } + + .server-stats { + flex-direction: row; + justify-content: right; + min-width: 450px; + margin-top: 0; + gap: 2rem; + padding-bottom: 0.8rem; + z-index: 1; + } + + .server-stats > * { + max-width: 200px; + } +} diff --git a/internal/glance/static/css/widget-todo.css b/internal/glance/static/css/widget-todo.css new file mode 100644 index 0000000..2466273 --- /dev/null +++ b/internal/glance/static/css/widget-todo.css @@ -0,0 +1,129 @@ +.todo-widget { + padding-top: 4rem; +} + +.todo-plus-icon { + --icon-color: var(--color-text-subdue); + position: relative; + width: 1.4rem; + height: 1.4rem; +} + +.todo-plus-icon::before, .todo-plus-icon::after { + content: ""; + position: absolute; + background-color: var(--icon-color); + transition: background-color .2s; +} + +.todo-plus-icon::before { + width: 2px; + inset-block: 0.2rem; + left: 50%; + transform: translateX(-50%); +} + +.todo-plus-icon::after { + height: 2px; + inset-inline: 0.2rem; + top: 50%; + transform: translateY(-50%); +} + +.todo-input textarea::placeholder { + color: var(--color-text-base-muted); +} + +.todo-input { + position: relative; + color: var(--color-text-highlight); +} + +.todo-input:focus-within .todo-plus-icon { + --icon-color: var(--color-text-base); +} + +.todo-item { + transform-origin: center; + padding: 0.5rem 0; +} + +.todo-item-checkbox { + -webkit-appearance: none; + appearance: none; + border: 2px solid var(--color-text-subdue); + width: 1.4rem; + height: 1.4rem; + position: relative; + cursor: pointer; + border-radius: 0.3rem; + transition: border-color .2s; +} + +.todo-item-checkbox::before { + content: ""; + inset: -1rem; + position: absolute; +} + +.todo-item-checkbox::after { + content: ''; + position: absolute; + inset: 0.3rem; + border-radius: 0.1rem; + opacity: 0; + transition: opacity .2s; +} + +.todo-item-checkbox:checked::after { + background: var(--color-primary); + opacity: 1; +} + +.todo-item-checkbox:focus-visible { + outline: none; + border-color: var(--color-primary); +} + +.todo-item-text { + color: var(--color-text-base); + transition: color .35s; +} + +.todo-item-text:focus { + color: var(--color-text-highlight); +} + +.todo-item-drag-handle { + position: absolute; + top: -0.5rem; + inset-inline: 0; + height: 1rem; + cursor: grab; +} + +.todo-item.is-being-dragged .todo-item-drag-handle { + height: 3rem; + top: -1.5rem; +} + +.todo-item:has(.todo-item-checkbox:checked) .todo-item-text { + text-decoration: line-through; + color: var(--color-text-subdue); +} + +.todo-item-delete { + width: 1.5rem; + height: 1.5rem; + opacity: 0; + transition: opacity .2s; + outline-offset: .5rem; +} + +.todo-item:hover .todo-item-delete, .todo-item:focus-within .todo-item-delete { + opacity: 1; +} + +.todo-item.is-being-dragged .todo-item-delete { + opacity: 0; +} diff --git a/internal/glance/static/css/widget-twitch.css b/internal/glance/static/css/widget-twitch.css new file mode 100644 index 0000000..d109756 --- /dev/null +++ b/internal/glance/static/css/widget-twitch.css @@ -0,0 +1,47 @@ +.twitch-category-thumbnail { + width: 5rem; + aspect-ratio: 3 / 4; + border-radius: var(--border-radius); +} + +.twitch-channel-avatar { + aspect-ratio: 1; + border-radius: 50%; +} + +.twitch-channel-avatar-container { + width: 4.4rem; + height: 4.4rem; + border: 2px solid var(--color-text-subdue); + padding: 2px; + border-radius: 50%; + position: relative; + flex-shrink: 0; +} + +.twitch-channel-live .twitch-channel-avatar-container { + border: 2px solid var(--color-positive); + margin-bottom: 1rem; +} + +.twitch-channel-live .twitch-channel-avatar-container::after { + content: 'LIVE'; + position: absolute; + background: var(--color-positive); + color: var(--color-widget-background); + font-size: var(--font-size-h6); + left: 50%; + bottom: -35%; + border-radius: var(--border-radius); + padding-inline: 0.3rem; + transform: translate(-50%); + border: 2px solid var(--color-widget-background); +} + +.twitch-stream-preview { + max-width: 100%; + width: 400px; + aspect-ratio: 16 / 9; + border-radius: var(--border-radius); + object-fit: cover; +} diff --git a/internal/glance/static/css/widget-videos.css b/internal/glance/static/css/widget-videos.css new file mode 100644 index 0000000..8d07801 --- /dev/null +++ b/internal/glance/static/css/widget-videos.css @@ -0,0 +1,13 @@ +.video-thumbnail { + width: 100%; + aspect-ratio: 16 / 8.9; + object-fit: cover; + border-radius: var(--border-radius) var(--border-radius) 0 0; +} + +.video-horizontal-list-thumbnail { + height: 4rem; + aspect-ratio: 16 / 8.9; + object-fit: cover; + border-radius: var(--border-radius); +} diff --git a/internal/glance/static/css/widget-weather.css b/internal/glance/static/css/widget-weather.css new file mode 100644 index 0000000..84d50ba --- /dev/null +++ b/internal/glance/static/css/widget-weather.css @@ -0,0 +1,139 @@ +.weather-column { + position: relative; + display: flex; + align-items: center; + justify-content: end; + flex-direction: column; + width: calc(100% / 12); + padding-top: 3px; +} + +.weather-column-value, .weather-columns:hover .weather-column-value { + font-size: 13px; + color: var(--color-text-highlight); + letter-spacing: -0.1rem; + margin-right: 0.1rem; + position: relative; + margin-bottom: 0.3rem; + opacity: 0; + transform: translateY(0.5rem); + transition: opacity .2s, transform .2s; + user-select: none; +} + +.weather-column-current .weather-column-value, .weather-column:hover .weather-column-value { + opacity: 1; + transform: translateY(0); +} + +.weather-column-value::after { + position: absolute; + content: '°'; + left: 100%; + color: var(--color-text-subdue); +} + +.weather-column-value.weather-column-value-negative::before { + position: absolute; + content: '-'; + right: 100%; +} + +.weather-bar, .weather-columns:hover .weather-bar { + height: calc(20px + var(--weather-bar-height) * 40px); + width: 6px; + background-color: hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 18%))); + border: 1px solid hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 24%))); + border-bottom: 0; + border-radius: 6px 6px 0 0; + mask-image: linear-gradient(0deg, transparent 0, #000 10px); + -webkit-mask-image: linear-gradient(0deg, transparent 0, #000 10px); + transition: background-color .2s, border-color .2s, width .2s; +} + +.weather-column-current .weather-bar, .weather-column:hover .weather-bar { + width: 10px; + background-color: hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 40%))); + border: 1px solid hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 50%))); +} + +.weather-column-rain { + position: absolute; + inset: 0; + bottom: 20%; + overflow: hidden; + mask-image: linear-gradient(0deg, transparent 40%, #000); + -webkit-mask-image: linear-gradient(0deg, transparent 40%, #000); +} + +.weather-column-rain::before { + content: ''; + position: absolute; + /* TODO: figure out a way to make it look continuous between columns, right now */ + /* depending on the width of the page the rain inside two columns next to each other */ + /* can overlap and look bad */ + background: radial-gradient(circle at 4px 4px, hsl(200, 90%, 70%, 0.4) 1px, transparent 0); + background-size: 8px 8px; + transform: rotate(45deg) translate(-50%, 25%); + height: 130%; + aspect-ratio: 1; + left: 55%; +} + +.weather-column:nth-child(3) .weather-column-time, +.weather-column:nth-child(7) .weather-column-time, +.weather-column:nth-child(11) .weather-column-time { + opacity: 1; + transform: translateY(0); +} + +.weather-column-time, .weather-columns:hover .weather-column-time { + margin-top: 0.3rem; + font-size: var(--font-size-h6); + opacity: 0; + transform: translateY(-0.5rem); + transition: opacity .2s, transform .2s; + user-select: none; +} + +.weather-column:hover .weather-column-time { + opacity: 1; + transform: translateY(0); +} + +.weather-column-daylight { + position: absolute; + inset: 0; + background: linear-gradient(0deg, transparent 30px, hsl(50, 50%, 30%, 0.2)); +} + +.weather-column-daylight-sunrise { + border-radius: 20px 0 0 0; +} + +.weather-column-daylight-sunset { + border-radius: 0 20px 0 0; +} + +.location-icon { + width: 0.8em; + height: 0.8em; + border-radius: 0 50% 50% 50%; + background-color: currentColor; + transform: rotate(225deg) translate(.1em, .1em); + position: relative; + flex-shrink: 0; +} + +.location-icon::after { + content: ''; + position: absolute; + z-index: 2; + width: .4em; + height: .4em; + border-radius: 50%; + background-color: var(--color-widget-background); + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} diff --git a/internal/glance/static/css/widgets.css b/internal/glance/static/css/widgets.css new file mode 100644 index 0000000..07b41c8 --- /dev/null +++ b/internal/glance/static/css/widgets.css @@ -0,0 +1,93 @@ +@import "widget-bookmarks.css"; +@import "widget-calendar.css"; +@import "widget-clock.css"; +@import "widget-dns-stats.css"; +@import "widget-docker-containers.css"; +@import "widget-group.css"; +@import "widget-markets.css"; +@import "widget-monitor.css"; +@import "widget-reddit.css"; +@import "widget-releases.css"; +@import "widget-rss.css"; +@import "widget-search.css"; +@import "widget-server-stats.css"; +@import "widget-twitch.css"; +@import "widget-videos.css"; +@import "widget-weather.css"; +@import "widget-todo.css"; + +@import "forum-posts.css"; + +.widget-error-header { + display: flex; + align-items: center; + justify-content: space-between; + position: relative; + margin-bottom: 1.8rem; + z-index: 1; +} + +.widget-error-header::before { + content: ''; + position: absolute; + inset: calc(0rem - (var(--widget-content-vertical-padding) / 2)) calc(0rem - (var(--widget-content-horizontal-padding) / 2)); + background: var(--color-negative); + opacity: 0.05; + border-radius: var(--border-radius); + z-index: -1; +} + +.widget-error-icon { + width: 2.4rem; + height: 2.4rem; + flex-shrink: 0; + stroke: var(--color-negative); + opacity: 0.6; +} + +.head-widgets { + margin-bottom: var(--widget-gap); +} + +.widget-content { + container-type: inline-size; + container-name: widget; +} + +.widget-content:not(.widget-content-frameless) { + padding: var(--widget-content-padding); +} + +.widget-content:not(.widget-content-frameless), .widget-content-frame { + background: var(--color-widget-background); + border-radius: var(--border-radius); + border: 1px solid var(--color-widget-content-border); + box-shadow: 0px 3px 0px 0px hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl)) - 0.5%)); +} + +.widget-header { + padding: 0 calc(var(--widget-content-horizontal-padding) + 1px); + font-size: var(--font-size-h4); + margin-bottom: 0.9rem; + display: flex; + align-items: center; + gap: 1rem; +} + +.widget-beta-icon { + width: 1.6rem; + height: 1.6rem; + flex-shrink: 0; + transition: transform .45s, opacity .45s, stroke .45s; + opacity: 0.7; +} + +.widget-beta-icon:hover, .widget-header .popover-active > .widget-beta-icon { + fill: var(--color-text-highlight); + transform: translateY(-10%) scale(1.3); + opacity: 1; +} + +.widget + .widget { + margin-top: var(--widget-gap); +} diff --git a/internal/glance/static/favicon.png b/internal/glance/static/favicon.png index 8323483..63e4734 100644 Binary files a/internal/glance/static/favicon.png and b/internal/glance/static/favicon.png differ diff --git a/internal/glance/static/favicon.svg b/internal/glance/static/favicon.svg new file mode 100644 index 0000000..8e92c1e --- /dev/null +++ b/internal/glance/static/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/internal/glance/static/js/animations.js b/internal/glance/static/js/animations.js index db5b0aa..7aa6d00 100644 --- a/internal/glance/static/js/animations.js +++ b/internal/glance/static/js/animations.js @@ -31,3 +31,28 @@ export function slideFade({ }, }; } + + +export function animateReposition( + element, + onAnimEnd, + animOptions = { duration: 400, easing: easeOutQuint } +) { + const rectBefore = element.getBoundingClientRect(); + + return () => { + const rectAfter = element.getBoundingClientRect(); + const offsetY = rectBefore.y - rectAfter.y; + const offsetX = rectBefore.x - rectAfter.x; + + element.animate({ + keyframes: [ + { transform: `translate(${offsetX}px, ${offsetY}px)` }, + { transform: 'none' } + ], + options: animOptions + }, onAnimEnd); + + return rectAfter; + } +} diff --git a/internal/glance/static/js/calendar.js b/internal/glance/static/js/calendar.js index fb6602e..fbf2e98 100644 --- a/internal/glance/static/js/calendar.js +++ b/internal/glance/static/js/calendar.js @@ -30,7 +30,7 @@ const [datesEntranceLeft, datesEntranceRight] = directions( const undoEntrance = slideFade({ direction: "left", distance: "100%", duration: 300 }); export default function(element) { - element.swap(Calendar( + element.swapWith(Calendar( Number(element.dataset.firstDayOfWeek ?? 1) )); } diff --git a/internal/glance/static/js/login.js b/internal/glance/static/js/login.js new file mode 100644 index 0000000..e077fe9 --- /dev/null +++ b/internal/glance/static/js/login.js @@ -0,0 +1,128 @@ +import { find } from "./templating.js"; + +const AUTH_ENDPOINT = pageData.baseURL + "/api/authenticate"; + +const showPasswordSVG = ` + +`; + +const hidePasswordSVG = ` + + +`; + +const container = find("#login-container"); +const usernameInput = find("#username"); +const passwordInput = find("#password"); +const errorMessage = find("#error-message"); +const loginButton = find("#login-button"); +const toggleVisibilityButton = find("#toggle-password-visibility"); + +const state = { + lastUsername: "", + lastPassword: "", + isLoading: false, + isRateLimited: false +}; + +const lang = { + showPassword: "Show password", + hidePassword: "Hide password", + incorrectCredentials: "Incorrect username or password", + rateLimited: "Too many login attempts, try again in a few minutes", + unknownError: "An error occurred, please try again", +}; + +container.clearStyles("display"); +setTimeout(() => usernameInput.focus(), 200); + +toggleVisibilityButton + .html(showPasswordSVG) + .attr("title", lang.showPassword) + .on("click", function() { + if (passwordInput.type === "password") { + passwordInput.type = "text"; + toggleVisibilityButton.html(hidePasswordSVG).attr("title", lang.hidePassword); + return; + } + + passwordInput.type = "password"; + toggleVisibilityButton.html(showPasswordSVG).attr("title", lang.showPassword); + }); + +function enableLoginButtonIfCriteriaMet() { + const usernameValue = usernameInput.value.trim(); + const passwordValue = passwordInput.value.trim(); + + const usernameValid = usernameValue.length >= 3; + const passwordValid = passwordValue.length >= 6; + + const isUsingLastCredentials = + usernameValue === state.lastUsername + && passwordValue === state.lastPassword; + + loginButton.disabled = !( + usernameValid + && passwordValid + && !isUsingLastCredentials + && !state.isLoading + && !state.isRateLimited + ); +} + +usernameInput.on("input", enableLoginButtonIfCriteriaMet); +passwordInput.on("input", enableLoginButtonIfCriteriaMet); + +async function handleLoginAttempt() { + state.lastUsername = usernameInput.value; + state.lastPassword = passwordInput.value; + errorMessage.text(""); + + loginButton.disable(); + state.isLoading = true; + + const response = await fetch(AUTH_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + username: usernameInput.value, + password: passwordInput.value + }), + }); + + state.isLoading = false; + if (response.status === 200) { + setTimeout(() => { window.location.href = pageData.baseURL + "/"; }, 300); + + container.animate({ + keyframes: [{ offset: 1, transform: "scale(0.95)", opacity: 0 }], + options: { duration: 300, easing: "ease", fill: "forwards" }} + ); + + find("footer")?.animate({ + keyframes: [{ offset: 1, opacity: 0 }], + options: { duration: 300, easing: "ease", fill: "forwards", delay: 50 } + }); + } else if (response.status === 401) { + errorMessage.text(lang.incorrectCredentials); + passwordInput.focus(); + } else if (response.status === 429) { + errorMessage.text(lang.rateLimited); + state.isRateLimited = true; + const retryAfter = response.headers.get("Retry-After") || 30; + setTimeout(() => { + state.lastUsername = ""; + state.lastPassword = ""; + state.isRateLimited = false; + + enableLoginButtonIfCriteriaMet(); + }, retryAfter * 1000); + } else { + errorMessage.text(lang.unknownError); + passwordInput.focus(); + } +} + +loginButton.disable().on("click", handleLoginAttempt); diff --git a/internal/glance/static/js/masonry.js b/internal/glance/static/js/masonry.js index 45680f4..83e6206 100644 --- a/internal/glance/static/js/masonry.js +++ b/internal/glance/static/js/masonry.js @@ -37,9 +37,6 @@ export function setupMasonries() { columnsFragment.append(column); } - // poor man's masonry - // TODO: add an option that allows placing items in the - // shortest column instead of iterating the columns in order for (let i = 0; i < items.length; i++) { columnsFragment.children[i % columnsCount].appendChild(items[i]); } diff --git a/internal/glance/static/js/main.js b/internal/glance/static/js/page.js similarity index 89% rename from internal/glance/static/js/main.js rename to internal/glance/static/js/page.js index dca713b..03db8d8 100644 --- a/internal/glance/static/js/main.js +++ b/internal/glance/static/js/page.js @@ -1,6 +1,7 @@ import { setupPopovers } from './popover.js'; import { setupMasonries } from './masonry.js'; import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js'; +import { elem, find, findAll } from './templating.js'; async function fetchPageContent(pageData) { // TODO: handle non 200 status codes/time outs @@ -641,6 +642,16 @@ async function setupCalendars() { calendar.default(elems[i]); } +async function setupTodos() { + const elems = document.getElementsByClassName("todo"); + if (elems.length == 0) return; + + const todo = await import ('./todo.js'); + + for (let i = 0; i < elems.length; i++) + todo.default(elems[i]); +} + function setupTruncatedElementTitles() { const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines"); @@ -654,7 +665,77 @@ function setupTruncatedElementTitles() { } } +async function changeTheme(key, onChanged) { + const themeStyleElem = find("#theme-style"); + + const response = await fetch(`${pageData.baseURL}/api/set-theme/${key}`, { + method: "POST", + }); + + if (response.status != 200) { + alert("Failed to set theme: " + response.statusText); + return; + } + const newThemeStyle = await response.text(); + + const tempStyle = elem("style") + .html("* { transition: none !important; }") + .appendTo(document.head); + + themeStyleElem.html(newThemeStyle); + document.documentElement.setAttribute("data-scheme", response.headers.get("X-Scheme")); + typeof onChanged == "function" && onChanged(); + setTimeout(() => { tempStyle.remove(); }, 10); +} + +function initThemeSwitcher() { + find(".mobile-navigation .theme-choices").replaceWith( + find(".header-container .theme-choices").cloneNode(true) + ); + + const presetElems = findAll(".theme-choices .theme-preset"); + let themePreviewElems = document.getElementsByClassName("current-theme-preview"); + let isLoading = false; + + presetElems.forEach((presetElement) => { + const themeKey = presetElement.dataset.key; + + if (themeKey === undefined) { + return; + } + + if (themeKey == pageData.theme) { + presetElement.classList.add("current"); + } + + presetElement.addEventListener("click", () => { + if (themeKey == pageData.theme) return; + if (isLoading) return; + + isLoading = true; + changeTheme(themeKey, function() { + isLoading = false; + pageData.theme = themeKey; + presetElems.forEach((e) => { e.classList.remove("current"); }); + + Array.from(themePreviewElems).forEach((preview) => { + preview.querySelector(".theme-preset").replaceWith( + presetElement.cloneNode(true) + ); + }) + + presetElems.forEach((e) => { + if (e.dataset.key != themeKey) return; + e.classList.add("current"); + }); + }); + }); + }) +} + async function setupPage() { + initThemeSwitcher(); + const pageElement = document.getElementById("page"); const pageContentElement = document.getElementById("page-content"); const pageContent = await fetchPageContent(pageData); @@ -665,6 +746,7 @@ async function setupPage() { setupPopovers(); setupClocks() await setupCalendars(); + await setupTodos(); setupCarousels(); setupSearchBoxes(); setupCollapsibleLists(); diff --git a/internal/glance/static/js/popover.js b/internal/glance/static/js/popover.js index 331ee26..98b1051 100644 --- a/internal/glance/static/js/popover.js +++ b/internal/glance/static/js/popover.js @@ -38,6 +38,8 @@ function handleMouseEnter(event) { if (activeTarget !== target) { hidePopover(); requestAnimationFrame(() => requestAnimationFrame(showPopover)); + } else if (activeTarget.dataset.popoverTrigger === "click") { + hidePopover(); } return; @@ -100,11 +102,14 @@ function showPopover() { contentElement.style.maxWidth = contentMaxWidth; activeTarget.classList.add("popover-active"); document.addEventListener("keydown", handleHidePopoverOnEscape); + window.addEventListener("scroll", queueRepositionContainer); window.addEventListener("resize", queueRepositionContainer); observer.observe(containerElement); } function repositionContainer() { + if (activeTarget === null) return; + containerElement.style.display = "block"; const targetBounds = activeTarget.dataset.popoverAnchor !== undefined @@ -157,7 +162,11 @@ function hidePopover() { activeTarget.classList.remove("popover-active"); containerElement.style.display = "none"; + containerElement.style.removeProperty("top"); + containerElement.style.removeProperty("left"); + containerElement.style.removeProperty("right"); document.removeEventListener("keydown", handleHidePopoverOnEscape); + window.removeEventListener("scroll", queueRepositionContainer); window.removeEventListener("resize", queueRepositionContainer); observer.unobserve(containerElement); @@ -181,7 +190,12 @@ export function setupPopovers() { for (let i = 0; i < targets.length; i++) { const target = targets[i]; - target.addEventListener("mouseenter", handleMouseEnter); + if (target.dataset.popoverTrigger === "click") { + target.addEventListener("click", handleMouseEnter); + } else { + target.addEventListener("mouseenter", handleMouseEnter); + } + target.addEventListener("mouseleave", handleMouseLeave); } } diff --git a/internal/glance/static/js/templating.js b/internal/glance/static/js/templating.js index 05824bc..a06d41f 100644 --- a/internal/glance/static/js/templating.js +++ b/internal/glance/static/js/templating.js @@ -29,6 +29,15 @@ export function findAll(selector) { return document.querySelectorAll(selector); } + +HTMLCollection.prototype.map = function(fn) { + return Array.from(this).map(fn); +} + +HTMLCollection.prototype.indexOf = function(element) { + return Array.prototype.indexOf.call(this, element); +} + const ep = HTMLElement.prototype; const fp = DocumentFragment.prototype; const tp = Text.prototype; @@ -110,7 +119,7 @@ ep.appendTo = function(parent) { return this; } -ep.swap = function(element) { +ep.swapWith = function(element) { this.replaceWith(element); return element; } @@ -147,6 +156,22 @@ ep.styles = function(s) { return this; } +ep.clearStyles = function(...props) { + for (let i = 0; i < props.length; i++) + this.style.removeProperty(props[i]); + return this; +} + +ep.disable = function() { + this.disabled = true; + return this; +} + +ep.enable = function() { + this.disabled = false; + return this; +} + const epAnimate = ep.animate; ep.animate = function(anim, callback) { const a = epAnimate.call(this, anim.keyframes, anim.options); diff --git a/internal/glance/static/js/todo.js b/internal/glance/static/js/todo.js new file mode 100644 index 0000000..b70f6bd --- /dev/null +++ b/internal/glance/static/js/todo.js @@ -0,0 +1,442 @@ +import { elem, fragment } from "./templating.js"; +import { animateReposition } from "./animations.js"; +import { clamp, Vec2, toggleableEvents, throttledDebounce } from "./utils.js"; + +const trashIconSvg = ` + +`; + +export default function(element) { + element.swapWith( + Todo(element.dataset.todoId) + ) +} + +function itemAnim(height, entrance = true) { + const visible = { height: height + "px", opacity: 1 }; + const hidden = { height: "0", opacity: 0, padding: "0" }; + + return { + keyframes: [ + entrance ? hidden : visible, + entrance ? visible : hidden + ], + options: { duration: 200, easing: "ease" } + } +} + +function inputMarginAnim(entrance = true) { + const amount = "1.5rem"; + + return { + keyframes: [ + { marginBottom: entrance ? "0px" : amount }, + { marginBottom: entrance ? amount : "0" } + ], + options: { duration: 200, easing: "ease", fill: "forwards" } + } +} + +function loadFromLocalStorage(id) { + return JSON.parse(localStorage.getItem(`todo-${id}`) || "[]"); +} + +function saveToLocalStorage(id, data) { + localStorage.setItem(`todo-${id}`, JSON.stringify(data)); +} + +function Item(unserialize = {}, onUpdate, onDelete, onEscape, onDragStart) { + let item, input, inputArea; + + const serializeable = { + text: unserialize.text || "", + checked: unserialize.checked || false + }; + + item = elem().classes("todo-item", "flex", "gap-10", "items-center").append( + elem("input") + .classes("todo-item-checkbox", "shrink-0") + .styles({ marginTop: "-0.1rem" }) + .attrs({ type: "checkbox" }) + .on("change", (e) => { + serializeable.checked = e.target.checked; + onUpdate(); + }) + .tap(self => self.checked = serializeable.checked), + + input = autoScalingTextarea(textarea => inputArea = textarea + .classes("todo-item-text") + .attrs({ + placeholder: "empty task", + spellcheck: "false" + }) + .on("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + } else if (e.key === "Escape") { + e.preventDefault(); + onEscape(); + } + }) + .on("input", () => { + serializeable.text = inputArea.value; + onUpdate(); + }) + ).classes("min-width-0", "grow").append( + elem() + .classes("todo-item-drag-handle") + .on("mousedown", (e) => onDragStart(e, item)) + ), + + elem("button") + .classes("todo-item-delete", "shrink-0") + .html(trashIconSvg) + .on("click", () => onDelete(item)) + ); + + input.component.setValue(serializeable.text); + return item.component({ + focusInput: () => inputArea.focus(), + serialize: () => serializeable + }); +} + +function Todo(id) { + let items, input, inputArea, inputContainer, lastAddedItem; + let queuedForRemoval = 0; + let reorderable; + let isDragging = false; + + const onDragEnd = () => isDragging = false; + const onDragStart = (event, element) => { + isDragging = true; + reorderable.component.onDragStart(event, element); + }; + + const saveItems = () => { + if (isDragging) return; + + saveToLocalStorage( + id, items.children.map(item => item.component.serialize()) + ); + }; + + const onItemRepositioned = () => saveItems(); + const debouncedOnItemUpdate = throttledDebounce(saveItems, 10, 1000); + + const onItemDelete = (item) => { + if (lastAddedItem === item) lastAddedItem = null; + const height = item.clientHeight; + queuedForRemoval++; + item.animate(itemAnim(height, false), () => { + item.remove(); + queuedForRemoval--; + saveItems(); + }); + + if (items.children.length - queuedForRemoval === 0) + inputContainer.animate(inputMarginAnim(false)); + }; + + const newItem = (data) => Item( + data, + debouncedOnItemUpdate, + onItemDelete, + () => inputArea.focus(), + onDragStart + ); + + const addNewItem = (itemText, prepend) => { + const totalItemsBeforeAppending = items.children.length; + const item = lastAddedItem = newItem({ text: itemText }); + + prepend ? items.prepend(item) : items.append(item); + saveItems(); + const height = item.clientHeight; + item.animate(itemAnim(height)); + + if (totalItemsBeforeAppending === 0) + inputContainer.animate(inputMarginAnim()); + }; + + const handleInputKeyDown = (e) => { + switch (e.key) { + case "Enter": + e.preventDefault(); + const value = e.target.value.trim(); + if (value === "") return; + addNewItem(value, e.ctrlKey); + input.component.setValue(""); + break; + case "Escape": + e.target.blur(); + break; + case "ArrowDown": + if (!lastAddedItem) return; + e.preventDefault(); + lastAddedItem.component.focusInput(); + break; + } + }; + + items = elem() + .classes("todo-items") + .append( + ...loadFromLocalStorage(id).map(data => newItem(data)) + ); + + return fragment().append( + inputContainer = elem() + .classes("todo-input", "flex", "gap-10", "items-center") + .classesIf(items.children.length > 0, "margin-bottom-15") + .styles({ paddingRight: "2.5rem" }) + .append( + elem().classes("todo-plus-icon", "shrink-0"), + input = autoScalingTextarea(textarea => inputArea = textarea + .on("keydown", handleInputKeyDown) + .attrs({ + placeholder: "Add a task", + spellcheck: "false" + }) + ).classes("grow", "min-width-0") + ), + + reorderable = verticallyReorderable(items, onItemRepositioned, onDragEnd), + ); +} + + +// See https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/ +export function autoScalingTextarea(yieldTextarea = null) { + let textarea, mimic; + + const updateMimic = (newValue) => mimic.text(newValue + ' '); + const container = elem().classes("auto-scaling-textarea-container").append( + textarea = elem("textarea") + .classes("auto-scaling-textarea") + .on("input", () => updateMimic(textarea.value)), + mimic = elem().classes("auto-scaling-textarea-mimic") + ) + + if (typeof yieldTextarea === "function") yieldTextarea(textarea); + + return container.component({ setValue: (newValue) => { + textarea.value = newValue; + updateMimic(newValue); + }}); +} + +export function verticallyReorderable(itemsContainer, onItemRepositioned, onDragEnd) { + const classToAddToDraggedItem = "is-being-dragged"; + + const currentlyBeingDragged = { + element: null, + initialIndex: null, + clientOffset: Vec2.new(), + }; + + const decoy = { + element: null, + currentIndex: null, + }; + + const draggableContainer = { + element: null, + initialRect: null, + }; + + const lastClientPos = Vec2.new(); + let initialScrollY = null; + let addDocumentEvents, removeDocumentEvents; + + const handleReposition = (event) => { + if (currentlyBeingDragged.element == null) return; + + if (event.clientY !== undefined && event.clientX !== undefined) + lastClientPos.setFromEvent(event); + + const client = lastClientPos; + const container = draggableContainer; + const item = currentlyBeingDragged; + + const scrollOffset = window.scrollY - initialScrollY; + const offsetY = client.y - container.initialRect.y - item.clientOffset.y + scrollOffset; + const offsetX = client.x - container.initialRect.x - item.clientOffset.x; + + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + const viewportWidth = window.innerWidth - scrollbarWidth; + + const confinedX = clamp( + offsetX, + -container.initialRect.x, + viewportWidth - container.initialRect.x - container.initialRect.width + ); + + container.element.styles({ + transform: `translate(${confinedX}px, ${offsetY}px)`, + }); + + const containerTop = client.y - item.clientOffset.y; + const containerBottom = client.y + container.initialRect.height - item.clientOffset.y; + + let swapWithLast = true; + let swapWithIndex = null; + + for (let i = 0; i < itemsContainer.children.length; i++) { + const childRect = itemsContainer.children[i].getBoundingClientRect(); + const topThreshold = childRect.top + childRect.height * .6; + const bottomThreshold = childRect.top + childRect.height * .4; + + if (containerBottom > topThreshold) { + if (containerTop < bottomThreshold && i != decoy.currentIndex) { + swapWithIndex = i; + swapWithLast = false; + break; + } + continue; + }; + + swapWithLast = false; + + if (i == decoy.currentIndex || i-1 == decoy.currentIndex) break; + swapWithIndex = (i < decoy.currentIndex) ? i : i-1; + break; + } + + const lastItemIndex = itemsContainer.children.length - 1; + + if (swapWithLast && decoy.currentIndex != lastItemIndex) + swapWithIndex = lastItemIndex; + + if (swapWithIndex === null) + return; + + const diff = swapWithIndex - decoy.currentIndex; + if (Math.abs(diff) > 1) { + swapWithIndex = decoy.currentIndex + Math.sign(diff); + } + + const siblingToSwapWith = itemsContainer.children[swapWithIndex]; + + if (siblingToSwapWith.isCurrentlyAnimating) return; + + const animateDecoy = animateReposition(decoy.element); + const animateChild = animateReposition( + siblingToSwapWith, + () => { + siblingToSwapWith.isCurrentlyAnimating = false; + handleReposition({ + clientX: client.x, + clientY: client.y, + }); + } + ); + + siblingToSwapWith.isCurrentlyAnimating = true; + + if (swapWithIndex > decoy.currentIndex) + decoy.element.before(siblingToSwapWith); + else + decoy.element.after(siblingToSwapWith); + + decoy.currentIndex = itemsContainer.children.indexOf(decoy.element); + + animateDecoy(); + animateChild(); + } + + const handleRelease = (event) => { + if (event.buttons != 0) return; + + removeDocumentEvents(); + const item = currentlyBeingDragged; + const element = item.element; + element.styles({ pointerEvents: "none" }); + const animate = animateReposition(element, () => { + item.element = null; + element + .clearClasses(classToAddToDraggedItem) + .clearStyles("pointer-events"); + + if (typeof onDragEnd === "function") onDragEnd(element); + + if (item.initialIndex != decoy.currentIndex && typeof onItemRepositioned === "function") + onItemRepositioned(element, item.initialIndex, decoy.currentIndex); + }); + + decoy.element.swapWith(element); + draggableContainer.element.append(decoy.element); + draggableContainer.element.clearStyles("transform", "width"); + + item.element = null; + decoy.element.remove(); + + animate(); + } + + const preventDefault = (event) => { + event.preventDefault(); + }; + + const handleGrab = (event, element) => { + if (currentlyBeingDragged.element != null) return; + + event.preventDefault(); + + const item = currentlyBeingDragged; + if (item.element != null) return; + + addDocumentEvents(); + initialScrollY = window.scrollY; + const client = lastClientPos.setFromEvent(event); + const elementRect = element.getBoundingClientRect(); + + item.element = element; + item.initialIndex = decoy.currentIndex = itemsContainer.children.indexOf(element); + item.clientOffset.set(client.x - elementRect.x, client.y - elementRect.y); + + // We use getComputedStyle here to get width and height because .clientWidth and .clientHeight + // return integers and not the real float values, which can cause the decoy to be off by a pixel + const elementStyle = getComputedStyle(element); + const initialWidth = elementStyle.width; + + decoy.element = elem().classes("drag-and-drop-decoy").styles({ + height: elementStyle.height, + width: initialWidth, + }); + + const container = draggableContainer; + + element.swapWith(decoy.element); + container.element.append(element); + element.classes(classToAddToDraggedItem); + + decoy.element.animate({ + keyframes: [{ transform: "scale(.9)", opacity: 0, offset: 0 }], + options: { duration: 300, easing: "ease" } + }) + + container.element.styles({ width: initialWidth, transform: "none" }); + container.initialRect = container.element.getBoundingClientRect(); + + const offsetY = elementRect.y - container.initialRect.y; + const offsetX = elementRect.x - container.initialRect.x; + + container.element.styles({ transform: `translate(${offsetX}px, ${offsetY}px)` }); + } + + [addDocumentEvents, removeDocumentEvents] = toggleableEvents(document, { + "mousemove": handleReposition, + "scroll": handleReposition, + "mousedown": preventDefault, + "contextmenu": preventDefault, + "mouseup": handleRelease, + }); + + return elem().classes("drag-and-drop-container").append( + itemsContainer, + draggableContainer.element = elem().classes("drag-and-drop-draggable") + ).component({ + onDragStart: handleGrab + }); +} diff --git a/internal/glance/static/js/utils.js b/internal/glance/static/js/utils.js index 1d1816a..a82f97a 100644 --- a/internal/glance/static/js/utils.js +++ b/internal/glance/static/js/utils.js @@ -36,3 +36,46 @@ export function openURLInNewTab(url, focus = true) { if (focus && newWindow != null) newWindow.focus(); } + + +export class Vec2 { + constructor(x, y) { + this.x = x; + this.y = y; + } + + static new(x = 0, y = 0) { + return new Vec2(x, y); + } + + static fromEvent(event) { + return new Vec2(event.clientX, event.clientY); + } + + setFromEvent(event) { + this.x = event.clientX; + this.y = event.clientY; + return this; + } + + set(x, y) { + this.x = x; + this.y = y; + return this; + } +} + +export function toggleableEvents(element, eventToHandlerMap) { + return [ + () => { + for (const [event, handler] of Object.entries(eventToHandlerMap)) { + element.addEventListener(event, handler); + } + }, + () => { + for (const [event, handler] of Object.entries(eventToHandlerMap)) { + element.removeEventListener(event, handler); + } + } + ]; +} diff --git a/internal/glance/static/main.css b/internal/glance/static/main.css deleted file mode 100644 index 2975a73..0000000 --- a/internal/glance/static/main.css +++ /dev/null @@ -1,2116 +0,0 @@ -@font-face { - font-family: 'JetBrains Mono'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url('fonts/JetBrainsMono-Regular.woff2') format('woff2'); -} - -:root { - font-size: 10px; - - --scheme: ; - --bgh: 240; - --bgs: 8%; - --bgl: 9%; - --bghs: var(--bgh), var(--bgs); - --cm: 1; - --tsm: 1; - - --widget-gap: 23px; - --widget-content-vertical-padding: 15px; - --widget-content-horizontal-padding: 17px; - --widget-content-padding: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding); - --content-bounds-padding: 15px; - --border-radius: 5px; - --mobile-navigation-height: 50px; - - --color-primary: hsl(43, 50%, 70%); - --color-positive: var(--color-primary); - --color-negative: hsl(0, 70%, 70%); - --color-background: hsl(var(--bghs), var(--bgl)); - --color-widget-background-hsl-values: var(--bghs), calc(var(--bgl) + 1%); - --color-widget-background: hsl(var(--color-widget-background-hsl-values)); - --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)) + 30% * 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-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%)); - --color-text-paragraph: hsl(var(--ths), calc(var(--scheme) var(--cm) * 73%)); - --color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%)); - --color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%)); - --color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%)); - - --font-size-h1: 1.7rem; - --font-size-h2: 1.6rem; - --font-size-h3: 1.5rem; - --font-size-h4: 1.4rem; - --font-size-base: 1.3rem; - --font-size-h5: 1.2rem; - --font-size-h6: 1.1rem; -} - -.light-scheme { - --scheme: 100% -; -} - -.page { - height: 100%; - padding-block: var(--widget-gap); -} - -.page-content, .page.content-ready .page-loading-container { - display: none; -} - -.page.content-ready > .page-content { - 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); -} - -.color-primary-if-not-visited:not(:visited) { - color: var(--color-primary); -} - -.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; - display: -webkit-box; - -webkit-box-orient: vertical; -} - -.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, -.bookmarks-link:not(.bookmarks-link-no-arrow)::after { - content: '↗' / ""; - margin-left: 0.5em; - display: inline-block; - position: relative; - top: 0.15em; - color: var(--color-text-base); -} - -.visited-indicator.text-truncate { - direction: rtl; - text-align: left; -} - -.visited-indicator:not(:visited)::before, .visited-indicator:not(:visited)::after { - color: var(--color-primary); -} - -.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.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); -} - -.collapsible-container:not(.container-expanded) > .collapsible-item { - display: none; -} - -.collapsible-item { - animation: collapsibleItemReveal .25s backwards; -} - -@keyframes collapsibleItemReveal { - from { - opacity: 0; - transform: translateY(10px); - } -} - -.expand-toggle-button { - font: inherit; - border: 0; - cursor: pointer; - display: block; - width: 100%; - text-align: left; - color: var(--color-text-base); - text-transform: uppercase; - font-size: var(--font-size-h4); - padding: var(--widget-content-vertical-padding) 0; - background: var(--color-widget-background); -} - -.expand-toggle-button.container-expanded { - position: sticky; - /* -1px to hide 1px gap on chrome */ - bottom: -1px; -} - -.expand-toggle-button-icon { - display: inline-block; - margin-left: 1rem; - position: relative; - top: -.2rem; -} - -.expand-toggle-button-icon::before { - content: '' / ""; - font-size: 0.8rem; - transform: rotate(90deg); - line-height: 1; - display: inline-block; - transition: transform 0.3s; -} - -.expand-toggle-button.container-expanded .expand-toggle-button-icon::before { - 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; -} - -.cards-grid.collapsible-container + .expand-toggle-button { - text-align: center; - margin-top: 0.5rem; - background-color: var(--color-background); -} - -.attachments { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -.attachments > * { - border-radius: var(--border-radius); - padding: 0.1rem 0.5rem; - font-size: var(--font-size-h6); - background-color: var(--color-separator); -} - -pre { - font: inherit; -} - -::selection { - background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%))); - color: var(--color-text-highlight); -} - -::-webkit-scrollbar-thumb { - background: var(--color-text-subdue); - border-radius: var(--border-radius); -} - -::-webkit-scrollbar { - background: var(--color-background); - height: 5px; - width: 10px; -} - -*:focus-visible { - outline: 2px solid var(--color-primary); - outline-offset: 0.1rem; - border-radius: var(--border-radius); -} - -*, *::before, *::after { - box-sizing: border-box; -} - -* { - padding: 0; - margin: 0; -} - -hr { - border: 0; - height: 1px; - background-color: var(--color-separator); -} - -img, svg { - display: block; - max-width: 100%; -} - -img[loading=lazy].loaded:not(.finished-transition) { - transition: opacity .4s; -} - -img[loading=lazy].cached:not(.finished-transition) { - transition: none; -} - -img[loading=lazy]:not(.loaded, .cached) { - opacity: 0; -} - -html { - scrollbar-color: var(--color-text-subdue) transparent; - scroll-behavior: smooth; -} - -html, body, .body-content { - height: 100%; -} - -h1, h2, h3, h4, h5 { - font: inherit; -} - -.visually-hidden { - clip-path: inset(50%); - height: 1px; - overflow: hidden; - position: absolute; - white-space: nowrap; - width: 1px; -} - -a { - text-decoration: none; - color: inherit; - overflow-wrap: break-word; -} - -ul { - list-style: none; -} - -body { - font-size: 1.3rem; - font-family: 'JetBrains Mono', monospace; - font-variant-ligatures: none; - line-height: 1.6; - color: var(--color-text-base); - background-color: var(--color-background); - overflow-y: scroll; -} - -.page-column-small { - width: 300px; - flex-shrink: 0; -} - -.page-column-full { - width: 100%; - min-width: 0; -} - -.page-columns { - display: flex; - gap: var(--widget-gap); - animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards; -} - -@keyframes pageColumnsEntrance { - from { - opacity: 0; - transform: translateY(10px); - } -} - -.page-loading-container { - 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; - } -} - -.loading-icon { - min-width: 1.5em; - width: 1.5em; - height: 1.5em; - border: 0.25em solid hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 12%))); - border-top-color: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 40%))); - border-radius: 50%; - animation: loadingIconSpin 800ms infinite linear; -} - -@keyframes loadingIconSpin { - to { - transform: rotate(360deg); - } -} - -.notice-icon { - width: 0.7rem; - height: 0.7rem; - border-radius: 50%; -} - -.notice-icon-major { - background: var(--color-negative); -} - -.notice-icon-minor { - border: 1px solid var(--color-negative); -} - -kbd { - font: inherit; - padding: 0.1rem 0.8rem; - border-radius: var(--border-radius); - border: 2px solid var(--color-widget-background-highlight); - box-shadow: 0 2px 0 var(--color-widget-background-highlight); - user-select: none; - transition: transform .1s, box-shadow .1s; - font-size: var(--font-size-h5); - cursor: pointer; -} - -kbd:active { - transform: translateY(2px); - box-shadow: 0 0 0 0 var(--color-widget-background-highlight); -} - -.masonry { - display: flex; - gap: var(--widget-gap); -} - -.masonry-column { - flex: 1; - display: flex; - flex-direction: column; -} - -.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; - z-index: 1; -} - -.summary::-webkit-details-marker { - display: none; -} - -.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; -} - -.details:not([open]) .list-with-transition { - display: none; -} - -.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 { - --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); -} - -.dynamic-columns > * { - 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; } -.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; } -.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; } -.dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; } -.dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; } - -@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 (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 { - flex-direction: column; -} - -.cards-horizontal { - --cards-per-row: 6.5; -} - -.cards-horizontal, .cards-vertical { - --cards-gap: calc(var(--widget-content-vertical-padding) * 0.7); - display: flex; - gap: var(--cards-gap); -} - -.card { - display: flex; - flex-direction: column; -} - -.cards-horizontal .card { - flex-shrink: 0; - width: calc(100% / var(--cards-per-row) - var(--cards-gap) * (var(--cards-per-row) - 1) / var(--cards-per-row)); -} - -.cards-grid .card { - min-width: 0; -} - -.cards-horizontal { - overflow-x: auto; - scrollbar-width: thin; - padding-bottom: 1rem; -} - -.cards-grid { - --cards-per-row: 6; - display: grid; - grid-template-columns: repeat(var(--cards-per-row), 1fr); - gap: calc(var(--widget-content-vertical-padding) * 0.7); -} - -@container widget (max-width: 1300px) { .cards-horizontal { --cards-per-row: 5.5; } } -@container widget (max-width: 1100px) { .cards-horizontal { --cards-per-row: 4.5; } } -@container widget (max-width: 850px) { .cards-horizontal { --cards-per-row: 3.5; } } -@container widget (max-width: 750px) { .cards-horizontal { --cards-per-row: 3.5; } } -@container widget (max-width: 650px) { .cards-horizontal { --cards-per-row: 2.5; } } -@container widget (max-width: 450px) { .cards-horizontal { --cards-per-row: 2.3; } } - -@container widget (max-width: 1300px) { .cards-grid { --cards-per-row: 5; } } -@container widget (max-width: 1100px) { .cards-grid { --cards-per-row: 4; } } -@container widget (max-width: 850px) { .cards-grid { --cards-per-row: 3; } } -@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; - align-items: center; - justify-content: space-between; - position: relative; - margin-bottom: 1.8rem; - z-index: 1; -} - -.widget-error-header::before { - content: ''; - position: absolute; - inset: calc(0rem - (var(--widget-content-vertical-padding) / 2)) calc(0rem - (var(--widget-content-horizontal-padding) / 2)); - background: var(--color-negative); - opacity: 0.05; - border-radius: var(--border-radius); - z-index: -1; -} - -.widget-error-icon { - width: 2.4rem; - height: 2.4rem; - flex-shrink: 0; - stroke: var(--color-negative); - opacity: 0.6; -} - -.widget-content { - container-type: inline-size; - container-name: widget; -} - -.widget-content:not(.widget-content-frameless) { - padding: var(--widget-content-padding); -} - -.widget-content:not(.widget-content-frameless), .widget-content-frame { - background: var(--color-widget-background); - border-radius: var(--border-radius); - border: 1px solid var(--color-widget-content-border); - box-shadow: 0px 3px 0px 0px hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl)) - 0.5%)); -} - -.padding-widget { - padding: var(--widget-content-padding); -} - -.margin-bottom-widget { - margin-bottom: var(--widget-content-vertical-padding); -} - -.padding-block-widget { - padding-block: var(--widget-content-vertical-padding); -} - -.padding-inline-widget { - padding-inline: var(--widget-content-horizontal-padding); -} - -.widget-header { - padding: 0 calc(var(--widget-content-horizontal-padding) + 1px); - font-size: var(--font-size-h4); - margin-bottom: 0.9rem; - display: flex; - align-items: center; - gap: 1rem; -} - -.widget-beta-icon { - width: 1.6rem; - height: 1.6rem; - flex-shrink: 0; - transition: transform .45s, opacity .45s, stroke .45s; - opacity: 0.7; -} - -.widget-beta-icon:hover, .widget-header .popover-active > .widget-beta-icon { - fill: var(--color-text-highlight); - transform: translateY(-10%) scale(1.3); - opacity: 1; -} - -.widget + .widget { - margin-top: var(--widget-gap); -} - -.list-horizontal-text { - display: flex; - list-style: none; - flex-wrap: wrap; - align-items: center; -} - -.list-horizontal-text > *:not(:last-child)::after { - content: '•' / ""; - color: var(--color-text-subdue); - margin: 0 0.4rem; - position: relative; - top: 0.1rem; -} - -.header-container { - margin-top: calc(var(--widget-gap) / 2); - --header-height: 45px; - --header-items-gap: 2.5rem; -} - -.header { - display: flex; - height: var(--header-height); - gap: var(--header-items-gap); -} - -.logo { - height: 100%; - line-height: var(--header-height); - font-size: 2rem; - color: var(--color-text-highlight); - border-right: 1px solid var(--color-widget-content-border); - 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); -} - -.nav .nav-item { - line-height: var(--header-height); -} - -.footer { - padding-bottom: calc(var(--widget-gap) * 1.5); - padding-top: calc(var(--widget-gap) / 2); - animation: loadingContainerEntrance 200ms backwards; - animation-delay: 150ms; -} - -.mobile-navigation, .mobile-reachability-header { - display: none; -} - -.nav-item { - display: block; - height: 100%; - border-bottom: 2px solid transparent; - transition: color .3s, border-color .3s; - font-size: var(--font-size-h3); - flex-shrink: 0; -} - -.nav-item:not(.nav-item-current):hover { - border-bottom-color: var(--color-text-subdue); - color: var(--color-text-highlight); -} - -.nav-item.nav-item-current { - border-bottom-color: var(--color-primary); - 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 { - width: 100%; -} - -.market-values { - min-width: 8rem; -} - -.carousel-container { - position: relative; -} - -.carousel-container::before, .carousel-container::after { - content: ''; - position: absolute; - width: 2rem; - top: 0; - bottom: 1rem; - z-index: 10; - opacity: 0; - pointer-events: none; - transition-duration: 0.2s; -} - -.carousel-container::before { - left: 0; - background: linear-gradient(to right, var(--color-background), transparent); -} - -.carousel-container::after { - right: 0; - background: linear-gradient(to left, var(--color-background), transparent); -} - -.carousel-container.show-left-cutoff::before, .carousel-container.show-right-cutoff::after { - opacity: 1; -} - -.video-thumbnail { - width: 100%; - aspect-ratio: 16 / 8.9; - object-fit: cover; - border-radius: var(--border-radius) var(--border-radius) 0 0; -} - -.video-horizontal-list-thumbnail { - height: 4rem; - aspect-ratio: 16 / 8.9; - object-fit: cover; - border-radius: var(--border-radius); -} - -.search-icon { - width: 2.3rem; -} - -.search-icon-container { - position: relative; - flex-shrink: 0; -} - -/* gives a wider hit area for the 3 people that will notice the animation : ) */ -.search-icon-container::before { - content: ''; - position: absolute; - inset: -1rem; -} - -.search-icon-container:hover > .search-icon { - animation: searchIconHover 2.9s forwards; -} - -@keyframes searchIconHover { - 0%, 39% { translate: 0 0; } - 20% { scale: 1.3; } - 40% { scale: 1; } - 50% { translate: -30% 30%; } - 70% { translate: 30% -30%; } - 90% { translate: -30% -30%; } - 100% { translate: 0 0; } -} - -.search { - transition: border-color .2s; - position: relative; -} - -.search:hover { - border-color: var(--color-text-subdue); -} - -.search:focus-within { - border-color: var(--color-primary); -} - -.search-input { - border: 0; - background: none; - width: 100%; - height: 6rem; - font: inherit; - outline: none; - color: var(--color-text-highlight); -} - -.search-input::placeholder { - color: var(--color-text-base-muted); - opacity: 1; -} - -.search-bangs { display: none; } - -.search-bang { - border-radius: calc(var(--border-radius) * 2); - background: var(--color-widget-background-highlight); - padding: 0.3rem 1rem; - flex-shrink: 0; - font-size: var(--font-size-h5); - animation: searchBangsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards; -} - -@keyframes searchBangsEntrance { - 0% { - opacity: 0; - transform: translateX(-10px); - } -} - -.search-bang:empty { - display: none; -} - -.forum-post-list-thumbnail { - flex-shrink: 0; - width: 6rem; - height: 4.1rem; - border-radius: var(--border-radius); - object-fit: cover; - border: 1px solid var(--color-separator); - margin-top: 0.1rem; -} - -.forum-post-tags-container { - transform: translateY(-0.15rem); -} - -@container widget (max-width: 550px) { - .forum-post-autohide { - display: none; - } -} - -.bookmarks-group { - --bookmarks-group-color: var(--color-primary); -} - -.bookmarks-group-title { - color: var(--bookmarks-group-color); -} - -.bookmarks-group .bookmarks-link::after { - color: var(--bookmarks-group-color); -} - -.bookmarks-icon-container { - margin-block: 0.1rem; - background-color: var(--color-widget-background-highlight); - border-radius: var(--border-radius); - padding: 0.5rem; - opacity: 0.7; - flex-shrink: 0; -} - -.bookmarks-icon { - width: 20px; - height: 20px; - opacity: 0.8; -} - -:root:not(.light-scheme) .flat-icon { - filter: invert(1); -} - -.old-calendar-day { - width: calc(100% / 7); - text-align: center; - padding: 0.6rem 0; -} - -.old-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); -} - -.calendar-dates { - text-align: center; - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 2px; -} - -.calendar-date { - padding: 0.4rem 0; - color: var(--color-text-base); - position: relative; - border-radius: var(--border-radius); - background: none; - border: none; - font: inherit; -} - -.calendar-current-date { - border-radius: var(--border-radius); - background-color: var(--color-popover-border); - color: var(--color-text-highlight); -} - -.calendar-spillover-date { - color: var(--color-text-subdue); -} - -.calendar-header-button { - position: relative; - cursor: pointer; - width: 2rem; - height: 2rem; - z-index: 1; - background: none; - border: none; -} - -.calendar-header-button::before { - content: ''; - position: absolute; - inset: -0.2rem; - border-radius: var(--border-radius); - background-color: var(--color-text-subdue); - opacity: 0; - transition: opacity 0.2s; - z-index: -1; -} - -.calendar-header-button:hover::before { - opacity: 0.4; -} - -.calendar-undo-button { - display: inline-block; - vertical-align: text-top; - width: 2rem; - height: 2rem; - margin-left: 0.7rem; -} - -.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; - 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; - 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); - flex-basis: calc(var(--percent) - 1px); -} - -.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; - align-items: center; - justify-content: end; - flex-direction: column; - width: calc(100% / 12); - padding-top: 3px; -} - -.weather-column-value, .weather-columns:hover .weather-column-value { - font-size: 13px; - color: var(--color-text-highlight); - letter-spacing: -0.1rem; - margin-right: 0.1rem; - position: relative; - margin-bottom: 0.3rem; - opacity: 0; - transform: translateY(0.5rem); - transition: opacity .2s, transform .2s; - user-select: none; -} - -.weather-column-current .weather-column-value, .weather-column:hover .weather-column-value { - opacity: 1; - transform: translateY(0); -} - -.weather-column-value::after { - position: absolute; - content: '°'; - left: 100%; - color: var(--color-text-subdue); -} - -.weather-column-value.weather-column-value-negative::before { - position: absolute; - content: '-'; - right: 100%; -} - -.weather-bar, .weather-columns:hover .weather-bar { - height: calc(20px + var(--weather-bar-height) * 40px); - width: 6px; - background-color: hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 18%))); - border: 1px solid hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 24%))); - border-bottom: 0; - border-radius: 6px 6px 0 0; - mask-image: linear-gradient(0deg, transparent 0, #000 10px); - -webkit-mask-image: linear-gradient(0deg, transparent 0, #000 10px); - transition: background-color .2s, border-color .2s, width .2s; -} - -.weather-column-current .weather-bar, .weather-column:hover .weather-bar { - width: 10px; - background-color: hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 40%))); - border: 1px solid hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 50%))); -} - -.weather-column-rain { - position: absolute; - inset: 0; - bottom: 20%; - overflow: hidden; - mask-image: linear-gradient(0deg, transparent 40%, #000); - -webkit-mask-image: linear-gradient(0deg, transparent 40%, #000); -} - -.weather-column-rain::before { - content: ''; - position: absolute; - /* TODO: figure out a way to make it look continuous between columns, right now - depending on the width of the page the rain inside two columns next to each other - can overlap and look bad */ - background: radial-gradient(circle at 4px 4px, hsl(200, 90%, 70%, 0.4) 1px, transparent 0); - background-size: 8px 8px; - transform: rotate(45deg) translate(-50%, 25%); - height: 130%; - aspect-ratio: 1; - left: 55%; -} - -.weather-column:nth-child(3) .weather-column-time, -.weather-column:nth-child(7) .weather-column-time, -.weather-column:nth-child(11) .weather-column-time { - opacity: 1; - transform: translateY(0); -} - -.weather-column-time, .weather-columns:hover .weather-column-time { - margin-top: 0.3rem; - font-size: var(--font-size-h6); - opacity: 0; - transform: translateY(-0.5rem); - transition: opacity .2s, transform .2s; - user-select: none; -} - -.weather-column:hover .weather-column-time { - opacity: 1; - transform: translateY(0); -} - -.weather-column-daylight { - position: absolute; - inset: 0; - background: linear-gradient(0deg, transparent 30px, hsl(50, 50%, 30%, 0.2)); -} - -.weather-column-daylight-sunrise { - border-radius: 20px 0 0 0; -} - -.weather-column-daylight-sunset { - border-radius: 0 20px 0 0; -} - -.location-icon { - width: 0.8em; - height: 0.8em; - border-radius: 0 50% 50% 50%; - background-color: currentColor; - transform: rotate(225deg) translate(.1em, .1em); - position: relative; - flex-shrink: 0; -} - -.location-icon::after { - content: ''; - position: absolute; - z-index: 2; - width: .4em; - height: .4em; - border-radius: 50%; - background-color: var(--color-widget-background); - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} - -.clock-time { - min-width: 8ch; -} - -.clock-time span { - color: var(--color-text-highlight); -} - -.monitor-site-icon { - display: block; - opacity: 0.8; - filter: grayscale(0.4); - object-fit: contain; - aspect-ratio: 1 / 1; - width: 3.2rem; - position: relative; - top: -0.1rem; - transition: filter 0.3s, opacity 0.3s; -} - -.monitor-site-icon.flat-icon { - opacity: 0.7; -} - -.monitor-site:hover .monitor-site-icon { - opacity: 1; -} - -.monitor-site:hover .monitor-site-icon:not(.flat-icon) { - filter: grayscale(0); -} - -.monitor-site-status-icon { - flex-shrink: 0; - margin-left: auto; - width: 2rem; - height: 2rem; -} - -.monitor-site-status-icon-compact { - width: 1.8rem; - height: 1.8rem; - flex-shrink: 0; -} - -.docker-container-icon { - display: block; - filter: grayscale(0.4); - object-fit: contain; - aspect-ratio: 1 / 1; - width: 2.7rem; - opacity: 0.8; - transition: filter 0.3s, opacity 0.3s; -} - -.docker-container-icon.flat-icon { - opacity: 0.7; -} - -.docker-container:hover .docker-container-icon { - opacity: 1; -} - -.docker-container:hover .docker-container-icon:not(.flat-icon) { - filter: grayscale(0); -} - -.docker-container-status-icon { - width: 2rem; - height: 2rem; -} - -.widget-type-server-info { - position: relative; -} - -.server + .server { - margin-top: 3rem; -} - -.server { - gap: 1rem; - display: flex; - flex-direction: column; -} - -.server-info { - align-items: center; - display: flex; - justify-content: space-between; - gap: 1.5rem; - flex-shrink: 1; - min-width: 0; -} - -.server-details { - min-width: 0; -} - -.server-icon { - height: 3rem; - width: 3rem; -} - -.server-spicy-cpu-icon { - height: 1em; - align-self: center; - margin-left: 0.4em; - margin-bottom: 0.2rem; -} - -.server-stats { - display: flex; - gap: 1.5rem; - margin-top: 0.5rem; -} - -.server-stat-unavailable { - opacity: 0.5; -} - -.progress-bar { - border: 1px solid var(--color-progress-border); - border-radius: var(--border-radius); - display: flex; - flex-direction: column; - gap: 2px; - padding: 2px; - height: 1.5rem; - margin-inline: -3px; /* naughty, but oh so beautiful */ -} - -.progress-bar-combined { - height: 3rem; -} - -.popover-active > .progress-bar { - transition: border-color .3s; - border-color: var(--color-text-subdue); -} - -.progress-value { - --half-border-radius: calc(var(--border-radius) / 2); - border-radius: 0 var(--half-border-radius) var(--half-border-radius) 0; - background: var(--color-progress-value); - width: calc(var(--percent) * 1%); - min-width: 1px; - flex: 1; -} - -.progress-value:first-child { - border-top-left-radius: var(--half-border-radius); -} - -.progress-value:last-child { - border-bottom-left-radius: var(--half-border-radius); -} - -.progress-value-notice { - background: linear-gradient(to right, var(--color-progress-value) 65%, var(--color-negative)); -} - -.value-separator { - min-width: 2rem; - margin-inline: 0.8rem; - flex: 1; - height: calc(1em * 1.1); - border-bottom: 1px dotted var(--color-text-subdue); -} - -@container widget (min-width: 650px) { - .server { - gap: 2rem; - flex-direction: row; - align-items: center; - } - - .server + .server { - margin-top: 1rem; - } - - .server-info { - flex-direction: row-reverse; - justify-content: unset; - margin-right: auto; - z-index: 1; - } - - .server-stats { - flex-direction: row; - justify-content: right; - min-width: 450px; - margin-top: 0; - gap: 2rem; - padding-bottom: 0.8rem; - z-index: 1; - } - - .server-stats > * { - max-width: 200px; - } -} - -.thumbnail { - filter: grayscale(0.2) contrast(0.9); - opacity: 0.8; - transition: filter 0.2s, opacity .2s; -} - -.thumbnail-container { - flex-shrink: 0; - border: 1px solid var(--color-separator); - border-radius: var(--border-radius); -} - -.thumbnail-container > * { - border-radius: var(--border-radius); - object-fit: cover; -} - -.thumbnail-parent:hover .thumbnail { - opacity: 1; - filter: none; -} - -.rss-card-image { - height: var(--rss-thumbnail-height, 10rem); - object-fit: cover; - border-radius: var(--border-radius) var(--border-radius) 0 0; -} - -.rss-card-2 { - position: relative; - height: var(--rss-card-height, 27rem); - overflow: hidden; -} - -.rss-card-2::before { - content: ''; - position: absolute; - inset: 0; - pointer-events: none; - background-image: linear-gradient( - 0deg, - var(--color-widget-background), - hsla(var(--color-widget-background-hsl-values), 0.8) 6rem, transparent 14rem - ); - z-index: 2; -} - -.rss-card-2-image { - position: absolute; - width: 100%; - height: 100%; - object-fit: cover; - /* +1px is required to fix some weird graphical bug where the image overflows on the bottom in firefox */ - border-radius: calc(var(--border-radius) + 1px); - opacity: 0.9; - z-index: 1; -} - -.rss-card-2-content { - position: absolute; - inset-inline: 0; - bottom: var(--widget-content-vertical-padding); - z-index: 3; -} - -.rss-detailed-description { - max-width: 55rem; - color: var(--color-text-base-muted); -} - -.rss-detailed-thumbnail { - margin-top: 0.3rem; -} - -.rss-detailed-thumbnail > * { - aspect-ratio: 3 / 2; - height: 8.7rem; -} - -.twitch-category-thumbnail { - width: 5rem; - aspect-ratio: 3 / 4; - border-radius: var(--border-radius); -} - -.twitch-channel-avatar { - aspect-ratio: 1; - border-radius: 50%; -} - -.twitch-channel-avatar-container { - width: 4.4rem; - height: 4.4rem; - border: 2px solid var(--color-text-subdue); - padding: 2px; - border-radius: 50%; - position: relative; - flex-shrink: 0; -} - -.twitch-channel-live .twitch-channel-avatar-container { - border: 2px solid var(--color-positive); - margin-bottom: 1rem; -} - -.twitch-channel-live .twitch-channel-avatar-container::after { - content: 'LIVE'; - position: absolute; - background: var(--color-positive); - color: var(--color-widget-background); - font-size: var(--font-size-h6); - left: 50%; - bottom: -35%; - border-radius: var(--border-radius); - padding-inline: 0.3rem; - transform: translate(-50%); - border: 2px solid var(--color-widget-background); -} - -.twitch-stream-preview { - max-width: 100%; - width: 400px; - aspect-ratio: 16 / 9; - border-radius: var(--border-radius); - object-fit: cover; -} - -.reddit-card-thumbnail { - width: 100%; - height: 100%; - object-fit: cover; - object-position: 0% 20%; - opacity: 0.15; - filter: blur(1px); -} - -.reddit-card-thumbnail-container { - position: absolute; - inset: 0; - overflow: hidden; - border-radius: var(--border-radius); -} - -.reddit-card-thumbnail-container::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(0deg, var(--color-widget-background) 10%, transparent); -} - -@media (max-width: 1190px) { - .header-container { - display: none; - } - - .page-column-small .size-title-dynamic { - font-size: var(--font-size-h3); - } - - .page-column-small { - width: 100%; - flex-shrink: 1; - } - - .page-column { - display: none; - animation: columnEntrance .0s cubic-bezier(0.25, 1, 0.5, 1) backwards; - } - - .page-columns-transitioned .page-column { - animation-duration: .3s; - } - - @keyframes columnEntrance { - from { - opacity: 0; - transform: scaleX(0.95); - } - } - - .mobile-navigation-offset { - height: var(--mobile-navigation-height); - flex-shrink: 0; - } - - .mobile-navigation { - display: block; - position: fixed; - bottom: 0; - transform: translateY(calc(100% - var(--mobile-navigation-height))); - left: var(--content-bounds-padding); - right: var(--content-bounds-padding); - z-index: 11; - background-color: var(--color-widget-background); - border: 1px solid var(--color-widget-content-border); - border-bottom: 0; - border-radius: var(--border-radius) var(--border-radius) 0 0; - transition: transform .3s; - } - - .mobile-navigation:has(.mobile-navigation-page-links-input:checked) .hamburger-icon { - --spacing: 7px; - color: var(--color-primary); - height: 2px; - } - - .mobile-navigation:has(.mobile-navigation-page-links-input:checked) { - transform: translateY(0); - } - - .mobile-navigation-page-links { - border-top: 1px solid var(--color-widget-content-border); - padding: 15px var(--content-bounds-padding); - display: flex; - align-items: center; - overflow-x: auto; - scrollbar-width: thin; - gap: 2.5rem; - } - - .mobile-navigation-icons { - display: flex; - justify-content: space-around; - align-items: center; - } - - body:has(.mobile-navigation-input[value="0"]:checked) .page-columns > :nth-child(1), - body:has(.mobile-navigation-input[value="1"]:checked) .page-columns > :nth-child(2), - body:has(.mobile-navigation-input[value="2"]:checked) .page-columns > :nth-child(3) { - display: block; - } - - .mobile-navigation-label { - display: flex; - flex: 1; - max-width: 50px; - height: var(--mobile-navigation-height); - justify-content: center; - align-items: center; - cursor: pointer; - font-size: 15px; - line-height: var(--mobile-navigation-height); - } - - .mobile-navigation-pill { - display: block; - background: var(--color-text-base); - height: 10px; - width: 10px; - border-radius: 10px; - transition: width .3s, background-color .3s; - } - - .mobile-navigation-label:hover > .mobile-navigation-pill { - background-color: var(--color-text-highlight); - } - - .mobile-navigation-label:hover { - color: var(--color-text-highlight); - } - - .mobile-navigation-input:checked + .mobile-navigation-pill { - background: var(--color-primary); - width: 30px; - } - - .mobile-navigation-input, .mobile-navigation-page-links-input { - display: none; - } - - .hamburger-icon { - --spacing: 4px; - width: 1em; - height: 1px; - background-color: currentColor; - transition: color .3s, box-shadow .3s; - box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor; - } - - .expand-toggle-button.container-expanded { - bottom: var(--mobile-navigation-height); - } - - .cards-grid + .expand-toggle-button.container-expanded { - /* hides content that peeks through the rounded borders of the mobile navigation */ - box-shadow: 0 var(--border-radius) 0 0 var(--color-background); - } - - .weather-column-rain::before { - background-size: 7px 7px; - } - - .ios .search-input { - /* so that iOS Safari does not zoom the page when the input is focused */ - font-size: 16px; - } -} - -@media (max-width: 1190px) and (display-mode: standalone) { - :root { - --safe-area-inset-bottom: env(safe-area-inset-bottom, 0); - } - - .ios .body-content { - height: 100dvh; - } - - .expand-toggle-button.container-expanded { - bottom: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom)); - } - - .mobile-navigation { - transform: translateY(calc(100% - var(--mobile-navigation-height) - var(--safe-area-inset-bottom))); - padding-bottom: var(--safe-area-inset-bottom); - } - - .mobile-navigation-icons { - padding-bottom: var(--safe-area-inset-bottom); - transition: padding-bottom .3s; - } - - .mobile-navigation-offset { - height: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom)); - } - - .mobile-navigation-icons:has(.mobile-navigation-page-links-input:checked) { - padding-bottom: 0; - } -} - -@media (display-mode: standalone) { - body { - padding-top: env(safe-area-inset-top, 0); - } -} - -@media (max-width: 550px) { - :root { - font-size: 9.4px; - --widget-gap: 15px; - --widget-content-vertical-padding: 10px; - --widget-content-horizontal-padding: 10px; - --content-bounds-padding: 10px; - } - - .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; } - - .row-reverse-on-mobile { - flex-direction: row-reverse; - } - - .hide-on-mobile, .thumbnail-container:has(> .hide-on-mobile) { - display: none - } - - .mobile-reachability-header { - display: block; - font-size: 3rem; - padding: 10vh 1rem; - text-align: center; - color: var(--color-text-highlight); - animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards; - } - - .rss-detailed-thumbnail > * { - height: 6rem; - } - - .rss-detailed-description { - line-clamp: 3; - -webkit-line-clamp: 3; - } -} - -.size-h1 { font-size: var(--font-size-h1); } -.size-h2 { font-size: var(--font-size-h2); } -.size-h3 { font-size: var(--font-size-h3); } -.size-h4 { font-size: var(--font-size-h4); } -.size-base { font-size: var(--font-size-base); } -.size-h5 { font-size: var(--font-size-h5); } -.size-h6 { font-size: var(--font-size-h6); } - -.color-highlight { color: var(--color-text-highlight); } -.color-paragraph { color: var(--color-text-paragraph); } -.color-base { color: var(--color-text-base); } -.color-subdue { color: var(--color-text-subdue); } -.color-negative { color: var(--color-negative); } -.color-positive { color: var(--color-positive); } -.color-primary { color: var(--color-primary); } - -.cursor-help { cursor: help; } -.rounded { border-radius: var(--border-radius); } -.break-all { word-break: break-all; } -.text-left { text-align: left; } -.text-right { text-align: right; } -.text-center { text-align: center; } -.text-elevate { margin-top: -0.2em; } -.text-compact { word-spacing: -0.18em; } -.text-very-compact { word-spacing: -0.35em; } -.rtl { direction: rtl; } -.shrink { flex-shrink: 1; } -.shrink-0 { flex-shrink: 0; } -.min-width-0 { min-width: 0; } -.max-width-100 { max-width: 100%; } -.block { display: block; } -.inline-block { display: inline-block; } -.overflow-hidden { overflow: hidden; } -.relative { position: relative; } -.flex { display: flex; } -.flex-1 { flex: 1; } -.flex-wrap { flex-wrap: wrap; } -.flex-nowrap { flex-wrap: nowrap; } -.justify-between { justify-content: space-between; } -.justify-stretch { justify-content: stretch; } -.justify-evenly { justify-content: space-evenly; } -.justify-center { justify-content: center; } -.justify-end { justify-content: end; } -.uppercase { text-transform: uppercase; } -.grow { flex-grow: 1; } -.flex-column { flex-direction: column; } -.items-center { align-items: center; } -.items-start { align-items: start; } -.items-end { align-items: end; } -.gap-5 { gap: 0.5rem; } -.gap-7 { gap: 0.7rem; } -.gap-10 { gap: 1rem; } -.gap-12 { gap: 1.2rem; } -.gap-15 { gap: 1.5rem; } -.gap-20 { gap: 2rem; } -.gap-25 { gap: 2.5rem; } -.gap-35 { gap: 3.5rem; } -.gap-45 { gap: 4.5rem; } -.gap-55 { gap: 5.5rem; } -.margin-left-auto { margin-left: auto; } -.margin-top-3 { margin-top: 0.3rem; } -.margin-top-5 { margin-top: 0.5rem; } -.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; } -.margin-block-8 { margin-block: 0.8rem; } -.margin-block-10 { margin-block: 1rem; } -.margin-block-15 { margin-block: 1.5rem; } -.margin-bottom-3 { margin-bottom: 0.3rem; } -.margin-bottom-5 { margin-bottom: 0.5rem; } -.margin-bottom-7 { margin-bottom: 0.7rem; } -.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-8 { --list-half-gap: 0.4rem; } -.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/glance/static/manifest.json b/internal/glance/static/manifest.json deleted file mode 100644 index 42e8213..0000000 --- a/internal/glance/static/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "Glance", - "display": "standalone", - "background_color": "#151519", - "scope": "/", - "start_url": "/", - "icons": [ - { - "src": "app-icon.png", - "type": "image/png", - "sizes": "512x512" - } - ] -} diff --git a/internal/glance/templates.go b/internal/glance/templates.go index 699772d..8164eb0 100644 --- a/internal/glance/templates.go +++ b/internal/glance/templates.go @@ -5,6 +5,7 @@ import ( "html/template" "math" "strconv" + "strings" "golang.org/x/text/language" "golang.org/x/text/message" @@ -53,6 +54,7 @@ var globalTemplateFunctions = template.FuncMap{ return template.HTML(value + ` ` + label + ``) }, + "hasPrefix": strings.HasPrefix, } func mustParseTemplate(primary string, dependencies ...string) *template.Template { diff --git a/internal/glance/templates/bookmarks.html b/internal/glance/templates/bookmarks.html index f247bdb..1952cdb 100644 --- a/internal/glance/templates/bookmarks.html +++ b/internal/glance/templates/bookmarks.html @@ -2,22 +2,29 @@ {{ define "widget-content" }}
- {{ range .Groups }} + {{- range .Groups }}
- {{ if ne .Title "" }}
{{ .Title }}
{{ end }} + {{- if ne .Title "" }} +
{{ .Title }}
+ {{- end }}
    - {{ range .Links }} -
  • - {{ if ne "" .Icon.URL }} -
    - + {{- range .Links }} +
  • +
    + {{- if ne "" .Icon.URL }} +
    + +
    + {{- end }} + {{ .Title }}
    - {{ end }} - {{ .Title }} + {{- if .Description }} +
    {{ .Description }}
    + {{- end }}
  • - {{ end }} + {{- end }}
- {{ end }} + {{- end }}
{{ end }} diff --git a/internal/glance/templates/docker-containers.html b/internal/glance/templates/docker-containers.html index d84e9a6..afbbb17 100644 --- a/internal/glance/templates/docker-containers.html +++ b/internal/glance/templates/docker-containers.html @@ -14,7 +14,7 @@ {{- range .Children }}
  • {{ template "state-icon" .StateIcon }}
    -
    {{ .Title }} {{ .StateText }}
    +
    {{ .Name }} {{ .StateText }}
  • {{- end }} @@ -24,9 +24,9 @@
    {{- if .URL }} - {{ .Title }} + {{ .Name }} {{- else }} -
    {{ .Title }}
    +
    {{ .Name }}
    {{- end }} {{- if .Description }}
    {{ .Description }}
    diff --git a/internal/glance/templates/document.html b/internal/glance/templates/document.html index a26f854..76332b9 100644 --- a/internal/glance/templates/document.html +++ b/internal/glance/templates/document.html @@ -1,23 +1,33 @@ - + {{ block "document-head-before" . }}{{ end }} + {{ block "document-title" . }}{{ end }} - - - - - - - - + + + + + + + + + {{ if .App.Config.Theme.CustomCSSFile }}{{ end }} {{ block "document-head-after" . }}{{ end }} + {{ if .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }} {{ template "document-body" . }} diff --git a/internal/glance/templates/footer.html b/internal/glance/templates/footer.html new file mode 100644 index 0000000..5a76330 --- /dev/null +++ b/internal/glance/templates/footer.html @@ -0,0 +1,11 @@ +{{ if not .App.Config.Branding.HideFooter }} +
    +{{ if eq "" .App.Config.Branding.CustomFooter }} +
    + Glance {{ if ne "dev" .App.Version }}{{ .App.Version }}{{ else }}({{ .App.Version }}){{ end }} +
    +{{ else }} + {{ .App.Config.Branding.CustomFooter }} +{{ end }} +
    +{{ end }} diff --git a/internal/glance/templates/forum-posts.html b/internal/glance/templates/forum-posts.html index 05a57d8..5b65b69 100644 --- a/internal/glance/templates/forum-posts.html +++ b/internal/glance/templates/forum-posts.html @@ -23,7 +23,7 @@ {{- end }} {{- end }}
    - {{ .Title }} + {{ .Title }} {{- if .Tags }}