mirror of
https://github.com/glanceapp/glance.git
synced 2025-01-05 21:33:10 +03:00
Merge branch 'release/v0.5.0' into lobsters-widget
This commit is contained in:
commit
8efda0047c
@ -11,6 +11,7 @@
|
|||||||
* Weather
|
* Weather
|
||||||
* Bookmarks
|
* Bookmarks
|
||||||
* Latest YouTube videos from specific channels
|
* Latest YouTube videos from specific channels
|
||||||
|
* Clock
|
||||||
* Calendar
|
* Calendar
|
||||||
* Stocks
|
* Stocks
|
||||||
* iframe
|
* iframe
|
||||||
@ -18,6 +19,7 @@
|
|||||||
* GitHub releases
|
* GitHub releases
|
||||||
* Repository overview
|
* Repository overview
|
||||||
* Site monitor
|
* Site monitor
|
||||||
|
* Search box
|
||||||
|
|
||||||
#### Themeable
|
#### Themeable
|
||||||
![multiple color schemes example](docs/images/themes-example.png)
|
![multiple color schemes example](docs/images/themes-example.png)
|
||||||
|
@ -12,12 +12,14 @@
|
|||||||
- [Hacker News](#hacker-news)
|
- [Hacker News](#hacker-news)
|
||||||
- [Lobsters](#lobsters)
|
- [Lobsters](#lobsters)
|
||||||
- [Reddit](#reddit)
|
- [Reddit](#reddit)
|
||||||
|
- [Search](#search-widget)
|
||||||
- [Weather](#weather)
|
- [Weather](#weather)
|
||||||
- [Monitor](#monitor)
|
- [Monitor](#monitor)
|
||||||
- [Releases](#releases)
|
- [Releases](#releases)
|
||||||
- [Repository](#repository)
|
- [Repository](#repository)
|
||||||
- [Bookmarks](#bookmarks)
|
- [Bookmarks](#bookmarks)
|
||||||
- [Calendar](#calendar)
|
- [Calendar](#calendar)
|
||||||
|
- [Clock](#clock)
|
||||||
- [Stocks](#stocks)
|
- [Stocks](#stocks)
|
||||||
- [Twitch Channels](#twitch-channels)
|
- [Twitch Channels](#twitch-channels)
|
||||||
- [Twitch Top Games](#twitch-top-games)
|
- [Twitch Top Games](#twitch-top-games)
|
||||||
@ -35,6 +37,7 @@ pages:
|
|||||||
columns:
|
columns:
|
||||||
- size: small
|
- size: small
|
||||||
widgets:
|
widgets:
|
||||||
|
- type: clock
|
||||||
- type: calendar
|
- type: calendar
|
||||||
|
|
||||||
- type: rss
|
- type: rss
|
||||||
@ -683,6 +686,80 @@ 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.
|
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 |
|
||||||
|
| ---- | ------ | --------- |
|
||||||
|
| <kbd>S</kbd> | Focus the search bar | Not already focused on another input field |
|
||||||
|
| <kbd>Enter</kbd> | Perform search in the same tab | Search input is focused and not empty |
|
||||||
|
| <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Perform search in a new tab | Search input is focused and not empty |
|
||||||
|
| <kbd>Escape</kbd> | 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}
|
||||||
|
```
|
||||||
|
|
||||||
### Weather
|
### Weather
|
||||||
Display weather information for a specific location. The data is provided by https://open-meteo.com/.
|
Display weather information for a specific location. The data is provided by https://open-meteo.com/.
|
||||||
|
|
||||||
@ -691,6 +768,7 @@ Example:
|
|||||||
```yaml
|
```yaml
|
||||||
- type: weather
|
- type: weather
|
||||||
units: metric
|
units: metric
|
||||||
|
hour-format: 12h
|
||||||
location: London, United Kingdom
|
location: London, United Kingdom
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -715,6 +793,7 @@ Each bar represents a 2 hour interval. The yellow background represents sunrise
|
|||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| location | string | yes | |
|
| location | string | yes | |
|
||||||
| units | string | no | metric |
|
| units | string | no | metric |
|
||||||
|
| hour-format | string | no | 12h |
|
||||||
| hide-location | boolean | no | false |
|
| hide-location | boolean | no | false |
|
||||||
| show-area-name | boolean | no | false |
|
| show-area-name | boolean | no | false |
|
||||||
|
|
||||||
@ -724,6 +803,9 @@ The name of the city and country to fetch weather information for. Attempting to
|
|||||||
##### `units`
|
##### `units`
|
||||||
Whether to show the temperature in celsius or fahrenheit, possible values are `metric` or `imperial`.
|
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`
|
##### `hide-location`
|
||||||
Optionally don't display the location name on the widget.
|
Optionally don't display the location name on the widget.
|
||||||
|
|
||||||
@ -1002,6 +1084,51 @@ Whether to open the link in the same tab or a new one.
|
|||||||
|
|
||||||
Whether to hide the colored arrow on each link.
|
Whether to hide the colored arrow on each link.
|
||||||
|
|
||||||
|
### 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
|
### Calendar
|
||||||
Display a calendar.
|
Display a calendar.
|
||||||
|
|
||||||
@ -1107,6 +1234,7 @@ Preview:
|
|||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| channels | array | yes | |
|
| channels | array | yes | |
|
||||||
| collapse-after | integer | no | 5 |
|
| collapse-after | integer | no | 5 |
|
||||||
|
| sort-by | string | no | viewers |
|
||||||
|
|
||||||
##### `channels`
|
##### `channels`
|
||||||
A list of channels to display.
|
A list of channels to display.
|
||||||
@ -1114,6 +1242,9 @@ A list of channels to display.
|
|||||||
##### `collapse-after`
|
##### `collapse-after`
|
||||||
How many channels are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
|
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
|
### Twitch top games
|
||||||
Display a list of games with the most viewers on Twitch.
|
Display a list of games with the most viewers on Twitch.
|
||||||
|
|
||||||
|
BIN
docs/images/clock-widget-preview.png
Normal file
BIN
docs/images/clock-widget-preview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
BIN
docs/images/search-widget-bangs-preview.png
Normal file
BIN
docs/images/search-widget-bangs-preview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.5 KiB |
BIN
docs/images/search-widget-preview.png
Normal file
BIN
docs/images/search-widget-preview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.8 KiB |
BIN
internal/assets/static/app-icon.png
Normal file
BIN
internal/assets/static/app-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.8 KiB |
@ -37,6 +37,7 @@
|
|||||||
|
|
||||||
--ths: var(--bgh), calc(var(--bgs) * var(--tsm));
|
--ths: var(--bgh), calc(var(--bgs) * var(--tsm));
|
||||||
--color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
|
--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-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
|
||||||
--color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
|
--color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
|
||||||
|
|
||||||
@ -57,6 +58,14 @@
|
|||||||
font-size: var(--font-size-h4);
|
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 {
|
.page-column-full .size-title-dynamic {
|
||||||
font-size: var(--font-size-h3);
|
font-size: var(--font-size-h3);
|
||||||
}
|
}
|
||||||
@ -71,14 +80,16 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-truncate-3-lines {
|
.text-truncate-2-lines, .text-truncate-3-lines {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-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:not(.text-truncate)::after,
|
||||||
.visited-indicator.text-truncate::before,
|
.visited-indicator.text-truncate::before,
|
||||||
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
|
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
|
||||||
@ -106,6 +117,7 @@
|
|||||||
.list-gap-14 { --list-half-gap: 0.7rem; }
|
.list-gap-14 { --list-half-gap: 0.7rem; }
|
||||||
.list-gap-20 { --list-half-gap: 1rem; }
|
.list-gap-20 { --list-half-gap: 1rem; }
|
||||||
.list-gap-24 { --list-half-gap: 1.2rem; }
|
.list-gap-24 { --list-half-gap: 1.2rem; }
|
||||||
|
.list-gap-34 { --list-half-gap: 1.7rem; }
|
||||||
|
|
||||||
.list > *:not(:first-child) {
|
.list > *:not(:first-child) {
|
||||||
margin-top: calc(var(--list-half-gap) * 2);
|
margin-top: calc(var(--list-half-gap) * 2);
|
||||||
@ -117,70 +129,85 @@
|
|||||||
padding-top: var(--list-half-gap);
|
padding-top: var(--list-half-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes listItemReveal {
|
.collapsible-container:not(.container-expanded) > .collapsible-item {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-item {
|
||||||
|
animation: collapsibleItemReveal .25s backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes collapsibleItemReveal {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
transform: translateY(10px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-collapsible-item {
|
.expand-toggle-button {
|
||||||
display: none;
|
font: inherit;
|
||||||
animation: listItemReveal 0.3s backwards;
|
border: 0;
|
||||||
animation-delay: var(--animation-delay);
|
cursor: pointer;
|
||||||
}
|
display: block;
|
||||||
|
width: 100%;
|
||||||
.list-collapsible-label {
|
text-align: left;
|
||||||
display: flex;
|
color: var(--color-text-base);
|
||||||
align-items: center;
|
text-transform: uppercase;
|
||||||
gap: 1rem;
|
font-size: var(--font-size-h4);
|
||||||
padding: var(--widget-content-vertical-padding) 0;
|
padding: var(--widget-content-vertical-padding) 0;
|
||||||
background: var(--color-widget-background);
|
background: var(--color-widget-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-collapsible-label:has(.list-collapsible-input:checked) {
|
.expand-toggle-button.container-expanded {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
/* -1px to hide 1px gap on chrome */
|
||||||
|
bottom: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-collapsible:has(+ .list-collapsible-label > .list-collapsible-input:checked) .list-collapsible-item {
|
.expand-toggle-button-icon {
|
||||||
display: block;
|
display: inline-block;
|
||||||
|
margin-left: 1rem;
|
||||||
|
position: relative;
|
||||||
|
top: -.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-collapsible-input {
|
.expand-toggle-button-icon::before {
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-collapsible-label::before, .list-collapsible-label::after {
|
|
||||||
cursor: pointer;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-collapsible-label::before {
|
|
||||||
content: 'SHOW MORE';
|
|
||||||
font-size: var(--font-size-h4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-collapsible-label:has(.list-collapsible-input:checked)::before {
|
|
||||||
content: 'SHOW LESS';
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-collapsible-label::after {
|
|
||||||
content: '';
|
content: '';
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
display: inline-block;
|
||||||
transition: transform 0.3s;
|
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);
|
transform: rotate(-90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-content:has(.list-collapsible-label:last-child) {
|
.widget-content:has(.expand-toggle-button:last-child) {
|
||||||
padding-bottom: 0;
|
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;
|
||||||
|
margin-left: -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 {
|
::selection {
|
||||||
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
|
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
|
||||||
color: var(--color-text-highlight);
|
color: var(--color-text-highlight);
|
||||||
@ -327,6 +354,23 @@ body {
|
|||||||
border: 1px solid var(--color-negative);
|
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 {
|
.content-bounds {
|
||||||
max-width: 1600px;
|
max-width: 1600px;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
@ -638,6 +682,85 @@ body {
|
|||||||
-webkit-box-orient: vertical;
|
-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 {
|
.forum-post-list-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.2rem;
|
gap: 1.2rem;
|
||||||
@ -706,7 +829,7 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: calc(100% / 12);
|
width: calc(100% / 12);
|
||||||
padding-top: 3px;
|
padding-top: 3px;
|
||||||
max-width: 3.5rem;
|
max-width: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-column-value, .weather-columns:hover .weather-column-value {
|
.weather-column-value, .weather-columns:hover .weather-column-value {
|
||||||
@ -840,6 +963,10 @@ body {
|
|||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clock-time span {
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
.monitor-site-icon {
|
.monitor-site-icon {
|
||||||
display: block;
|
display: block;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
@ -866,11 +993,22 @@ body {
|
|||||||
|
|
||||||
.thumbnail {
|
.thumbnail {
|
||||||
filter: grayscale(0.2) contrast(0.9);
|
filter: grayscale(0.2) contrast(0.9);
|
||||||
transition: all 0.2s;
|
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
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;
|
opacity: 1;
|
||||||
filter: none;
|
filter: none;
|
||||||
}
|
}
|
||||||
@ -918,6 +1056,20 @@ body {
|
|||||||
z-index: 3;
|
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 {
|
.twitch-category-thumbnail {
|
||||||
width: 5rem;
|
width: 5rem;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
@ -996,10 +1148,10 @@ body {
|
|||||||
|
|
||||||
.page-column {
|
.page-column {
|
||||||
display: none;
|
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;
|
animation-duration: .3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1107,9 +1259,48 @@ body {
|
|||||||
box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor;
|
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);
|
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) {
|
@media (max-width: 550px) {
|
||||||
@ -1123,22 +1314,30 @@ body {
|
|||||||
|
|
||||||
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||||
|
|
||||||
.forum-post-list-item {
|
.row-reverse-on-mobile {
|
||||||
flex-flow: row-reverse;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hide-on-mobile {
|
.hide-on-mobile, .thumbnail-container:has(> .hide-on-mobile) {
|
||||||
display: none
|
display: none
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-reachability-header {
|
.mobile-reachability-header {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
padding: 10dvh 1rem;
|
padding: 10vh 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--color-text-highlight);
|
color: var(--color-text-highlight);
|
||||||
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
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); }
|
.size-h1 { font-size: var(--font-size-h1); }
|
||||||
@ -1166,6 +1365,7 @@ body {
|
|||||||
.shrink { flex-shrink: 1; }
|
.shrink { flex-shrink: 1; }
|
||||||
.shrink-0 { flex-shrink: 0; }
|
.shrink-0 { flex-shrink: 0; }
|
||||||
.min-width-0 { min-width: 0; }
|
.min-width-0 { min-width: 0; }
|
||||||
|
.max-width-100 { max-width: 100%; }
|
||||||
.block { display: block; }
|
.block { display: block; }
|
||||||
.overflow-hidden { overflow: hidden; }
|
.overflow-hidden { overflow: hidden; }
|
||||||
.relative { position: relative; }
|
.relative { position: relative; }
|
||||||
@ -1185,6 +1385,10 @@ body {
|
|||||||
.gap-7 { gap: 0.7rem; }
|
.gap-7 { gap: 0.7rem; }
|
||||||
.gap-10 { gap: 1rem; }
|
.gap-10 { gap: 1rem; }
|
||||||
.gap-15 { gap: 1.5rem; }
|
.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-3 { margin-top: 0.3rem; }
|
||||||
.margin-top-5 { margin-top: 0.5rem; }
|
.margin-top-5 { margin-top: 0.5rem; }
|
||||||
.margin-top-7 { margin-top: 0.7rem; }
|
.margin-top-7 { margin-top: 0.7rem; }
|
||||||
@ -1201,3 +1405,4 @@ body {
|
|||||||
.margin-bottom-10 { margin-bottom: 1rem; }
|
.margin-bottom-10 { margin-bottom: 1rem; }
|
||||||
.margin-bottom-15 { margin-bottom: 1.5rem; }
|
.margin-bottom-15 { margin-bottom: 1.5rem; }
|
||||||
.margin-bottom-auto { margin-bottom: auto; }
|
.margin-bottom-auto { margin-bottom: auto; }
|
||||||
|
.scale-half { transform: scale(0.5); }
|
||||||
|
@ -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: handle non 200 status codes/time outs
|
||||||
// TODO: add retries
|
// TODO: add retries
|
||||||
const response = await fetch(`/api/pages/${pageSlug}/content/`);
|
const response = await fetch(`/api/pages/${pageSlug}/content/`);
|
||||||
@ -33,8 +33,13 @@ async function fetchPageContents (pageSlug) {
|
|||||||
function setupCarousels() {
|
function setupCarousels() {
|
||||||
const carouselElements = document.getElementsByClassName("carousel-container");
|
const carouselElements = document.getElementsByClassName("carousel-container");
|
||||||
|
|
||||||
|
if (carouselElements.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < carouselElements.length; i++) {
|
for (let i = 0; i < carouselElements.length; i++) {
|
||||||
const carousel = carouselElements[i];
|
const carousel = carouselElements[i];
|
||||||
|
carousel.classList.add("show-right-cutoff");
|
||||||
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
|
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
|
||||||
|
|
||||||
const determineSideCutoffs = () => {
|
const determineSideCutoffs = () => {
|
||||||
@ -54,9 +59,9 @@ function setupCarousels() {
|
|||||||
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
|
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
|
||||||
|
|
||||||
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
|
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)
|
if (timestamp === undefined)
|
||||||
continue
|
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;
|
const updateInterval = 60 * 1000;
|
||||||
let lastUpdateTime = Date.now();
|
let lastUpdateTime = Date.now();
|
||||||
|
|
||||||
|
updateRelativeTimeForElements(elements);
|
||||||
|
|
||||||
const updateElementsAndTimestamp = () => {
|
const updateElementsAndTimestamp = () => {
|
||||||
updateRelativeTimeForElements(elements);
|
updateRelativeTimeForElements(elements);
|
||||||
lastUpdateTime = Date.now();
|
lastUpdateTime = Date.now();
|
||||||
@ -153,35 +257,316 @@ function setupLazyImages() {
|
|||||||
image.classList.add("finished-transition");
|
image.classList.add("finished-transition");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < images.length; i++) {
|
afterContentReady(() => {
|
||||||
const image = images[i];
|
setTimeout(() => {
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
const image = images[i];
|
||||||
|
|
||||||
if (image.complete) {
|
if (image.complete) {
|
||||||
image.classList.add("cached");
|
image.classList.add("cached");
|
||||||
setTimeout(() => imageFinishedTransition(image), 5);
|
setTimeout(() => imageFinishedTransition(image), 1);
|
||||||
} else {
|
} else {
|
||||||
// TODO: also handle error event
|
// TODO: also handle error event
|
||||||
image.addEventListener("load", () => {
|
image.addEventListener("load", () => {
|
||||||
image.classList.add("loaded");
|
image.classList.add("loaded");
|
||||||
setTimeout(() => imageFinishedTransition(image), 500);
|
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 (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 {
|
||||||
|
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() {
|
async function setupPage() {
|
||||||
const pageElement = document.getElementById("page");
|
const pageElement = document.getElementById("page");
|
||||||
const pageContents = await fetchPageContents(pageData.slug);
|
const pageContentElement = document.getElementById("page-content");
|
||||||
|
const pageContent = await fetchPageContent(pageData.slug);
|
||||||
|
|
||||||
pageElement.innerHTML = pageContents;
|
pageContentElement.innerHTML = pageContent;
|
||||||
|
|
||||||
setTimeout(() => {
|
try {
|
||||||
document.body.classList.add("animate-element-transition");
|
setupClocks()
|
||||||
}, 150);
|
setupCarousels();
|
||||||
|
setupSearchboxes();
|
||||||
|
setupCollapsibleLists();
|
||||||
|
setupCollapsibleGrids();
|
||||||
|
setupDynamicRelativeTime();
|
||||||
|
setupLazyImages();
|
||||||
|
} finally {
|
||||||
|
pageElement.classList.add("content-ready");
|
||||||
|
|
||||||
setTimeout(setupLazyImages, 5);
|
for (let i = 0; i < contentReadyCallbacks.length; i++) {
|
||||||
setupCarousels();
|
contentReadyCallbacks[i]();
|
||||||
setupDynamicRelativeTime();
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.classList.add("page-columns-transitioned");
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
|
14
internal/assets/static/manifest.json
Normal file
14
internal/assets/static/manifest.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -15,6 +15,7 @@ var (
|
|||||||
PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
|
PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
|
||||||
PageContentTemplate = compileTemplate("content.html")
|
PageContentTemplate = compileTemplate("content.html")
|
||||||
CalendarTemplate = compileTemplate("calendar.html", "widget-base.html")
|
CalendarTemplate = compileTemplate("calendar.html", "widget-base.html")
|
||||||
|
ClockTemplate = compileTemplate("clock.html", "widget-base.html")
|
||||||
BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html")
|
BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html")
|
||||||
IFrameTemplate = compileTemplate("iframe.html", "widget-base.html")
|
IFrameTemplate = compileTemplate("iframe.html", "widget-base.html")
|
||||||
WeatherTemplate = compileTemplate("weather.html", "widget-base.html")
|
WeatherTemplate = compileTemplate("weather.html", "widget-base.html")
|
||||||
@ -22,16 +23,19 @@ var (
|
|||||||
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
|
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
|
||||||
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
|
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
|
||||||
ReleasesTemplate = compileTemplate("releases.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")
|
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
|
||||||
VideosGridTemplate = compileTemplate("videos-grid.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")
|
StocksTemplate = compileTemplate("stocks.html", "widget-base.html")
|
||||||
RSSListTemplate = compileTemplate("rss-list.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")
|
RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
|
||||||
RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
|
RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
|
||||||
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
|
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
|
||||||
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
|
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
|
||||||
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
|
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
|
||||||
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
|
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
|
||||||
|
SearchTemplate = compileTemplate("search.html", "widget-base.html")
|
||||||
)
|
)
|
||||||
|
|
||||||
var globalTemplateFunctions = template.FuncMap{
|
var globalTemplateFunctions = template.FuncMap{
|
||||||
|
17
internal/assets/templates/change-detection.html
Normal file
17
internal/assets/templates/change-detection.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
|
{{ range .ChangeDetections }}
|
||||||
|
<li>
|
||||||
|
<a class="size-h4 block text-truncate color-highlight" href="{{ .URL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
<li {{ dynamicRelativeTimeAttrs .LastChanged }}></li>
|
||||||
|
<li class="shrink min-width-0"><a class="visited-indicator" href="{{ .DiffURL }}" target="_blank" rel="noreferrer">diff:{{ .PreviousHash }}</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{{ else }}
|
||||||
|
<li>No watches configured</li>
|
||||||
|
{{ end}}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
30
internal/assets/templates/clock.html
Normal file
30
internal/assets/templates/clock.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="clock" data-hour-format="{{ .HourFormat }}">
|
||||||
|
<div class="flex justify-between items-center" data-local-time>
|
||||||
|
<div>
|
||||||
|
<div class="color-highlight size-h1" data-date></div>
|
||||||
|
<div data-year></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="clock-time size-h1" data-time></div>
|
||||||
|
<div data-weekday></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ if gt (len .Timezones) 0 }}
|
||||||
|
<hr class="margin-block-10">
|
||||||
|
<ul class="list list-gap-10">
|
||||||
|
{{ range .Timezones }}
|
||||||
|
<li class="flex items-center gap-15" data-time-in-zone="{{ .Timezone }}">
|
||||||
|
<div class="grow min-width-0">
|
||||||
|
<div class="text-truncate">{{ if ne .Label "" }}{{ .Label }}{{ else }}{{ .Timezone }}{{ end }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-subdue" data-time-diff></div>
|
||||||
|
<div class="size-h4 clock-time shrink-0 text-right" data-time></div>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
@ -5,7 +5,15 @@
|
|||||||
<title>{{ block "document-title" . }}{{ end }}</title>
|
<title>{{ block "document-title" . }}{{ end }}</title>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Glance">
|
||||||
|
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
|
||||||
|
<link rel="apple-touch-icon" sizes="512x512" href="/static/app-icon.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="50x50" href="/static/favicon.png">
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||||
<link rel="stylesheet" href="/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}">
|
<link rel="stylesheet" href="/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}">
|
||||||
<script async src="/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script>
|
<script async src="/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script>
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-14 list-collapsible">
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range $i, $post := .Posts }}
|
{{ range .Posts }}
|
||||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
<li>
|
||||||
<div class="forum-post-list-item thumbnail-container">
|
<div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
|
||||||
{{ if $.ShowThumbnails }}
|
{{ if $.ShowThumbnails }}
|
||||||
{{ if ne $post.ThumbnailUrl "" }}
|
{{ if ne .ThumbnailUrl "" }}
|
||||||
<img class="forum-post-list-thumbnail thumbnail" src="{{ $post.ThumbnailUrl }}" alt="" loading="lazy">
|
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
|
||||||
{{ else if $post.HasTargetUrl }}
|
{{ else if .HasTargetUrl }}
|
||||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -18,14 +18,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div class="grow">
|
<div class="grow min-width-0">
|
||||||
<a href="{{ $post.DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li title="{{ $post.TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs $post.TimePosted }}>{{ $post.TimePosted | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li>{{ $post.Score | formatNumber }} points</li>
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
<li>{{ $post.CommentCount | formatNumber }} comments</li>
|
<li>{{ .CommentCount | formatNumber }} comments</li>
|
||||||
{{ if $post.HasTargetUrl }}
|
{{ if .HasTargetUrl }}
|
||||||
<li class="shrink min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ $post.TargetUrlDomain }}</a></li>
|
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -33,7 +33,4 @@
|
|||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
{{ if gt (len .Posts) $.CollapseAfter }}
|
|
||||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
@ -50,6 +50,7 @@
|
|||||||
|
|
||||||
<div class="content-bounds">
|
<div class="content-bounds">
|
||||||
<div class="page" id="page">
|
<div class="page" id="page">
|
||||||
|
<div class="page-content" id="page-content"></div>
|
||||||
<div class="page-loading-container">
|
<div class="page-loading-container">
|
||||||
<!-- TODO: add a bigger/better loading indicator -->
|
<!-- TODO: add a bigger/better loading indicator -->
|
||||||
<div class="loading-icon"></div>
|
<div class="loading-icon"></div>
|
||||||
@ -59,11 +60,8 @@
|
|||||||
|
|
||||||
<div class="footer flex items-center flex-column">
|
<div class="footer flex items-center flex-column">
|
||||||
<div>
|
<div>
|
||||||
<span class="size-h3">Glance</span> ({{ .App.Version }})
|
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-horizontal-text margin-top-5 size-h5 color-primary">
|
<a class="color-primary block margin-top-5 size-h5" href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a>
|
||||||
<li><a href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a></li>
|
|
||||||
<li><a href="https://github.com/glanceapp/glance/discussions" target="_blank" rel="noreferrer">Submit feedback</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text margin-top-7">
|
<ul class="list-horizontal-text margin-top-7">
|
||||||
<li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li>{{ .Score | formatNumber }} points</li>
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text margin-top-7">
|
<ul class="list-horizontal-text margin-top-7">
|
||||||
<li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li>{{ .Score | formatNumber }} points</li>
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-10 list-collapsible">
|
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range $i, $release := .Releases }}
|
{{ range $i, $release := .Releases }}
|
||||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
<li>
|
||||||
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li title="{{ $release.TimeReleased | formatTime }}" {{ dynamicRelativeTimeAttrs $release.TimeReleased }}>{{ $release.TimeReleased | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs $release.TimeReleased }}></li>
|
||||||
<li>{{ $release.Version }}</li>
|
<li>{{ $release.Version }}</li>
|
||||||
{{ if gt $release.Downvotes 3 }}
|
{{ if gt $release.Downvotes 3 }}
|
||||||
<li>{{ $release.Downvotes | formatNumber }} ⚠</li>
|
<li>{{ $release.Downvotes | formatNumber }} ⚠</li>
|
||||||
@ -15,7 +15,4 @@
|
|||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
{{ if gt (len .Releases) $.CollapseAfter }}
|
|
||||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
<div class="flex gap-7 size-h5 margin-top-3">
|
<div class="flex gap-7 size-h5 margin-top-3">
|
||||||
<ul class="list list-gap-2">
|
<ul class="list list-gap-2">
|
||||||
{{ range .RepositoryDetails.PullRequests }}
|
{{ range .RepositoryDetails.PullRequests }}
|
||||||
<li title="{{ .CreatedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .CreatedAt }}>{{ .CreatedAt | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="list list-gap-2 min-width-0">
|
<ul class="list list-gap-2 min-width-0">
|
||||||
@ -30,7 +30,7 @@
|
|||||||
<div class="flex gap-7 size-h5 margin-top-3">
|
<div class="flex gap-7 size-h5 margin-top-3">
|
||||||
<ul class="list list-gap-2">
|
<ul class="list list-gap-2">
|
||||||
{{ range .RepositoryDetails.Issues }}
|
{{ range .RepositoryDetails.Issues }}
|
||||||
<li title="{{ .CreatedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .CreatedAt }}>{{ .CreatedAt | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="list list-gap-2 min-width-0">
|
<ul class="list list-gap-2 min-width-0">
|
||||||
|
38
internal/assets/templates/rss-detailed-list.html
Normal file
38
internal/assets/templates/rss-detailed-list.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<ul class="list list-gap-24 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
|
{{ range .Items }}
|
||||||
|
<li class="flex gap-15 items-start row-reverse-on-mobile thumbnail-parent">
|
||||||
|
<div class="thumbnail-container rss-detailed-thumbnail">
|
||||||
|
{{ if ne "" .ImageURL }}
|
||||||
|
<img class="thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||||
|
{{ else }}
|
||||||
|
<svg class="scale-half hide-on-mobile" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||||
|
</svg>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="grow min-width-0">
|
||||||
|
<a class="size-h3 color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text flex-nowrap">
|
||||||
|
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
|
<li class="min-width-0">
|
||||||
|
<a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{{ if ne "" .Description }}
|
||||||
|
<p class="rss-detailed-description text-truncate-2-lines margin-top-10">{{ .Description }}</p>
|
||||||
|
{{ end }}
|
||||||
|
{{ if gt (len .Categories) 0 }}
|
||||||
|
<ul class="attachments margin-top-10">
|
||||||
|
{{ range .Categories }}
|
||||||
|
<li>{{ . }}</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
@ -6,7 +6,7 @@
|
|||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
|
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
|
||||||
{{ range .Items }}
|
{{ range .Items }}
|
||||||
<div class="card rss-card-2 widget-content-frame thumbnail-container">
|
<div class="card rss-card-2 widget-content-frame thumbnail-parent">
|
||||||
{{ if ne "" .ImageURL }}
|
{{ if ne "" .ImageURL }}
|
||||||
<img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
<img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||||
{{ else }}
|
{{ else }}
|
||||||
@ -17,8 +17,8 @@
|
|||||||
<div class="rss-card-2-content padding-inline-widget">
|
<div class="rss-card-2-content padding-inline-widget">
|
||||||
<a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text flex-nowrap margin-top-5">
|
<ul class="list-horizontal-text flex-nowrap margin-top-5">
|
||||||
<li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
<li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
|
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
|
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
|
||||||
{{ range .Items }}
|
{{ range .Items }}
|
||||||
<div class="card widget-content-frame thumbnail-container">
|
<div class="card widget-content-frame thumbnail-parent">
|
||||||
{{ if ne "" .ImageURL }}
|
{{ if ne "" .ImageURL }}
|
||||||
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||||
{{ else }}
|
{{ else }}
|
||||||
@ -17,8 +17,8 @@
|
|||||||
<div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
|
<div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
|
||||||
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
||||||
<li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
<li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
|
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,20 +1,17 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-14 list-collapsible">
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range $i, $item := .Items }}
|
{{ range .Items }}
|
||||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
<li>
|
||||||
<a class="size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a class="size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text flex-nowrap">
|
||||||
<li title="{{ $item.PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs $item.PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
{{ if gt (len $.FeedRequests) 1 }}
|
<li class="min-width-0">
|
||||||
<li><a href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a></li>
|
<a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
|
||||||
{{ end }}
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
{{ if gt (len .Items) $.CollapseAfter }}
|
|
||||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
24
internal/assets/templates/search.html
Normal file
24
internal/assets/templates/search.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}">
|
||||||
|
<div class="search-bangs">
|
||||||
|
{{ range .Bangs }}
|
||||||
|
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-icon-container">
|
||||||
|
<svg class="search-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off">
|
||||||
|
|
||||||
|
<div class="search-bang"></div>
|
||||||
|
<kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
@ -21,7 +21,7 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "stock" }}
|
{{ define "stock" }}
|
||||||
<div class="shrink min-width-0">
|
<div class="min-width-0">
|
||||||
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
|
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
|
||||||
<div class="text-truncate">{{ .Name }}</div>
|
<div class="text-truncate">{{ .Name }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,27 +1,27 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-14 list-collapsible">
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range $i, $channel := .Channels }}
|
{{ range .Channels }}
|
||||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
<li>
|
||||||
<div class="{{ if $channel.IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-container">
|
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
|
||||||
<div class="twitch-channel-avatar-container">
|
<div class="twitch-channel-avatar-container">
|
||||||
{{ if $channel.Exists }}
|
{{ if .Exists }}
|
||||||
<img class="twitch-channel-avatar thumbnail" src="{{ $channel.AvatarUrl }}" alt="" loading="lazy">
|
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink min-width-0">
|
<div class="min-width-0">
|
||||||
<a href="https://twitch.tv/{{ $channel.Login }}" class="size-h3{{ if $channel.IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ $channel.Name }}</a>
|
<a href="https://twitch.tv/{{ .Login }}" class="size-h3{{ if .IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
{{ if $channel.Exists }}
|
{{ if .Exists }}
|
||||||
{{ if $channel.IsLive }}
|
{{ if .IsLive }}
|
||||||
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ $channel.CategorySlug }}" target="_blank" rel="noreferrer">{{ $channel.Category }}</a>
|
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li title="{{ $channel.LiveSince | formatTime }}" {{ dynamicRelativeTimeAttrs $channel.LiveSince }}>{{ $channel.LiveSince | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
|
||||||
<li>{{ $channel.ViewersCount | formatViewerCount }} viewers</li>
|
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
|
||||||
</ul>
|
</ul>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<div>Offline</div>
|
<div>Offline</div>
|
||||||
@ -34,7 +34,4 @@
|
|||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
{{ if gt (len .Channels) $.CollapseAfter }}
|
|
||||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
@ -1,26 +1,25 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-14 list-collapsible">
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range $i, $category := .Categories }}
|
{{ range .Categories }}
|
||||||
{{ $shouldCollapseItem := shouldCollapse $i $.CollapseAfter }}
|
<li class="twitch-category thumbnail-parent">
|
||||||
<li class="twitch-category thumbnail-container{{ if $shouldCollapseItem }} list-collapsible-item{{ end }}" {{ if $shouldCollapseItem }}style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
|
||||||
<div class="flex gap-10 items-center">
|
<div class="flex gap-10 items-center">
|
||||||
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ $category.AvatarUrl }}" alt="">
|
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ .AvatarUrl }}" alt="">
|
||||||
<div class="shrink min-width-0">
|
<div class="min-width-0">
|
||||||
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ $category.Slug }}" target="_blank" rel="noreferrer">{{ $category.Name }}</a>
|
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li>{{ $category.ViewersCount | formatViewerCount }} viewers</li>
|
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
|
||||||
{{ if $category.IsNew }}
|
{{ if .IsNew }}
|
||||||
<li class="color-primary">NEW</li>
|
<li class="color-primary">NEW</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="list-horizontal-text flex-nowrap">
|
<ul class="list-horizontal-text flex-nowrap">
|
||||||
{{ range $i, $tag := $category.Tags }}
|
{{ range $i, $tag := .Tags }}
|
||||||
{{ if eq $i 0 }}
|
{{ if eq $i 0 }}
|
||||||
<li class="shrink-0">{{ $tag.Name }}</li>
|
<li class="shrink-0">{{ $tag.Name }}</li>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<li class="text-truncate shrink min-width-0">{{ $tag.Name }}</li>
|
<li class="text-truncate min-width-0">{{ $tag.Name }}</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
@ -29,7 +28,4 @@
|
|||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
{{ if gt (len .Categories) $.CollapseAfter }}
|
|
||||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
@ -3,8 +3,8 @@
|
|||||||
<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
|
<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
|
||||||
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
|
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
||||||
<li class="shrink-0" title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li class="shrink min-width-0">
|
<li class="min-width-0">
|
||||||
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
|
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -3,9 +3,9 @@
|
|||||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<div class="cards-grid">
|
<div class="cards-grid collapsible-container" data-collapse-after-rows="{{ .CollapseAfterRows }}">
|
||||||
{{ range .Videos }}
|
{{ range .Videos }}
|
||||||
<div class="card widget-content-frame thumbnail-container">
|
<div class="card widget-content-frame thumbnail-parent">
|
||||||
{{ template "video-card-contents" . }}
|
{{ template "video-card-contents" . }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
<div class="cards-horizontal carousel-items-container">
|
<div class="cards-horizontal carousel-items-container">
|
||||||
{{ range .Videos }}
|
{{ range .Videos }}
|
||||||
<div class="card widget-content-frame thumbnail-container">
|
<div class="card widget-content-frame thumbnail-parent">
|
||||||
{{ template "video-card-contents" . }}
|
{{ template "video-card-contents" . }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
139
internal/feed/changedetection.go
Normal file
139
internal/feed/changedetection.go
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChangeDetectionWatch struct {
|
||||||
|
Title string
|
||||||
|
URL string
|
||||||
|
LastChanged time.Time
|
||||||
|
DiffURL string
|
||||||
|
PreviousHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChangeDetectionWatches []ChangeDetectionWatch
|
||||||
|
|
||||||
|
func (r ChangeDetectionWatches) SortByNewest() ChangeDetectionWatches {
|
||||||
|
sort.Slice(r, func(i, j int) bool {
|
||||||
|
return r[i].LastChanged.After(r[j].LastChanged)
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type changeDetectionResponseJson struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
LastChanged int64 `json:"last_changed"`
|
||||||
|
DateCreated int64 `json:"date_created"`
|
||||||
|
PreviousHash string `json:"previous_md5"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) {
|
||||||
|
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil)
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
request.Header.Add("x-api-key", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultClient, request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uuids := make([]string, 0, len(uuidsMap))
|
||||||
|
|
||||||
|
for uuid := range uuidsMap {
|
||||||
|
uuids = append(uuids, uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) {
|
||||||
|
watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs))
|
||||||
|
|
||||||
|
if len(requestedWatchIDs) == 0 {
|
||||||
|
return watches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := make([]*http.Request, len(requestedWatchIDs))
|
||||||
|
|
||||||
|
for i, repository := range requestedWatchIDs {
|
||||||
|
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch/%s", instanceURL, repository), nil)
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
request.Header.Add("x-api-key", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
requests[i] = request
|
||||||
|
}
|
||||||
|
|
||||||
|
task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultClient)
|
||||||
|
job := newJob(task, requests).withWorkers(15)
|
||||||
|
responses, errs, err := workerPoolDo(job)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var failed int
|
||||||
|
|
||||||
|
for i := range responses {
|
||||||
|
if errs[i] != nil {
|
||||||
|
failed++
|
||||||
|
slog.Error("Failed to fetch or parse change detection watch", "error", errs[i], "url", requests[i].URL)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
watchJson := responses[i]
|
||||||
|
|
||||||
|
watch := ChangeDetectionWatch{
|
||||||
|
URL: watchJson.URL,
|
||||||
|
DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1),
|
||||||
|
}
|
||||||
|
|
||||||
|
if watchJson.LastChanged == 0 {
|
||||||
|
watch.LastChanged = time.Unix(watchJson.DateCreated, 0)
|
||||||
|
} else {
|
||||||
|
watch.LastChanged = time.Unix(watchJson.LastChanged, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if watchJson.Title != "" {
|
||||||
|
watch.Title = watchJson.Title
|
||||||
|
} else {
|
||||||
|
watch.Title = strings.TrimPrefix(strings.Trim(stripURLScheme(watchJson.URL), "/"), "www.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if watchJson.PreviousHash != "" {
|
||||||
|
var hashLength = 8
|
||||||
|
|
||||||
|
if len(watchJson.PreviousHash) < hashLength {
|
||||||
|
hashLength = len(watchJson.PreviousHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch.PreviousHash = watchJson.PreviousHash[0:hashLength]
|
||||||
|
}
|
||||||
|
|
||||||
|
watches = append(watches, watch)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(watches) == 0 {
|
||||||
|
return nil, ErrNoContent
|
||||||
|
}
|
||||||
|
|
||||||
|
watches.SortByNewest()
|
||||||
|
|
||||||
|
if failed > 0 {
|
||||||
|
return watches, fmt.Errorf("%w: could not get %d watches", ErrPartialContent, failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return watches, nil
|
||||||
|
}
|
@ -3,8 +3,11 @@ package feed
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mmcdole/gofeed"
|
"github.com/mmcdole/gofeed"
|
||||||
@ -16,12 +19,34 @@ type RSSFeedItem struct {
|
|||||||
Title string
|
Title string
|
||||||
Link string
|
Link string
|
||||||
ImageURL string
|
ImageURL string
|
||||||
|
Categories []string
|
||||||
|
Description string
|
||||||
PublishedAt time.Time
|
PublishedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doesn't cover all cases but works the vast majority of the time
|
||||||
|
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
|
||||||
|
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
|
||||||
|
|
||||||
|
func sanitizeFeedDescription(description string) string {
|
||||||
|
if description == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
description = strings.ReplaceAll(description, "\n", " ")
|
||||||
|
description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
|
||||||
|
description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
|
||||||
|
description = strings.TrimSpace(description)
|
||||||
|
description = html.UnescapeString(description)
|
||||||
|
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
|
||||||
type RSSFeedRequest struct {
|
type RSSFeedRequest struct {
|
||||||
Url string `yaml:"url"`
|
Url string `yaml:"url"`
|
||||||
Title string `yaml:"title"`
|
Title string `yaml:"title"`
|
||||||
|
HideCategories bool `yaml:"hide-categories"`
|
||||||
|
HideDescription bool `yaml:"hide-description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RSSFeedItems []RSSFeedItem
|
type RSSFeedItems []RSSFeedItem
|
||||||
@ -57,6 +82,36 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
|||||||
Link: item.Link,
|
Link: item.Link,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !request.HideDescription && item.Description != "" {
|
||||||
|
description, _ := limitStringLength(item.Description, 1000)
|
||||||
|
description = sanitizeFeedDescription(description)
|
||||||
|
description, limited := limitStringLength(description, 200)
|
||||||
|
|
||||||
|
if limited {
|
||||||
|
description += "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
rssItem.Description = description
|
||||||
|
}
|
||||||
|
|
||||||
|
if !request.HideCategories {
|
||||||
|
var categories = make([]string, 0, 6)
|
||||||
|
|
||||||
|
for _, category := range item.Categories {
|
||||||
|
if len(categories) == 6 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(category) == 0 || len(category) > 30 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
categories = append(categories, category)
|
||||||
|
}
|
||||||
|
|
||||||
|
rssItem.Categories = categories
|
||||||
|
}
|
||||||
|
|
||||||
if request.Title != "" {
|
if request.Title != "" {
|
||||||
rssItem.ChannelName = request.Title
|
rssItem.ChannelName = request.Title
|
||||||
} else {
|
} else {
|
||||||
|
@ -44,6 +44,12 @@ func (channels TwitchChannels) SortByViewers() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (channels TwitchChannels) SortByLive() {
|
||||||
|
sort.SliceStable(channels, func(i, j int) bool {
|
||||||
|
return channels[i].IsLive && !channels[j].IsLive
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type twitchOperationResponse struct {
|
type twitchOperationResponse struct {
|
||||||
Data json.RawMessage
|
Data json.RawMessage
|
||||||
Extensions struct {
|
Extensions struct {
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@ -77,3 +78,20 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
|
|||||||
|
|
||||||
return values
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
|
||||||
|
|
||||||
|
func stripURLScheme(url string) string {
|
||||||
|
return urlSchemePattern.ReplaceAllString(url, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func limitStringLength(s string, max int) (string, bool) {
|
||||||
|
asRunes := []rune(s)
|
||||||
|
|
||||||
|
if len(asRunes) > max {
|
||||||
|
return string(asRunes[:max]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, false
|
||||||
|
}
|
||||||
|
@ -36,7 +36,7 @@ func Main() int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if app.Serve() != nil {
|
if err := app.Serve(); err != nil {
|
||||||
fmt.Printf("http server error: %v\n", err)
|
fmt.Printf("http server error: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
66
internal/widget/changedetection.go
Normal file
66
internal/widget/changedetection.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
|
"github.com/glanceapp/glance/internal/feed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChangeDetection struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
ChangeDetections feed.ChangeDetectionWatches `yaml:"-"`
|
||||||
|
WatchUUIDs []string `yaml:"watches"`
|
||||||
|
InstanceURL string `yaml:"instance-url"`
|
||||||
|
Token OptionalEnvString `yaml:"token"`
|
||||||
|
Limit int `yaml:"limit"`
|
||||||
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *ChangeDetection) Initialize() error {
|
||||||
|
widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour)
|
||||||
|
|
||||||
|
if widget.Limit <= 0 {
|
||||||
|
widget.Limit = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||||
|
widget.CollapseAfter = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.InstanceURL == "" {
|
||||||
|
widget.InstanceURL = "https://www.changedetection.io"
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *ChangeDetection) Update(ctx context.Context) {
|
||||||
|
if len(widget.WatchUUIDs) == 0 {
|
||||||
|
uuids, err := feed.FetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token))
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.WatchUUIDs = uuids
|
||||||
|
}
|
||||||
|
|
||||||
|
watches, err := feed.FetchWatchesFromChangeDetection(widget.InstanceURL, widget.WatchUUIDs, string(widget.Token))
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(watches) > widget.Limit {
|
||||||
|
watches = watches[:widget.Limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.ChangeDetections = watches
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *ChangeDetection) Render() template.HTML {
|
||||||
|
return widget.render(widget, assets.ChangeDetectionTemplate)
|
||||||
|
}
|
50
internal/widget/clock.go
Normal file
50
internal/widget/clock.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Clock struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
cachedHTML template.HTML `yaml:"-"`
|
||||||
|
HourFormat string `yaml:"hour-format"`
|
||||||
|
Timezones []struct {
|
||||||
|
Timezone string `yaml:"timezone"`
|
||||||
|
Label string `yaml:"label"`
|
||||||
|
} `yaml:"timezones"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Clock) Initialize() error {
|
||||||
|
widget.withTitle("Clock").withError(nil)
|
||||||
|
|
||||||
|
if widget.HourFormat == "" {
|
||||||
|
widget.HourFormat = "24h"
|
||||||
|
} else if widget.HourFormat != "12h" && widget.HourFormat != "24h" {
|
||||||
|
return errors.New("invalid hour format for clock widget, must be either 12h or 24h")
|
||||||
|
}
|
||||||
|
|
||||||
|
for t := range widget.Timezones {
|
||||||
|
if widget.Timezones[t].Timezone == "" {
|
||||||
|
return errors.New("missing timezone value for clock widget")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := time.LoadLocation(widget.Timezones[t].Timezone)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid timezone '%s' for clock widget: %v", widget.Timezones[t].Timezone, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.cachedHTML = widget.render(widget, assets.ClockTemplate)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Clock) Render() template.HTML {
|
||||||
|
return widget.cachedHTML
|
||||||
|
}
|
@ -39,6 +39,13 @@ func (widget *RSS) Initialize() error {
|
|||||||
widget.CardHeight = 0
|
widget.CardHeight = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if widget.Style != "detailed-list" {
|
||||||
|
for i := range widget.FeedRequests {
|
||||||
|
widget.FeedRequests[i].HideCategories = true
|
||||||
|
widget.FeedRequests[i].HideDescription = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,5 +72,9 @@ func (widget *RSS) Render() template.HTML {
|
|||||||
return widget.render(widget, assets.RSSHorizontalCards2Template)
|
return widget.render(widget, assets.RSSHorizontalCards2Template)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if widget.Style == "detailed-list" {
|
||||||
|
return widget.render(widget, assets.RSSDetailedListTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
return widget.render(widget, assets.RSSListTemplate)
|
return widget.render(widget, assets.RSSListTemplate)
|
||||||
}
|
}
|
||||||
|
66
internal/widget/search.go
Normal file
66
internal/widget/search.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SearchBang struct {
|
||||||
|
Title string
|
||||||
|
Shortcut string
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Search struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
cachedHTML template.HTML `yaml:"-"`
|
||||||
|
SearchEngine string `yaml:"search-engine"`
|
||||||
|
Bangs []SearchBang `yaml:"bangs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertSearchUrl(url string) string {
|
||||||
|
// Go's template is being stubborn and continues to escape the curlies in the
|
||||||
|
// URL regardless of what the type of the variable is so this is my way around it
|
||||||
|
return strings.ReplaceAll(url, "{QUERY}", "!QUERY!")
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchEngines = map[string]string{
|
||||||
|
"duckduckgo": "https://duckduckgo.com/?q={QUERY}",
|
||||||
|
"google": "https://www.google.com/search?q={QUERY}",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Search) Initialize() error {
|
||||||
|
widget.withTitle("Search").withError(nil)
|
||||||
|
|
||||||
|
if widget.SearchEngine == "" {
|
||||||
|
widget.SearchEngine = "duckduckgo"
|
||||||
|
}
|
||||||
|
|
||||||
|
if url, ok := searchEngines[widget.SearchEngine]; ok {
|
||||||
|
widget.SearchEngine = url
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.SearchEngine = convertSearchUrl(widget.SearchEngine)
|
||||||
|
|
||||||
|
for i := range widget.Bangs {
|
||||||
|
if widget.Bangs[i].Shortcut == "" {
|
||||||
|
return fmt.Errorf("Search bang %d has no shortcut", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Bangs[i].URL == "" {
|
||||||
|
return fmt.Errorf("Search bang %d has no URL", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.Bangs[i].URL = convertSearchUrl(widget.Bangs[i].URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.cachedHTML = widget.render(widget, assets.SearchTemplate)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Search) Render() template.HTML {
|
||||||
|
return widget.cachedHTML
|
||||||
|
}
|
@ -14,6 +14,7 @@ type TwitchChannels struct {
|
|||||||
ChannelsRequest []string `yaml:"channels"`
|
ChannelsRequest []string `yaml:"channels"`
|
||||||
Channels []feed.TwitchChannel `yaml:"-"`
|
Channels []feed.TwitchChannel `yaml:"-"`
|
||||||
CollapseAfter int `yaml:"collapse-after"`
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
|
SortBy string `yaml:"sort-by"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *TwitchChannels) Initialize() error {
|
func (widget *TwitchChannels) Initialize() error {
|
||||||
@ -23,6 +24,10 @@ func (widget *TwitchChannels) Initialize() error {
|
|||||||
widget.CollapseAfter = 5
|
widget.CollapseAfter = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if widget.SortBy != "viewers" && widget.SortBy != "live" {
|
||||||
|
widget.SortBy = "viewers"
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,7 +38,12 @@ func (widget *TwitchChannels) Update(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
channels.SortByViewers()
|
if widget.SortBy == "viewers" {
|
||||||
|
channels.SortByViewers()
|
||||||
|
} else if widget.SortBy == "live" {
|
||||||
|
channels.SortByLive()
|
||||||
|
}
|
||||||
|
|
||||||
widget.Channels = channels
|
widget.Channels = channels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,12 +10,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Videos struct {
|
type Videos struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
Videos feed.Videos `yaml:"-"`
|
Videos feed.Videos `yaml:"-"`
|
||||||
VideoUrlTemplate string `yaml:"video-url-template"`
|
VideoUrlTemplate string `yaml:"video-url-template"`
|
||||||
Style string `yaml:"style"`
|
Style string `yaml:"style"`
|
||||||
Channels []string `yaml:"channels"`
|
CollapseAfterRows int `yaml:"collapse-after-rows"`
|
||||||
Limit int `yaml:"limit"`
|
Channels []string `yaml:"channels"`
|
||||||
|
Limit int `yaml:"limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Videos) Initialize() error {
|
func (widget *Videos) Initialize() error {
|
||||||
@ -25,6 +26,10 @@ func (widget *Videos) Initialize() error {
|
|||||||
widget.Limit = 25
|
widget.Limit = 25
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if widget.CollapseAfterRows == 0 || widget.CollapseAfterRows < -1 {
|
||||||
|
widget.CollapseAfterRows = 4
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,17 +14,26 @@ type Weather struct {
|
|||||||
Location string `yaml:"location"`
|
Location string `yaml:"location"`
|
||||||
ShowAreaName bool `yaml:"show-area-name"`
|
ShowAreaName bool `yaml:"show-area-name"`
|
||||||
HideLocation bool `yaml:"hide-location"`
|
HideLocation bool `yaml:"hide-location"`
|
||||||
|
HourFormat string `yaml:"hour-format"`
|
||||||
Units string `yaml:"units"`
|
Units string `yaml:"units"`
|
||||||
Place *feed.PlaceJson `yaml:"-"`
|
Place *feed.PlaceJson `yaml:"-"`
|
||||||
Weather *feed.Weather `yaml:"-"`
|
Weather *feed.Weather `yaml:"-"`
|
||||||
TimeLabels [12]string `yaml:"-"`
|
TimeLabels [12]string `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var timeLabels = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"}
|
var timeLabels12h = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"}
|
||||||
|
var timeLabels24h = [12]string{"02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00", "22:00", "00:00"}
|
||||||
|
|
||||||
func (widget *Weather) Initialize() error {
|
func (widget *Weather) Initialize() error {
|
||||||
widget.withTitle("Weather").withCacheOnTheHour()
|
widget.withTitle("Weather").withCacheOnTheHour()
|
||||||
widget.TimeLabels = timeLabels
|
|
||||||
|
if widget.HourFormat == "" || widget.HourFormat == "12h" {
|
||||||
|
widget.TimeLabels = timeLabels12h
|
||||||
|
} else if widget.HourFormat == "24h" {
|
||||||
|
widget.TimeLabels = timeLabels24h
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("invalid hour format '%s' for weather widget, must be either 12h or 24h", widget.HourFormat)
|
||||||
|
}
|
||||||
|
|
||||||
if widget.Units == "" {
|
if widget.Units == "" {
|
||||||
widget.Units = "metric"
|
widget.Units = "metric"
|
||||||
|
@ -19,6 +19,8 @@ func New(widgetType string) (Widget, error) {
|
|||||||
switch widgetType {
|
switch widgetType {
|
||||||
case "calendar":
|
case "calendar":
|
||||||
return &Calendar{}, nil
|
return &Calendar{}, nil
|
||||||
|
case "clock":
|
||||||
|
return &Clock{}, nil
|
||||||
case "weather":
|
case "weather":
|
||||||
return &Weather{}, nil
|
return &Weather{}, nil
|
||||||
case "bookmarks":
|
case "bookmarks":
|
||||||
@ -45,8 +47,12 @@ func New(widgetType string) (Widget, error) {
|
|||||||
return &TwitchChannels{}, nil
|
return &TwitchChannels{}, nil
|
||||||
case "lobsters":
|
case "lobsters":
|
||||||
return &Lobsters{}, nil
|
return &Lobsters{}, nil
|
||||||
|
case "change-detection":
|
||||||
|
return &ChangeDetection{}, nil
|
||||||
case "repository":
|
case "repository":
|
||||||
return &Repository{}, nil
|
return &Repository{}, nil
|
||||||
|
case "search":
|
||||||
|
return &Search{}, nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@ -77,7 +78,40 @@ var buildTargets = []buildTarget{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasUncommitedChanges() (bool, error) {
|
||||||
|
output, err := exec.Command("git", "status", "--porcelain").CombinedOutput()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(output) > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
flags := flag.NewFlagSet("", flag.ExitOnError)
|
||||||
|
|
||||||
|
specificTag := flags.String("tag", "", "Which tagged version to build")
|
||||||
|
|
||||||
|
err := flags.Parse(os.Args[1:])
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
uncommitedChanges, err := hasUncommitedChanges()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if uncommitedChanges {
|
||||||
|
fmt.Println("There are uncommited changes - commit, stash or discard them first")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
cwd, err := os.Getwd()
|
cwd, err := os.Getwd()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -95,10 +129,24 @@ func main() {
|
|||||||
os.Mkdir(buildPath, 0755)
|
os.Mkdir(buildPath, 0755)
|
||||||
os.Mkdir(archivesPath, 0755)
|
os.Mkdir(archivesPath, 0755)
|
||||||
|
|
||||||
version, err := getVersionFromGit()
|
var version string
|
||||||
|
|
||||||
|
if *specificTag == "" {
|
||||||
|
version, err := getVersionFromGit()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(version, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
version = *specificTag
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := exec.Command("git", "checkout", "tags/"+version).CombinedOutput()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(version, err)
|
fmt.Println(string(output))
|
||||||
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,13 +167,19 @@ func main() {
|
|||||||
|
|
||||||
fmt.Println("Building docker image")
|
fmt.Println("Building docker image")
|
||||||
|
|
||||||
output, err := exec.Command(
|
var dockerBuildOptions = []string{
|
||||||
"sudo", "docker", "build",
|
"docker", "build",
|
||||||
"--platform=linux/amd64,linux/arm64,linux/arm/v7",
|
"--platform=linux/amd64,linux/arm64,linux/arm/v7",
|
||||||
"-t", versionTag,
|
"-t", versionTag,
|
||||||
"-t", latestTag,
|
}
|
||||||
".",
|
|
||||||
).CombinedOutput()
|
if !strings.Contains(version, "beta") {
|
||||||
|
dockerBuildOptions = append(dockerBuildOptions, "-t", latestTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
dockerBuildOptions = append(dockerBuildOptions, ".")
|
||||||
|
|
||||||
|
output, err = exec.Command("sudo", dockerBuildOptions...).CombinedOutput()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(string(output))
|
fmt.Println(string(output))
|
||||||
@ -152,6 +206,10 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(version, "beta") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
output, err = exec.Command(
|
output, err = exec.Command(
|
||||||
"sudo", "docker", "push", latestTag,
|
"sudo", "docker", "push", latestTag,
|
||||||
).CombinedOutput()
|
).CombinedOutput()
|
||||||
|
Loading…
Reference in New Issue
Block a user