diff --git a/Dockerfile b/Dockerfile index 2d6126d4..a883a43d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.19 +FROM alpine:3.20 ARG TARGETOS ARG TARGETARCH diff --git a/README.md b/README.md index 11ffb426..f4839d60 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) diff --git a/docs/configuration.md b/docs/configuration.md index 18e23b26..f5477004 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,17 +10,23 @@ - [RSS](#rss) - [Videos](#videos) - [Hacker News](#hacker-news) + - [Lobsters](#lobsters) - [Reddit](#reddit) + - [Search](#search-widget) + - [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. @@ -34,6 +40,7 @@ pages: columns: - size: small widgets: + - type: clock - type: calendar - type: rss @@ -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 @@ -405,6 +412,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 +434,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 +473,7 @@ Preview: | channels | array | yes | | | limit | integer | no | 25 | | style | string | no | horizontal-cards | +| collapse-after-rows | integer | no | 4 | | video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} | ##### `channels` @@ -470,6 +488,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 +551,45 @@ 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 | +| ---- | ---- | -------- | ------- | +| limit | integer | no | 15 | +| collapse-after | integer | no | 5 | +| sort-by | string | no | hot | +| tags | array | no | | + +##### `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. @@ -639,6 +699,111 @@ 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 | + +#### Properties +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| search-engine | string | no | duckduckgo | +| 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}` | + +##### `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} +``` + +### 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/. @@ -647,6 +812,7 @@ Example: ```yaml - type: weather units: metric + hour-format: 12h location: London, United Kingdom ``` @@ -671,6 +837,7 @@ Each bar represents a 2 hour interval. The yellow background represents sunrise | ---- | ---- | -------- | ------- | | location | string | yes | | | units | string | no | metric | +| hour-format | string | no | 12h | | hide-location | boolean | no | false | | show-area-name | boolean | no | false | @@ -680,6 +847,9 @@ The name of the city and country to fetch weather information for. Attempting to ##### `units` Whether to show the temperature in celsius or fahrenheit, possible values are `metric` or `imperial`. +#### `hour-format` +Whether to show the hours of the day in 12-hour format or 24-hour format. Possible values are `12h` and `24h`. + ##### `hide-location` Optionally don't display the location name on the widget. @@ -749,6 +919,7 @@ Properties for each site: | title | string | yes | | | url | string | yes | | | icon | string | no | | +| allow-insecure | boolean | no | false | | same-tab | boolean | no | false | `title` @@ -761,7 +932,21 @@ The URL which will be requested and its response will determine the status of th `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` @@ -958,6 +1143,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. @@ -975,14 +1252,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 @@ -997,21 +1274,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. @@ -1063,6 +1340,7 @@ Preview: | ---- | ---- | -------- | ------- | | channels | array | yes | | | collapse-after | integer | no | 5 | +| sort-by | string | no | viewers | ##### `channels` A list of channels to display. @@ -1070,6 +1348,9 @@ A list of channels to display. ##### `collapse-after` How many channels are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse. +##### `sort-by` +Can be used to specify the order in which the channels are displayed. Possible values are `viewers` and `live`. + ### Twitch top games Display a list of games with the most viewers on Twitch. @@ -1132,3 +1413,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 00000000..06db1ae6 --- /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 00000000..74b7fe72 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 00000000..bf809c5a 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 00000000..3fbdbef1 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 00000000..6c23452d Binary files /dev/null and b/docs/images/extension-overview.png differ diff --git a/docs/images/lobsters-widget-preview.png b/docs/images/lobsters-widget-preview.png new file mode 100644 index 00000000..9648d6d7 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 00000000..8cf1f11c 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 00000000..9490690e 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 00000000..9672a77d Binary files /dev/null and b/docs/images/search-widget-preview.png differ diff --git a/go.mod b/go.mod index b4a1e729..502fc709 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/glanceapp/glance -go 1.22.0 +go 1.22.3 require ( github.com/mmcdole/gofeed v1.3.0 diff --git a/internal/assets/static/app-icon.png b/internal/assets/static/app-icon.png new file mode 100644 index 00000000..54fc4131 Binary files /dev/null and b/internal/assets/static/app-icon.png differ diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css index 1e64def1..62b44809 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,14 @@ font-size: var(--font-size-h4); } +.page-content, .page.content-ready .page-loading-container { + display: none; +} + +.page.content-ready > .page-content { + display: block; +} + .page-column-full .size-title-dynamic { font-size: var(--font-size-h3); } @@ -71,14 +80,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 { @@ -106,6 +117,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); @@ -117,70 +129,84 @@ padding-top: var(--list-half-gap); } -@keyframes listItemReveal { - from { - opacity: 0; - transform: translateY(10px); - } -} - -.list-collapsible-item { +.collapsible-container:not(.container-expanded) > .collapsible-item { display: none; - animation: listItemReveal 0.3s backwards; - animation-delay: var(--animation-delay); -} - -.list-collapsible-label { - display: flex; - align-items: center; - gap: 1rem; - padding: var(--widget-content-vertical-padding) 0; - background: var(--color-widget-background); } -.list-collapsible-label:has(.list-collapsible-input:checked) { - position: sticky; - bottom: 0; -} - -.list-collapsible:has(+ .list-collapsible-label > .list-collapsible-input:checked) .list-collapsible-item { - display: block; +.collapsible-item { + animation: collapsibleItemReveal .25s backwards; } -.list-collapsible-input { - display: none; +@keyframes collapsibleItemReveal { + from { + opacity: 0; + transform: translateY(10px); + } } -.list-collapsible-label::before, .list-collapsible-label::after { +.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); } -.list-collapsible-label::before { - content: 'SHOW MORE'; - font-size: var(--font-size-h4); +.expand-toggle-button.container-expanded { + position: sticky; + /* -1px to hide 1px gap on chrome */ + bottom: -1px; } -.list-collapsible-label:has(.list-collapsible-input:checked)::before { - content: 'SHOW LESS'; +.expand-toggle-button-icon { + display: inline-block; + margin-left: 1rem; + position: relative; + top: -.2rem; } -.list-collapsible-label::after { +.expand-toggle-button-icon::before { content: ''; font-size: 0.8rem; transform: rotate(90deg); line-height: 1; + display: inline-block; transition: transform 0.3s; } -.list-collapsible-label:has(.list-collapsible-input:checked)::after { +.expand-toggle-button.container-expanded .expand-toggle-button-icon::before { transform: rotate(-90deg); } -.widget-content:has(.list-collapsible-label:last-child) { +.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); +} + ::selection { background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%))); color: var(--color-text-highlight); @@ -327,6 +353,23 @@ 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; margin-inline: auto; @@ -578,16 +621,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; } @@ -638,6 +681,85 @@ 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; +} + +.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; @@ -653,6 +775,10 @@ body { margin-top: 0.1rem; } +.forum-post-tags-container { + transform: translateY(-0.15rem); +} + .bookmarks-group { --bookmarks-group-color: var(--color-primary); } @@ -706,7 +832,7 @@ body { flex-direction: column; width: calc(100% / 12); padding-top: 3px; - max-width: 3.5rem; + max-width: 30px; } .weather-column-value, .weather-columns:hover .weather-column-value { @@ -840,6 +966,10 @@ body { transform: translate(-50%, -50%); } +.clock-time span { + color: var(--color-text-highlight); +} + .monitor-site-icon { display: block; opacity: 0.8; @@ -866,11 +996,22 @@ body { .thumbnail { filter: grayscale(0.2) contrast(0.9); - transition: all 0.2s; 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-container:hover .thumbnail { +.thumbnail-parent:hover .thumbnail { opacity: 1; filter: none; } @@ -918,8 +1059,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); } @@ -996,10 +1152,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; } @@ -1107,9 +1263,48 @@ body { box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor; } - .list-collapsible-label:has(.list-collapsible-input:checked) { + .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; + } +} + +@media (max-width: 1190px) and (display-mode: standalone) { + :root { + --safe-area-inset-bottom: env(safe-area-inset-bottom, 0); + } + + .list-collapsible-label:has(.list-collapsible-input:checked) { + 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-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) { @@ -1123,22 +1318,30 @@ 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 } .mobile-reachability-header { display: block; font-size: 3rem; - padding: 10dvh 1rem; + 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 { + -webkit-line-clamp: 3; + } } .size-h1 { font-size: var(--font-size-h1); } @@ -1166,7 +1369,9 @@ body { .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; } @@ -1174,6 +1379,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; } @@ -1185,6 +1391,10 @@ body { .gap-7 { gap: 0.7rem; } .gap-10 { gap: 1rem; } .gap-15 { gap: 1.5rem; } +.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; } @@ -1201,3 +1411,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 05dbc45e..3e10b962 100644 --- a/internal/assets/static/main.js +++ b/internal/assets/static/main.js @@ -21,7 +21,7 @@ function throttledDebounce(callback, maxDebounceTimes, debounceDelay) { }; -async function fetchPageContents (pageSlug) { +async function fetchPageContent(pageSlug) { // TODO: handle non 200 status codes/time outs // TODO: add retries const response = await fetch(`/api/pages/${pageSlug}/content/`); @@ -33,8 +33,13 @@ async function fetchPageContents (pageSlug) { function setupCarousels() { const carouselElements = document.getElementsByClassName("carousel-container"); + if (carouselElements.length == 0) { + return; + } + for (let i = 0; i < carouselElements.length; i++) { const carousel = carouselElements[i]; + carousel.classList.add("show-right-cutoff"); const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0]; const determineSideCutoffs = () => { @@ -54,9 +59,9 @@ function setupCarousels() { const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100); itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited); - document.addEventListener("resize", determineSideCutoffsRateLimited); + window.addEventListener("resize", determineSideCutoffsRateLimited); - determineSideCutoffs(); + afterContentReady(determineSideCutoffs); } } @@ -98,7 +103,104 @@ 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 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) { + return; + } + + const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query)); + + if (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.trimStart(); + 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()); + }); } } @@ -107,6 +209,8 @@ function setupDynamicRelativeTime() { const updateInterval = 60 * 1000; let lastUpdateTime = Date.now(); + updateRelativeTimeForElements(elements); + const updateElementsAndTimestamp = () => { updateRelativeTimeForElements(elements); lastUpdateTime = Date.now(); @@ -153,35 +257,316 @@ function setupLazyImages() { image.classList.add("finished-transition"); } - 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), 1); + } else { + // TODO: also handle error event + image.addEventListener("load", () => { + image.classList.add("loaded"); + setTimeout(() => imageFinishedTransition(image), 400); + }); + } + } + }, 1); + }); +} + +function attachExpandToggleButton(collapsibleContainer) { + const showMoreText = "Show more"; + const showLessText = "Show less"; + + let expanded = false; + const button = document.createElement("button"); + const icon = document.createElement("span"); + icon.classList.add("expand-toggle-button-icon"); + const textNode = document.createTextNode(showMoreText); + button.classList.add("expand-toggle-button"); + button.append(textNode, icon); + button.addEventListener("click", () => { + expanded = !expanded; + + if (expanded) { + collapsibleContainer.classList.add("container-expanded"); + button.classList.add("container-expanded"); + textNode.nodeValue = showLessText; + return; + } + + const topBefore = button.getClientRects()[0].top; + + collapsibleContainer.classList.remove("container-expanded"); + button.classList.remove("container-expanded"); + textNode.nodeValue = showMoreText; + + const topAfter = button.getClientRects()[0].top; + + if (topAfter > 0) + return; + + window.scrollBy({ + top: topAfter - topBefore, + behavior: "instant" + }); + }); + + collapsibleContainer.after(button); + + return button; +}; + + +function setupCollapsibleLists() { + const collapsibleLists = document.querySelectorAll(".list.collapsible-container"); + + if (collapsibleLists.length == 0) { + return; + } + + for (let i = 0; i < collapsibleLists.length; i++) { + const list = collapsibleLists[i]; + + if (list.dataset.collapseAfter === undefined) { + continue; + } + + const collapseAfter = parseInt(list.dataset.collapseAfter); + + if (collapseAfter == -1) { + continue; + } + + if (list.children.length <= collapseAfter) { + continue; + } + + attachExpandToggleButton(list); + + for (let c = collapseAfter; c < list.children.length; c++) { + const child = list.children[c]; + child.classList.add("collapsible-item"); + child.style.animationDelay = ((c - collapseAfter) * 20).toString() + "ms"; + } + } +} + +function setupCollapsibleGrids() { + const collapsibleGridElements = document.querySelectorAll(".cards-grid.collapsible-container"); - if (image.complete) { - image.classList.add("cached"); - setTimeout(() => imageFinishedTransition(image), 5); + if (collapsibleGridElements.length == 0) { + return; + } + + for (let i = 0; i < collapsibleGridElements.length; i++) { + const gridElement = collapsibleGridElements[i]; + + if (gridElement.dataset.collapseAfterRows === undefined) { + continue; + } + + const collapseAfterRows = parseInt(gridElement.dataset.collapseAfterRows); + + if (collapseAfterRows == -1) { + continue; + } + + const getCardsPerRow = () => { + return parseInt(getComputedStyle(gridElement).getPropertyValue('--cards-per-row')); + }; + + const button = attachExpandToggleButton(gridElement); + + let cardsPerRow = 2; + + const resolveCollapsibleItems = () => { + const hideItemsAfterIndex = cardsPerRow * collapseAfterRows; + + if (hideItemsAfterIndex >= gridElement.children.length) { + button.style.display = "none"; + } else { + button.style.removeProperty("display"); + } + + let row = 0; + + for (let i = 0; i < gridElement.children.length; i++) { + const child = gridElement.children[i]; + + if (i >= hideItemsAfterIndex) { + child.classList.add("collapsible-item"); + child.style.animationDelay = (row * 40).toString() + "ms"; + + if (i % cardsPerRow + 1 == cardsPerRow) { + row++; + } + } else { + child.classList.remove("collapsible-item"); + child.style.removeProperty("animation-delay"); + } + } + }; + + afterContentReady(() => { + cardsPerRow = getCardsPerRow(); + resolveCollapsibleItems(); + }); + + window.addEventListener("resize", () => { + const newCardsPerRow = getCardsPerRow(); + + if (cardsPerRow == newCardsPerRow) { + return; + } + + cardsPerRow = newCardsPerRow; + resolveCollapsibleItems(); + }); + } +} + +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 { - // TODO: also handle error event - image.addEventListener("load", () => { - image.classList.add("loaded"); - setTimeout(() => imageFinishedTransition(image), 500); + 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 pageContents = await fetchPageContents(pageData.slug); - - pageElement.innerHTML = pageContents; - - setTimeout(() => { - document.body.classList.add("animate-element-transition"); - }, 150); + const pageContentElement = document.getElementById("page-content"); + const pageContent = await fetchPageContent(pageData.slug); + + pageContentElement.innerHTML = pageContent; + + try { + setupClocks() + setupCarousels(); + setupSearchboxes(); + setupCollapsibleLists(); + setupCollapsibleGrids(); + setupDynamicRelativeTime(); + setupLazyImages(); + } finally { + pageElement.classList.add("content-ready"); + + for (let i = 0; i < contentReadyCallbacks.length; i++) { + contentReadyCallbacks[i](); + } - setTimeout(setupLazyImages, 5); - setupCarousels(); - setupDynamicRelativeTime(); + setTimeout(() => { + document.body.classList.add("page-columns-transitioned"); + }, 300); + } } if (document.readyState === "loading") { diff --git a/internal/assets/static/manifest.json b/internal/assets/static/manifest.json new file mode 100644 index 00000000..668b2896 --- /dev/null +++ b/internal/assets/static/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Glance", + "display": "standalone", + "background_color": "#151519", + "scope": "/", + "start_url": "/", + "icons": [ + { + "src": "/static/app-icon.png", + "type": "image/png", + "sizes": "512x512" + } + ] +} diff --git a/internal/assets/templates.go b/internal/assets/templates.go index b8aa6aed..53ae871f 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,20 @@ 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") ) 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 00000000..22b7a181 --- /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 00000000..1bc0bf52 --- /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 04984f87..d126d8b7 100644 --- a/internal/assets/templates/document.html +++ b/internal/assets/templates/document.html @@ -5,7 +5,15 @@ {{ block "document-title" . }}{{ end }} - + + + + + + + + + diff --git a/internal/assets/templates/extension.html b/internal/assets/templates/extension.html new file mode 100644 index 00000000..e5794c82 --- /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 32f7076e..a6fe24d8 100644 --- a/internal/assets/templates/forum-posts.html +++ b/internal/assets/templates/forum-posts.html @@ -1,14 +1,14 @@ {{ template "widget-base.html" . }} {{ define "widget-content" }} -