diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8708dce --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +# https://docs.docker.com/build/building/context/#dockerignore-files +# Ignore all files by default +* + +# Only add necessary files to the Docker build context (Dockerfiles are always included implicitly) +!/build/ +!/internal/ +!/go.mod +!/go.sum +!main.go diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..22b3d05 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ + diff --git a/Dockerfile b/Dockerfile index 2d6126d..a812eec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.19 +FROM alpine:3.20.2 ARG TARGETOS ARG TARGETARCH diff --git a/Dockerfile.single-platform b/Dockerfile.single-platform index 1930f99..0336871 100644 --- a/Dockerfile.single-platform +++ b/Dockerfile.single-platform @@ -1,7 +1,14 @@ -FROM alpine:3.19 +FROM golang:1.22.5-alpine3.20 AS builder WORKDIR /app -COPY build/glance /app/glance +COPY . /app +RUN CGO_ENABLED=0 go build . + + +FROM alpine:3.20.2 + +WORKDIR /app +COPY --from=builder /app/glance . EXPOSE 8080/tcp ENTRYPOINT ["/app/glance"] diff --git a/README.md b/README.md index 715c8e5..f4839d6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,10 @@ * Subreddit posts * Weather * Bookmarks +* Hacker News +* Lobsters * Latest YouTube videos from specific channels +* Clock * Calendar * Stocks * iframe @@ -18,6 +21,7 @@ * GitHub releases * Repository overview * Site monitor +* Search box #### Themeable ![multiple color schemes example](docs/images/themes-example.png) @@ -92,12 +96,6 @@ go run . ### Building Docker image -Build Glance with CGO disabled: - -```bash -CGO_ENABLED=0 go build -o build/glance . -``` - Build the image: **Make sure to replace "owner" with your name or organization.** diff --git a/docs/configuration.md b/docs/configuration.md index a88f811..478cdcd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,17 +10,24 @@ - [RSS](#rss) - [Videos](#videos) - [Hacker News](#hacker-news) + - [Lobsters](#lobsters) - [Reddit](#reddit) + - [Search](#search-widget) + - [Group](#group) + - [Extension](#extension) - [Weather](#weather) - [Monitor](#monitor) - [Releases](#releases) - [Repository](#repository) - [Bookmarks](#bookmarks) - [Calendar](#calendar) - - [Stocks](#stocks) + - [ChangeDetection.io](#changedetectionio) + - [Clock](#clock) + - [Markets](#markets) - [Twitch Channels](#twitch-channels) - [Twitch Top Games](#twitch-top-games) - [iframe](#iframe) + - [HTML](#html) ## Intro Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error. @@ -76,8 +83,8 @@ pages: - type: weather location: London, United Kingdom - - type: stocks - stocks: + - type: markets + markets: - symbol: SPY name: S&P 500 - symbol: BTC-USD @@ -228,6 +235,8 @@ theme: > .widget-type-rss a { > font-size: 1.5rem; > } +> +> In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets. ## Pages & Columns @@ -255,6 +264,8 @@ pages: | ---- | ---- | -------- | ------- | | title | string | yes | | | slug | string | no | | +| width | string | no | | +| hide-desktop-navigation | boolean | no | false | | show-mobile-header | boolean | no | false | | columns | array | yes | | @@ -264,6 +275,21 @@ The name of the page which gets shown in the navigation bar. #### `slug` The URL friendly version of the title which is used to access the page. For example if the title of the page is "RSS Feeds" you can make the page accessible via `localhost:8080/feeds` by setting the slug to `feeds`. If not defined, it will automatically be generated from the title. +#### `width` +The maximum width of the page on desktop. Possible values are `slim` and `wide`. + +* default: `1600px` +* slim: `1100px` +* wide: `1920px` + +> [!NOTE] +> +> When using `slim`, the maximum number of columns allowed for that page is `2`. + + +#### `hide-desktop-navigation` +Whether to show the navigation links at the top of the page on desktop. + #### `show-mobile-header` Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices. @@ -348,7 +374,9 @@ pages: | ---- | ---- | -------- | | type | string | yes | | title | string | no | +| title-url | string | no | | cache | string | no | +| css-class | string | no | #### `type` Used to specify the widget. @@ -356,6 +384,9 @@ Used to specify the widget. #### `title` The title of the widget. If left blank it will be defined by the widget. +#### `title-url` +The URL to go to when clicking on the widget's title. If left blank it will be defined by the widget (if available). + #### `cache` How long to keep the fetched data in memory. The value is a string and must be a number followed by one of s, m, h, d. Examples: @@ -370,6 +401,9 @@ cache: 1d # 1 day > > Not all widgets can have their cache duration modified. The calendar and weather widgets update on the hour and this cannot be changed. +#### `css-class` +Set custom CSS classes for the specific widget instance. + ### RSS Display a list of articles from multiple RSS feeds. @@ -405,6 +439,10 @@ Used to change the appearance of the widget. Possible values are `vertical-list` ![preview of vertical-list style for RSS widget](images/rss-feed-vertical-list-preview.png) +`detailed-list` + +![preview of detailed-list style for RSS widget](images/rss-widget-detailed-list-preview.png) + `horizontal-cards` ![preview of horizontal-cards style for RSS widget](images/rss-feed-horizontal-cards-preview.png) @@ -423,10 +461,16 @@ Used to modify the height of cards when using the `horizontal-cards-2` style. Th An array of RSS/atom feeds. The title can optionally be changed. ###### Properties for each feed -| Name | Type | Required | Default | -| ---- | ---- | -------- | ------- | -| url | string | yes | | -| title | string | no | the title provided by the feed | +| Name | Type | Required | Default | Notes | +| ---- | ---- | -------- | ------- | ----- | +| url | string | yes | | | +| title | string | no | the title provided by the feed | | +| hide-categories | boolean | no | false | Only applicable for `detailed-list` style | +| hide-description | boolean | no | false | Only applicable for `detailed-list` style | +| item-link-prefix | string | no | | | + +###### `item-link-prefix` +If an RSS feed isn't returning item links with a base domain and Glance has failed to automatically detect the correct domain you can manually add a prefix to each link with this property. ##### `limit` The maximum number of articles to show. @@ -456,6 +500,8 @@ Preview: | channels | array | yes | | | limit | integer | no | 25 | | style | string | no | horizontal-cards | +| collapse-after-rows | integer | no | 4 | +| include-shorts | boolean | no | false | | video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} | ##### `channels` @@ -470,6 +516,9 @@ Then scroll down and click on "Share channel", then "Copy channel ID": ##### `limit` The maximum number of videos to show. +##### `collapse-after-rows` +Specify the number of rows to show when using the `grid-cards` style before the "SHOW MORE" button appears. + ##### `style` Used to change the appearance of the widget. Possible values are `horizontal-cards` and `grid-cards`. @@ -530,6 +579,57 @@ 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. +### Lobsters +Display a list of posts from [Lobsters](https://lobste.rs). + +Example: + +```yaml +- type: lobsters + sort-by: hot + tags: + - go + - security + - linux + limit: 15 + collapse-after: 5 +``` + +Preview: +![](images/lobsters-widget-preview.png) + +#### Properties +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| instance-url | string | no | https://lobste.rs/ | +| custom-url | string | no | | +| limit | integer | no | 15 | +| collapse-after | integer | no | 5 | +| sort-by | string | no | hot | +| tags | array | no | | + +##### `instance-url` +The base URL for a lobsters instance hosted somewhere other than on lobste.rs. Example: + +```yaml +instance-url: https://www.journalduhacker.net/ +``` + +##### `custom-url` +A custom URL to retrieve lobsters posts from. If this is specified, the `instance-url`, `sort-by` and `tags` properties are ignored. + +##### `limit` +The maximum number of posts to show. + +##### `collapse-after` +How many posts are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse. + +##### `sort-by` +The sort order in which posts are returned. Possible options are `hot` and `new`. + +##### `tags` +Limit to posts containing one of the given tags. **You cannot specify a sort order when filtering by tags, it will default to `hot`.** + ### Reddit Display a list of posts from a specific subreddit. @@ -626,7 +726,7 @@ https://your.proxy/?url={REQUEST-URL} ##### `sort-by` Can be used to specify the order in which the posts should get returned. Possible values are `hot`, `new`, `top` and `rising`. -##### `top-perid` +##### `top-period` Available only when `sort-by` is set to `top`. Possible values are `hour`, `day`, `week`, `month`, `year` and `all`. ##### `search` @@ -639,6 +739,167 @@ 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. +### Search Widget +Display a search bar that can be used to search for specific terms on various search engines. + +Example: + +```yaml +- type: search + search-engine: duckduckgo + bangs: + - title: YouTube + shortcut: "!yt" + url: https://www.youtube.com/results?search_query={QUERY} +``` + +Preview: + +![](images/search-widget-preview.png) + +#### Keyboard shortcuts +| Keys | Action | Condition | +| ---- | ------ | --------- | +| S | Focus the search bar | Not already focused on another input field | +| Enter | Perform search in the same tab | Search input is focused and not empty | +| Ctrl + Enter | Perform search in a new tab | Search input is focused and not empty | +| Escape | Leave focus | Search input is focused | + +> [!TIP] +> +> You can use the property `new-tab` with a value of `true` if you want to show search results in a new tab by default. Ctrl + Enter will then show results in the same tab. + +#### Properties +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| search-engine | string | no | duckduckgo | +| new-tab | boolean | no | false | +| autofocus | boolean | no | false | +| bangs | array | no | | + +##### `search-engine` +Either a value from the table below or a URL to a custom search engine. Use `{QUERY}` to indicate where the query value gets placed. + +| Name | URL | +| ---- | --- | +| duckduckgo | `https://duckduckgo.com/?q={QUERY}` | +| google | `https://www.google.com/search?q={QUERY}` | + +##### `new-tab` +When set to `true`, swaps the shortcuts for showing results in the same or new tab, defaulting to showing results in a new tab. + +##### `new-tab` +When set to `true`, automatically focuses the search input on page load. + +##### `bangs` +What now? [Bangs](https://duckduckgo.com/bangs). They're shortcuts that allow you to use the same search box for many different sites. Assuming you have it configured, if for example you start your search input with `!yt` you'd be able to perform a search on YouTube: + +![](images/search-widget-bangs-preview.png) + +##### Properties for each bang +| Name | Type | Required | +| ---- | ---- | -------- | +| title | string | no | +| shortcut | string | yes | +| url | string | yes | + +###### `title` +Optional title that will appear on the right side of the search bar when the query starts with the associated shortcut. + +###### `shortcut` +Any value you wish to use as the shortcut for the search engine. It does not have to start with `!`. + +> [!IMPORTANT] +> +> In YAML some characters have special meaning when placed in the beginning of a value. If your shortcut starts with `!` (and potentially some other special characters) you'll have to wrap the value in quotes: +> ```yaml +> shortcut: "!yt" +>``` + +###### `url` +The URL of the search engine. Use `{QUERY}` to indicate where the query value gets placed. Examples: + +```yaml +url: https://www.reddit.com/search?q={QUERY} +url: https://store.steampowered.com/search/?term={QUERY} +url: https://www.amazon.com/s?k={QUERY} +``` + +### Group +Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget within a group widget. + +Example: + +```yaml +- type: group + widgets: + - type: reddit + subreddit: gamingnews + show-thumbnails: true + collapse-after: 6 + - type: reddit + subreddit: games + - type: reddit + subreddit: pcgaming + show-thumbnails: true +``` + +Preview: + +![](images/group-widget-preview.png) + +#### Sharing properties + +To avoid repetition you can use [YAML anchors](https://support.atlassian.com/bitbucket-cloud/docs/yaml-anchors/) and share properties between widgets. + +Example: + +```yaml +- type: group + define: &shared-properties + type: reddit + show-thumbnails: true + collapse-after: 6 + widgets: + - subreddit: gamingnews + <<: *shared-properties + - subreddit: games + <<: *shared-properties + - subreddit: pcgaming + <<: *shared-properties +``` + +### Extension +Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP). + +```yaml +- type: extension + url: https://domain.com/widget/display-a-message + allow-potentially-dangerous-html: true + parameters: + message: Hello, world! +``` + +#### Properties +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| url | string | yes | | +| allow-potentially-dangerous-html | boolean | no | false | +| parameters | key & value | no | | + +##### `url` +The URL of the extension. + +##### `allow-potentially-dangerous-html` +Whether to allow the extension to display HTML. + +> [!WARNING] +> +> There's a reason this property is scary-sounding. It's intended to be used by developers who are comfortable with developing and using their own extensions. Do not enable it if you have no idea what it means or if you're not **absolutely sure** that the extension URL you're using is safe. + +##### `parameters` +A list of keys and values that will be sent to the extension as query paramters. + ### Weather Display weather information for a specific location. The data is provided by https://open-meteo.com/. @@ -702,7 +963,7 @@ Greenville, United States ``` ### Monitor -Display a list of sites and whether they are reachable (online) or not. This is determined by sending a HEAD 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. +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. Example: @@ -753,7 +1014,9 @@ Properties for each site: | ---- | ---- | -------- | ------- | | title | string | yes | | | url | string | yes | | +| check-url | string | no | | | icon | string | no | | +| allow-insecure | boolean | no | false | | same-tab | boolean | no | false | `title` @@ -762,11 +1025,29 @@ The title used to indicate the site. `url` -The URL which will be requested and its response will determine the status of the site. Optionally, you can specify this using an environment variable with the syntax `${VARIABLE_NAME}`. +The public facing URL of a monitored service, the user will be redirected here. If `check-url` is not specified, this is used as the status check. + +`check-url` + +The URL which will be requested and its response will determine the status of the site. If not specified, the `url` property is used. `icon` -Optional URL to an image which will be used as the icon for the site. Can be an external URL or internal via [server configured assets](#assets-path). +Optional URL to an image which will be used as the icon for the site. Can be an external URL or internal via [server configured assets](#assets-path). You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix: + +```yaml +icon: si:jellyfin +icon: si:gitea +icon: si:adguard +``` + +> [!WARNING] +> +> Simple Icons are loaded externally and are hosted on `cdnjs.cloudflare.com`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally. + +`allow-insecure` + +Whether to ignore invalid/self-signed certificates. `same-tab` @@ -963,6 +1244,98 @@ Whether to open the link in the same tab or a new one. Whether to hide the colored arrow on each link. +### ChangeDetection.io +Display a list watches from changedetection.io. + +Example + +```yaml +- type: change-detection + instance-url: https://changedetection.mydomain.com/ + token: ${CHANGE_DETECTION_TOKEN} +``` + +Preview: + +![](images/change-detection-widget-preview.png) + +#### Properties + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| instance-url | string | no | `https://www.changedetection.io` | +| token | string | no | | +| limit | integer | no | 10 | +| collapse-after | integer | no | 5 | +| watches | array of strings | no | | + +##### `instance-url` +The URL pointing to your instance of `changedetection.io`. + +##### `token` +The API access token which can be found in `SETTINGS > API`. Optionally, you can specify this using an environment variable with the syntax `${VARIABLE_NAME}`. + +##### `limit` +The maximum number of watches to show. + +##### `collapse-after` +How many watches are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse. + +##### `watches` +By default all of the configured watches will be shown. Optionally, you can specify a list of UUIDs for the specific watches you want to have listed: + +```yaml + - type: change-detection + watches: + - 1abca041-6d4f-4554-aa19-809147f538d3 + - 705ed3e4-ea86-4d25-a064-822a6425be2c +``` + +### Clock +Display a clock showing the current time and date. Optionally, also display the the time in other timezones. + +Example: + +```yaml +- type: clock + hour-format: 24h + timezones: + - timezone: Europe/Paris + label: Paris + - timezone: America/New_York + label: New York + - timezone: Asia/Tokyo + label: Tokyo +``` + +Preview: + +![](images/clock-widget-preview.png) + +#### Properties + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| hour-format | string | no | 24h | +| timezones | array | no | | + +##### `hour-format` +Whether to show the time in 12 or 24 hour format. Possible values are `12h` and `24h`. + +#### Properties for each timezone + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| timezone | string | yes | | +| label | string | no | | + +##### `timezone` +A timezone identifier such as `Europe/London`, `America/New_York`, etc. The full list of available identifiers can be found [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). + +##### `label` +Optionally, override the display value for the timezone to something more meaningful such as "Home", "Work" or anything else. + + ### Calendar Display a calendar. @@ -980,14 +1353,14 @@ Preview: > > There is currently no customizability available for the calendar. Extra features will be added in the future. -### Stocks -Display a list of stocks, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance. +### Markets +Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance. Example: ```yaml -- type: stocks - stocks: +- type: markets + markets: - symbol: SPY name: S&P 500 - symbol: BTC-USD @@ -1002,21 +1375,21 @@ Example: Preview: -![](images/stocks-widget-preview.png) +![](images/markets-widget-preview.png) #### Properties | Name | Type | Required | | ---- | ---- | -------- | -| stocks | array | yes | +| markets | array | yes | | sort-by | string | no | | style | string | no | -##### `stocks` -An array of stocks for which to display information about. +##### `markets` +An array of markets for which to display information about. ##### `sort-by` -By default the stocks are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change. +By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change. ##### `style` To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option. @@ -1141,3 +1514,16 @@ The source of the iframe. ##### `height` The height of the iframe. The minimum allowed height is 50. + +### HTML +Embed any HTML. + +Example: + +```yaml +- type: html + source: | +

Hello, World!

+``` + +Note the use of `|` after `source:`, this allows you to insert a multi-line string. diff --git a/docs/extensions.md b/docs/extensions.md new file mode 100644 index 0000000..06db1ae --- /dev/null +++ b/docs/extensions.md @@ -0,0 +1,158 @@ +# Extensions + +> [!IMPORTANT] +> +> **This document as well as the extensions feature are a work in progress. The API may change in the future. You are responsible for maintaining your own extensions.** + +## Overview + +With the intention of requiring minimal knowledge in order to develop extensions, rather than being a convoluted protocol they are nothing more than an HTTP request to a server that returns a few special headers. The exchange between Glance and extensions can be seen in the following diagram: + +![](images/extension-overview.png) + +If you know how to setup an HTTP server and a bit of HTML and CSS you're ready to start building your own extensions. + +> [!TIP] +> +> By default, the extension widget has a cache time of 30 minutes. To avoid having to restart Glance after every extension change you can set the cache time of the widget to 1 second: +> ```yaml +> - type: extension +> url: http://localhost:8081 +> cache: 1s +> ``` + +## Headers + +### `Widget-Title` +Used to specify the title of the widget. If not provided, the widget's title will be "Extension". + +### `Widget-Content-Type` +Used to specify the content type that will be returned by the extension. If not provided, the content will be shown as plain text. + +## Content Types + +> [!NOTE] +> +> Currently, `html` is the only supported content type. The long-term goal is to have generic content types such as `videos`, `forum-posts`, `markets`, `streams`, etc. which will be returned in JSON format and displayed by Glance using existing styles and functionality, allowing extension developers to achieve a native look while only focusing on providing data from their preferred source. + +### `html` +Displays the content as HTML. This requires the user to have the `allow-potentially-dangerous-html` property set to `true`, otherwise the content will be shown as plain text. + + +#### Using existing classes and functionality +Most of the features seen throughout Glance can easily be used in your custom HTML extensions. Below is an example of some of these features: + +```html +

Text with subdued color

+

Text with base color

+

Text with highlighted color

+

Text with primary color

+

Text with positive color

+

Text with negative color

+ +
+ +

Font size 1

+

Font size 2

+

Font size 3

+

Font size 4

+

Font size base

+

Font size 5

+

Font size 6

+ +
+ +Link with visited indicator + +
+ +Link with primary color if not visited + +
+ +

Event happened ago

+ +
+ + + +
+ + + +
+ + + +
+ +

Lazily loaded image:

+ + + +
+ +

List of posts:

+ + +``` + +All of that will result in the following: + +![](images/extension-html-reusing-existing-features-preview.png) + +**Class names or features may change, once again, you are responsible for maintaining your own extensions.** diff --git a/docs/images/change-detection-widget-preview.png b/docs/images/change-detection-widget-preview.png new file mode 100644 index 0000000..74b7fe7 Binary files /dev/null and b/docs/images/change-detection-widget-preview.png differ diff --git a/docs/images/clock-widget-preview.png b/docs/images/clock-widget-preview.png new file mode 100644 index 0000000..bf809c5 Binary files /dev/null and b/docs/images/clock-widget-preview.png differ diff --git a/docs/images/extension-html-reusing-existing-features-preview.png b/docs/images/extension-html-reusing-existing-features-preview.png new file mode 100644 index 0000000..3fbdbef Binary files /dev/null and b/docs/images/extension-html-reusing-existing-features-preview.png differ diff --git a/docs/images/extension-overview.png b/docs/images/extension-overview.png new file mode 100644 index 0000000..6c23452 Binary files /dev/null and b/docs/images/extension-overview.png differ diff --git a/docs/images/group-widget-preview.png b/docs/images/group-widget-preview.png new file mode 100644 index 0000000..1380937 Binary files /dev/null and b/docs/images/group-widget-preview.png differ diff --git a/docs/images/lobsters-widget-preview.png b/docs/images/lobsters-widget-preview.png new file mode 100644 index 0000000..9648d6d Binary files /dev/null and b/docs/images/lobsters-widget-preview.png differ diff --git a/docs/images/stocks-widget-preview.png b/docs/images/markets-widget-preview.png similarity index 100% rename from docs/images/stocks-widget-preview.png rename to docs/images/markets-widget-preview.png diff --git a/docs/images/rss-widget-detailed-list-preview.png b/docs/images/rss-widget-detailed-list-preview.png new file mode 100644 index 0000000..8cf1f11 Binary files /dev/null and b/docs/images/rss-widget-detailed-list-preview.png differ diff --git a/docs/images/search-widget-bangs-preview.png b/docs/images/search-widget-bangs-preview.png new file mode 100644 index 0000000..9490690 Binary files /dev/null and b/docs/images/search-widget-bangs-preview.png differ diff --git a/docs/images/search-widget-preview.png b/docs/images/search-widget-preview.png new file mode 100644 index 0000000..9672a77 Binary files /dev/null and b/docs/images/search-widget-preview.png differ diff --git a/docs/images/themes/kanagawa-dark.png b/docs/images/themes/kanagawa-dark.png new file mode 100644 index 0000000..7ca4bf1 Binary files /dev/null and b/docs/images/themes/kanagawa-dark.png differ diff --git a/docs/themes.md b/docs/themes.md index 95539ea..b4185db 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -53,6 +53,16 @@ theme: primary-color: 97 13 80 ``` +### Kanagawa Dark +![screenshot](images/themes/kanagawa-dark.png) +```yaml +theme: + background-color: 240 13 14 + primary-color: 51 33 68 + negative-color: 358 100 68 + contrast-multiplier: 1.2 +``` + ### Tucan ![screenshot](images/themes/tucan.png) ```yaml diff --git a/go.mod b/go.mod index b4a1e72..17aa4d4 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,19 @@ module github.com/glanceapp/glance -go 1.22.0 +go 1.22.5 require ( github.com/mmcdole/gofeed v1.3.0 - golang.org/x/text v0.14.0 + golang.org/x/text v0.16.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/PuerkitoBio/goquery v1.9.1 // indirect + github.com/PuerkitoBio/goquery v1.9.2 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mmcdole/goxpp v1.1.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - golang.org/x/net v0.24.0 // indirect + golang.org/x/net v0.27.0 // indirect ) diff --git a/go.sum b/go.sum index 54489e6..28cb1ae 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI= -github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= +github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= +github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -33,8 +33,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= -golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -54,8 +54,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/assets/files.go b/internal/assets/files.go index bfb2b4c..2c7c09e 100644 --- a/internal/assets/files.go +++ b/internal/assets/files.go @@ -1,8 +1,14 @@ package assets import ( + "crypto/md5" "embed" + "encoding/hex" + "io" "io/fs" + "log/slog" + "strconv" + "time" ) //go:embed static @@ -13,3 +19,38 @@ var _templateFS embed.FS var PublicFS, _ = fs.Sub(_publicFS, "static") var TemplateFS, _ = fs.Sub(_templateFS, "templates") + +func getFSHash(files fs.FS) string { + hash := md5.New() + + err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + file, err := files.Open(path) + + if err != nil { + return err + } + + if _, err := io.Copy(hash, file); err != nil { + return err + } + + return nil + }) + + if err == nil { + return hex.EncodeToString(hash.Sum(nil))[:10] + } + + slog.Warn("Could not compute assets cache", "err", err) + return strconv.FormatInt(time.Now().Unix(), 10) +} + +var PublicFSHash = getFSHash(PublicFS) diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css index 6ed4c12..2afbf17 100644 --- a/internal/assets/static/main.css +++ b/internal/assets/static/main.css @@ -37,6 +37,7 @@ --ths: var(--bgh), calc(var(--bgs) * var(--tsm)); --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-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%)); --color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%)); @@ -57,6 +58,10 @@ font-size: var(--font-size-h4); } +.page { + height: 100%; +} + .page-content, .page.content-ready .page-loading-container { display: none; } @@ -79,14 +84,16 @@ white-space: nowrap; } -.text-truncate-3-lines { +.text-truncate-2-lines, .text-truncate-3-lines { overflow: hidden; text-overflow: ellipsis; - -webkit-line-clamp: 3; display: -webkit-box; -webkit-box-orient: vertical; } +.text-truncate-3-lines { -webkit-line-clamp: 3; } +.text-truncate-2-lines { -webkit-line-clamp: 2; } + .visited-indicator:not(.text-truncate)::after, .visited-indicator.text-truncate::before, .bookmarks-link:not(.bookmarks-link-no-arrow)::after { @@ -114,6 +121,7 @@ .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; } .list > *:not(:first-child) { margin-top: calc(var(--list-half-gap) * 2); @@ -180,6 +188,57 @@ transform: rotate(-90deg); } +.widget-group-header { + overflow-x: auto; + scrollbar-width: thin; +} + +.widget-group-title { + background: none; + font: inherit; + border: none; + color: inherit; + text-transform: uppercase; + border-bottom: 1px solid transparent; + cursor: pointer; + flex-shrink: 0; + padding-bottom: 0.1rem; + transition: color .3s, border-color .3s; +} + +.widget-group-title:hover:not(.widget-group-title-current) { + border-bottom-color: var(--color-text-subdue); + color: var(--color-text-highlight); +} + +.widget-group-title-current { + border-bottom-color: var(--color-primary); + color: var(--color-text-highlight); +} + +.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; } @@ -190,9 +249,17 @@ background-color: var(--color-background); } -/* required to prevent collapsed lazy images from being loaded while the container is being setup */ -.collapsible-container:not(.ready) img[loading=lazy] { - display: none; +.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); } ::selection { @@ -248,9 +315,14 @@ html { scroll-behavior: smooth; } +html, body { + height: 100%; +} + a { text-decoration: none; color: inherit; + overflow-wrap: break-word; } ul { @@ -280,9 +352,8 @@ body { .page-columns { display: flex; gap: var(--widget-gap); - margin: var(--widget-gap) 0; + margin-top: var(--widget-gap); animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards; - animation-delay: 3ms; } @keyframes pageColumnsEntrance { @@ -293,8 +364,11 @@ body { } .page-loading-container { - margin: 50px auto; - width: fit-content; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + transform: translateY(-5rem); animation: loadingContainerEntrance 200ms backwards; animation-delay: 150ms; font-size: 2rem; @@ -342,12 +416,38 @@ body { 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); } +.page-width-wide .content-bounds { + max-width: 1920px; +} + +.page-width-slim .content-bounds { + max-width: 1100px; +} + .dynamic-columns { gap: calc(var(--widget-content-vertical-padding) / 2); display: grid; @@ -566,7 +666,7 @@ body { } .footer { - margin-block: calc(var(--widget-gap) * 1.5); + padding-block: calc(var(--widget-gap) * 1.5); animation: loadingContainerEntrance 200ms backwards; animation-delay: 150ms; } @@ -593,16 +693,16 @@ body { color: var(--color-text-highlight); } -.stock-chart { +.market-chart { margin-left: auto; width: 6.5rem; } -.stock-chart svg { +.market-chart svg { width: 100%; } -.stock-values { +.market-values { min-width: 8rem; } @@ -653,6 +753,86 @@ body { -webkit-box-orient: vertical; } +.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-item { display: flex; gap: 1.2rem; @@ -668,6 +848,10 @@ body { margin-top: 0.1rem; } +.forum-post-tags-container { + transform: translateY(-0.15rem); +} + .bookmarks-group { --bookmarks-group-color: var(--color-primary); } @@ -721,7 +905,7 @@ body { flex-direction: column; width: calc(100% / 12); padding-top: 3px; - max-width: 3rem; + max-width: 30px; } .weather-column-value, .weather-columns:hover .weather-column-value { @@ -855,6 +1039,10 @@ body { transform: translate(-50%, -50%); } +.clock-time span { + color: var(--color-text-highlight); +} + .monitor-site-icon { display: block; opacity: 0.8; @@ -885,7 +1073,18 @@ body { transition: filter 0.2s, opacity .2s; } -.thumbnail-container:hover .thumbnail { +.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; } @@ -933,8 +1132,23 @@ body { 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); } @@ -1011,10 +1225,10 @@ body { .page-column { display: none; - animation: columnEntrance 0s cubic-bezier(0.25, 1, 0.5, 1) backwards; + animation: columnEntrance .0s cubic-bezier(0.25, 1, 0.5, 1) backwards; } - .animate-element-transition .page-column { + .page-columns-transitioned .page-column { animation-duration: .3s; } @@ -1025,8 +1239,14 @@ body { } } - body { - padding-bottom: calc(var(--mobile-navigation-height) + var(--content-bounds-padding)); + .mobile-navigation-offset { + height: var(--mobile-navigation-height); + margin-top: var(--widget-gap); + flex-shrink: 0; + } + + .footer + .mobile-navigation-offset { + margin-top: 0; } .mobile-navigation { @@ -1059,7 +1279,7 @@ body { padding: 15px var(--content-bounds-padding); display: flex; align-items: center; - overflow-x: scroll; + overflow-x: auto; gap: 2.5rem; } @@ -1130,6 +1350,10 @@ body { /* 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; + } } @media (max-width: 1190px) and (display-mode: standalone) { @@ -1173,11 +1397,11 @@ body { .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; } - .forum-post-list-item { - flex-flow: row-reverse; + .row-reverse-on-mobile { + flex-direction: row-reverse; } - .hide-on-mobile { + .hide-on-mobile, .thumbnail-container:has(> .hide-on-mobile) { display: none } @@ -1189,6 +1413,14 @@ body { 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 { + -webkit-line-clamp: 3; + } } .size-h1 { font-size: var(--font-size-h1); } @@ -1216,7 +1448,10 @@ body { .shrink { flex-shrink: 1; } .shrink-0 { flex-shrink: 0; } .min-width-0 { min-width: 0; } +.max-width-100 { max-width: 100%; } +.height-100 { height: 100%; } .block { display: block; } +.inline-block { display: inline-block; } .overflow-hidden { overflow: hidden; } .relative { position: relative; } .flex { display: flex; } @@ -1224,6 +1459,7 @@ body { .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; } @@ -1235,11 +1471,17 @@ body { .gap-7 { gap: 0.7rem; } .gap-10 { gap: 1rem; } .gap-15 { gap: 1.5rem; } +.gap-20 { gap: 2rem; } +.gap-25 { gap: 2.5rem; } +.gap-35 { gap: 3.5rem; } +.gap-45 { gap: 4.5rem; } +.gap-55 { gap: 5.5rem; } .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-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; } @@ -1251,3 +1493,4 @@ body { .margin-bottom-10 { margin-bottom: 1rem; } .margin-bottom-15 { margin-bottom: 1.5rem; } .margin-bottom-auto { margin-bottom: auto; } +.scale-half { transform: scale(0.5); } diff --git a/internal/assets/static/main.js b/internal/assets/static/main.js index 5426c29..1ac26ca 100644 --- a/internal/assets/static/main.js +++ b/internal/assets/static/main.js @@ -59,9 +59,9 @@ function setupCarousels() { const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100); itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited); - document.addEventListener("resize", determineSideCutoffsRateLimited); + window.addEventListener("resize", determineSideCutoffsRateLimited); - setTimeout(determineSideCutoffs, 1); + afterContentReady(determineSideCutoffs); } } @@ -103,7 +103,108 @@ function updateRelativeTimeForElements(elements) if (timestamp === undefined) continue - element.innerText = relativeTimeSince(timestamp); + element.textContent = relativeTimeSince(timestamp); + } +} + +function setupSearchBoxes() { + const searchWidgets = document.getElementsByClassName("search"); + + if (searchWidgets.length == 0) { + return; + } + + for (let i = 0; i < searchWidgets.length; i++) { + const widget = searchWidgets[i]; + const defaultSearchUrl = widget.dataset.defaultSearchUrl; + const newTab = widget.dataset.newTab === "true"; + const inputElement = widget.getElementsByClassName("search-input")[0]; + const bangElement = widget.getElementsByClassName("search-bang")[0]; + const bangs = widget.querySelectorAll(".search-bangs > input"); + const bangsMap = {}; + const kbdElement = widget.getElementsByTagName("kbd")[0]; + let currentBang = null; + + for (let j = 0; j < bangs.length; j++) { + const bang = bangs[j]; + bangsMap[bang.dataset.shortcut] = bang; + } + + const handleKeyDown = (event) => { + if (event.key == "Escape") { + inputElement.blur(); + return; + } + + if (event.key == "Enter") { + const input = inputElement.value.trim(); + let query; + let searchUrlTemplate; + + if (currentBang != null) { + query = input.slice(currentBang.dataset.shortcut.length + 1); + searchUrlTemplate = currentBang.dataset.url; + } else { + query = input; + searchUrlTemplate = defaultSearchUrl; + } + if (query.length == 0 && currentBang == null) { + return; + } + + const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query)); + + if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) { + window.open(url, '_blank').focus(); + } else { + window.location.href = url; + } + + return; + } + }; + + const changeCurrentBang = (bang) => { + currentBang = bang; + bangElement.textContent = bang != null ? bang.dataset.title : ""; + } + + const handleInput = (event) => { + const value = event.target.value.trim(); + if (value in bangsMap) { + changeCurrentBang(bangsMap[value]); + return; + } + + const words = value.split(" "); + if (words.length >= 2 && words[0] in bangsMap) { + changeCurrentBang(bangsMap[words[0]]); + return; + } + + changeCurrentBang(null); + }; + + inputElement.addEventListener("focus", () => { + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("input", handleInput); + }); + inputElement.addEventListener("blur", () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("input", handleInput); + }); + + document.addEventListener("keydown", (event) => { + if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; + if (event.key != "s") return; + + inputElement.focus(); + event.preventDefault(); + }); + + kbdElement.addEventListener("mousedown", () => { + requestAnimationFrame(() => inputElement.focus()); + }); } } @@ -149,6 +250,46 @@ function setupDynamicRelativeTime() { }); } +function setupGroups() { + const groups = document.getElementsByClassName("widget-type-group"); + + if (groups.length == 0) { + return; + } + + for (let g = 0; g < groups.length; g++) { + const group = groups[g]; + const titles = group.getElementsByClassName("widget-header")[0].children; + const tabs = group.getElementsByClassName("widget-group-contents")[0].children; + let current = 0; + + for (let t = 0; t < titles.length; t++) { + const title = titles[t]; + title.addEventListener("click", () => { + if (t == current) { + return; + } + + for (let i = 0; i < titles.length; i++) { + titles[i].classList.remove("widget-group-title-current"); + tabs[i].classList.remove("widget-group-content-current"); + } + + if (current < t) { + tabs[t].dataset.direction = "right"; + } else { + tabs[t].dataset.direction = "left"; + } + + current = t; + + title.classList.add("widget-group-title-current"); + tabs[t].classList.add("widget-group-content-current"); + }); + } + } +} + function setupLazyImages() { const images = document.querySelectorAll("img[loading=lazy]"); @@ -160,22 +301,24 @@ function setupLazyImages() { image.classList.add("finished-transition"); } - setTimeout(() => { - for (let i = 0; i < images.length; i++) { - const image = images[i]; + afterContentReady(() => { + setTimeout(() => { + for (let i = 0; i < images.length; i++) { + const image = images[i]; - if (image.complete) { - image.classList.add("cached"); - setTimeout(() => imageFinishedTransition(image), 5); - } else { - // TODO: also handle error event - image.addEventListener("load", () => { - image.classList.add("loaded"); - setTimeout(() => imageFinishedTransition(image), 500); - }); + if (image.complete) { + image.classList.add("cached"); + setTimeout(() => imageFinishedTransition(image), 1); + } else { + // TODO: also handle error event + image.addEventListener("load", () => { + image.classList.add("loaded"); + setTimeout(() => imageFinishedTransition(image), 400); + }); + } } - } - }, 5); + }, 1); + }); } function attachExpandToggleButton(collapsibleContainer) { @@ -253,8 +396,6 @@ function setupCollapsibleLists() { child.classList.add("collapsible-item"); child.style.animationDelay = ((c - collapseAfter) * 20).toString() + "ms"; } - - list.classList.add("ready"); } } @@ -314,11 +455,10 @@ function setupCollapsibleGrids() { } }; - setTimeout(() => { + afterContentReady(() => { cardsPerRow = getCardsPerRow(); resolveCollapsibleItems(); - gridElement.classList.add("ready"); - }, 1); + }); window.addEventListener("resize", () => { const newCardsPerRow = getCardsPerRow(); @@ -333,6 +473,118 @@ function setupCollapsibleGrids() { } } +const contentReadyCallbacks = []; + +function afterContentReady(callback) { + contentReadyCallbacks.push(callback); +} + +const weekDayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + +function makeSettableTimeElement(element, hourFormat) { + const fragment = document.createDocumentFragment(); + const hour = document.createElement('span'); + const minute = document.createElement('span'); + const amPm = document.createElement('span'); + fragment.append(hour, document.createTextNode(':'), minute); + + if (hourFormat == '12h') { + fragment.append(document.createTextNode(' '), amPm); + } + + element.append(fragment); + + return (date) => { + const hours = date.getHours(); + + if (hourFormat == '12h') { + amPm.textContent = hours < 12 ? 'AM' : 'PM'; + hour.textContent = hours % 12 || 12; + } else { + hour.textContent = hours < 10 ? '0' + hours : hours; + } + + const minutes = date.getMinutes(); + minute.textContent = minutes < 10 ? '0' + minutes : minutes; + }; +}; + +function timeInZone(now, zone) { + let timeInZone; + + try { + timeInZone = new Date(now.toLocaleString('en-US', { timeZone: zone })); + } catch (e) { + // TODO: indicate to the user that this is an invalid timezone + console.error(e); + timeInZone = now + } + + const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60); + + return { time: timeInZone, diffInHours: diffInHours }; +} + +function setupClocks() { + const clocks = document.getElementsByClassName('clock'); + + if (clocks.length == 0) { + return; + } + + const updateCallbacks = []; + + for (var i = 0; i < clocks.length; i++) { + const clock = clocks[i]; + const hourFormat = clock.dataset.hourFormat; + const localTimeContainer = clock.querySelector('[data-local-time]'); + const localDateElement = localTimeContainer.querySelector('[data-date]'); + const localWeekdayElement = localTimeContainer.querySelector('[data-weekday]'); + const localYearElement = localTimeContainer.querySelector('[data-year]'); + const timeZoneContainers = clock.querySelectorAll('[data-time-in-zone]'); + + const setLocalTime = makeSettableTimeElement( + localTimeContainer.querySelector('[data-time]'), + hourFormat + ); + + updateCallbacks.push((now) => { + setLocalTime(now); + localDateElement.textContent = now.getDate() + ' ' + monthNames[now.getMonth()]; + localWeekdayElement.textContent = weekDayNames[now.getDay()]; + localYearElement.textContent = now.getFullYear(); + }); + + for (var z = 0; z < timeZoneContainers.length; z++) { + const timeZoneContainer = timeZoneContainers[z]; + const diffElement = timeZoneContainer.querySelector('[data-time-diff]'); + + const setZoneTime = makeSettableTimeElement( + timeZoneContainer.querySelector('[data-time]'), + hourFormat + ); + + updateCallbacks.push((now) => { + const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone); + setZoneTime(time); + diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h'; + }); + } + } + + const updateClocks = () => { + const now = new Date(); + + for (var i = 0; i < updateCallbacks.length; i++) + updateCallbacks[i](now); + + setTimeout(updateClocks, (60 - now.getSeconds()) * 1000); + }; + + updateClocks(); +} + async function setupPage() { const pageElement = document.getElementById("page"); const pageContentElement = document.getElementById("page-content"); @@ -340,23 +592,26 @@ async function setupPage() { pageContentElement.innerHTML = pageContent; - setTimeout(() => { - document.body.classList.add("animate-element-transition"); - }, 200); - try { - setupLazyImages(); + setupClocks() setupCarousels(); + setupSearchBoxes(); setupCollapsibleLists(); setupCollapsibleGrids(); + setupGroups(); setupDynamicRelativeTime(); + setupLazyImages(); } finally { pageElement.classList.add("content-ready"); + + for (let i = 0; i < contentReadyCallbacks.length; i++) { + contentReadyCallbacks[i](); + } + + setTimeout(() => { + document.body.classList.add("page-columns-transitioned"); + }, 300); } } -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", setupPage); -} else { - setupPage(); -} +setupPage(); diff --git a/internal/assets/static/manifest.json b/internal/assets/static/manifest.json index 8ce7aa8..42e8213 100644 --- a/internal/assets/static/manifest.json +++ b/internal/assets/static/manifest.json @@ -1,13 +1,14 @@ { "name": "Glance", "display": "standalone", + "background_color": "#151519", "scope": "/", "start_url": "/", "icons": [ { - "src": "/static/app-icon.png", + "src": "app-icon.png", "type": "image/png", "sizes": "512x512" } ] -} \ No newline at end of file +} diff --git a/internal/assets/templates.go b/internal/assets/templates.go index b8aa6ae..8274c8c 100644 --- a/internal/assets/templates.go +++ b/internal/assets/templates.go @@ -15,6 +15,7 @@ var ( PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl") PageContentTemplate = compileTemplate("content.html") CalendarTemplate = compileTemplate("calendar.html", "widget-base.html") + ClockTemplate = compileTemplate("clock.html", "widget-base.html") BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html") IFrameTemplate = compileTemplate("iframe.html", "widget-base.html") WeatherTemplate = compileTemplate("weather.html", "widget-base.html") @@ -22,16 +23,21 @@ var ( RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html") RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html") ReleasesTemplate = compileTemplate("releases.html", "widget-base.html") + ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html") VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html") VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html") - StocksTemplate = compileTemplate("stocks.html", "widget-base.html") + MarketsTemplate = compileTemplate("markets.html", "widget-base.html") RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html") + RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html") RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html") RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html") MonitorTemplate = compileTemplate("monitor.html", "widget-base.html") TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html") TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html") RepositoryTemplate = compileTemplate("repository.html", "widget-base.html") + SearchTemplate = compileTemplate("search.html", "widget-base.html") + ExtensionTemplate = compileTemplate("extension.html", "widget-base.html") + GroupTemplate = compileTemplate("group.html", "widget-base.html") ) var globalTemplateFunctions = template.FuncMap{ diff --git a/internal/assets/templates/change-detection.html b/internal/assets/templates/change-detection.html new file mode 100644 index 0000000..22b7a18 --- /dev/null +++ b/internal/assets/templates/change-detection.html @@ -0,0 +1,17 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} + +{{ end }} diff --git a/internal/assets/templates/clock.html b/internal/assets/templates/clock.html new file mode 100644 index 0000000..1bc0bf5 --- /dev/null +++ b/internal/assets/templates/clock.html @@ -0,0 +1,30 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} +
+
+
+
+
+
+
+
+
+
+
+ {{ if gt (len .Timezones) 0 }} +
+ + {{ end }} +
+{{ end }} diff --git a/internal/assets/templates/document.html b/internal/assets/templates/document.html index 4d784bd..d37ac56 100644 --- a/internal/assets/templates/document.html +++ b/internal/assets/templates/document.html @@ -11,12 +11,12 @@ - - - - - - + + + + + + {{ block "document-head-after" . }}{{ end }} diff --git a/internal/assets/templates/extension.html b/internal/assets/templates/extension.html new file mode 100644 index 0000000..e5794c8 --- /dev/null +++ b/internal/assets/templates/extension.html @@ -0,0 +1,5 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} +{{ .Extension.Content }} +{{ end }} diff --git a/internal/assets/templates/forum-posts.html b/internal/assets/templates/forum-posts.html index b8fea41..a6fe24d 100644 --- a/internal/assets/templates/forum-posts.html +++ b/internal/assets/templates/forum-posts.html @@ -4,7 +4,7 @@