Compare commits

..
66 Commits
Author SHA1 Message Date
Svilen Markov 917e2cabfc Merge branch 'main' into release/v0.6.0
Create release / release (push) Has been cancelled
2024-08-07 19:03:12 +01:00
Svilen Markov 404a2acc8f Remove extra margin from mobile nav offset 2024-08-07 11:16:30 +01:00
Svilen Markov 7bf61ee8ed Remove app name from title 2024-08-07 04:05:10 +01:00
Svilen Markov bf1cc47525 Fix & increase offset of loading indicator 2024-08-05 23:16:32 +01:00
Svilen Markov 74ad27d5fb Rejig CSS 2024-08-05 22:50:44 +01:00
Svilen Markov 60b73aff24 Merge pull request #167 from CremaLuca/main
feat: serving behind reverse proxy
2024-08-05 14:12:20 +01:00
Svilen Markov 4de465e544 Add some foolproofery 2024-08-05 14:11:19 +01:00
Svilen Markov e1161b9227 Refactor base-url & add documentation 2024-08-05 14:08:16 +01:00
Svilen Markov c2cdd0fa08 Merge branch 'release/v0.6.0' into main 2024-08-05 13:26:46 +01:00
Svilen Markov b365a03e71 Allow hiding desktop navigation 2024-08-05 12:42:41 +01:00
Svilen Markov 2e629efc7f Remove link from footer 2024-08-05 12:28:08 +01:00
Svilen Markov fcaa2bef6e Vertically center loading indicator 2024-08-05 12:15:55 +01:00
Svilen Markov 3b2a9fedef Move footer to bottom of viewport 2024-08-05 12:00:20 +01:00
Svilen Markov 64256f2638 Fix value 2024-08-05 11:40:32 +01:00
Svilen Markov 5c8122d93b Fix extra margin at bottom of page 2024-08-05 11:17:46 +01:00
Svilen Markov 6ffb95779d Don't force scrollbar when not needed 2024-08-05 11:12:25 +01:00
Svilen Markov af975d0e7f Bump versions 2024-08-05 00:00:11 +01:00
Svilen Markov 34b2b88cf3 Merge branch 'main' into release/v0.6.0 2024-08-04 23:46:28 +01:00
Svilen Markov 3fea166274 Add page width customizability 2024-08-04 23:40:58 +01:00
Svilen Markov 233b905492 Merge pull request #177 from MrExplode/monitor-urls
Different monitor urls
2024-08-04 20:55:02 +01:00
Svilen Markov 7afad765cb Use hyphen instead of underscore 2024-08-04 20:54:30 +01:00
MrExplode 39663cb594 Document monitor URL changes 2024-08-03 14:14:42 +02:00
MrExplode 457e0539fa Allow empty check URL 2024-08-03 14:14:24 +02:00
MrExplode 871ba619a3 Add separate URL for monitor check 2024-08-03 14:06:52 +02:00
Svilen Markov f5427310e8 Document include-shorts 2024-08-03 04:25:07 +01:00
Svilen Markov e4435252f4 Merge pull request #176 from 3rd/release/v0.6.0
feat: add no_shorts option for YouTube feeds
2024-08-03 04:05:56 +01:00
Svilen Markov a66aa74c7f Invert property 2024-08-03 04:04:02 +01:00
Svilen Markov 3949e15f77 Remove previous scuffed attempt at filtering shorts 2024-08-03 04:03:16 +01:00
Svilen Markov acaf50bf8a Use author > name instead of title
This is because the playlist response returns the name of the playlist in the title,
which meant that the channel name was always "Videos"
2024-08-03 04:02:23 +01:00
3rd 09eedc08c1 feat: add no_shorts option for YouTube feeds 2024-08-02 23:39:27 +03:00
Svilen Markov 738bcf8bcb Add group widget 2024-08-01 21:34:07 +01:00
Svilen Markov 795caa5d9d Allow widgets to handle HTTP requests 2024-08-01 18:26:38 +01:00
Svilen Markov c041197f3f Merge pull request #173 from DVDAndroid/dvd/assets-fixes
Fix crash when using a custom CSS file; fix error 404 for manifest.json
2024-07-26 16:30:55 +01:00
dvdandroid 0730789cb3 Fix crash when using a custom CSS file; fix error 404 for manifest.json 2024-07-25 16:57:10 +02:00
Svilen Markov 1eb0fbd2c9 Merge pull request #171 from DVDAndroid/dvd/twich-nocategory-fix
Fix incorrect LiveSince if API returned no game
2024-07-25 00:42:59 +01:00
Svilen Markov 0484122b7f Merge pull request #172 from DVDAndroid/dvd/clickable-twitch-avatar
Make Twitch avatar clickable
2024-07-25 00:42:46 +01:00
dvdandroid 7bc9c704d1 Don't render "empty" game category 2024-07-24 21:50:00 +02:00
dvdandroid c9efdc2c16 Fix incorrect LiveSince if API returned no game
(streamer can stream without a category/game associated)
2024-07-24 21:38:35 +02:00
dvdandroid e0dee998d4 Make Twitch avatar clickable 2024-07-23 23:17:28 +02:00
Svilen Markov e09ea60616 Update dependencies 2024-07-15 21:59:51 +01:00
Svilen Markov 7cab07fa01 Bump alpine version 2024-07-15 21:59:51 +01:00
Svilen Markov 5a62a8afe9 Add autofocus property to search widget 2024-07-15 21:59:51 +01:00
CremaLuca 23d6d41148 fix(serve): using paths without base url 2024-07-10 15:40:35 +02:00
Luca Crema a07fbfd7b7 Added baseurl somewhere 2024-07-10 15:35:25 +02:00
Svilen Markov 9bda88edbd Merge pull request #145 from ssrtw/rss-feed-enhance
Rss feed enhance
2024-07-03 19:49:32 +01:00
Svilen Markov 16da25898f Prevent users from modifying internal property 2024-07-03 19:48:58 +01:00
Svilen Markov 88ae2b8e83 Merge pull request #164 from dracarys18/main
fix: allow custom bangs without an argument
2024-07-03 19:26:54 +01:00
dracarys18 3805f8b156 fix: allow custom bangs without an argument 2024-07-03 02:46:16 +05:30
Svilen Markov 39f40670d0 Fix missing RSS thumbnail images 2024-06-29 17:17:27 +01:00
Svilen Markov 514cf2b81c Allow setting widget title URL 2024-06-29 16:10:43 +01:00
Svilen Markov 4dbb5975c0 Allow setting custom CSS classes for individual widgets 2024-06-29 15:55:17 +01:00
Svilen Markov b327e59ab1 Allow specifying a custom lobsters URL or instance 2024-06-29 15:43:07 +01:00
Svilen Markov 917d51e54b Make script a module 2024-06-29 15:20:26 +01:00
Svilen Markov 122d05346b Change caching mechanism 2024-06-29 15:20:26 +01:00
ssrtw a2b7f59dc9 fix: correct title handling for RSS items without titles 2024-06-13 03:13:57 +00:00
ssrtw c23dc93c7a feat: use description as fallback for RSS items without titles 2024-06-12 15:36:29 +00:00
ssrtw d3e3667405 fix: add detailed mode flag to fix RSS logic conflict 2024-06-12 03:28:43 +00:00
Svilen Markov d4ed5afe93 Merge pull request #134 from wfg/github-latest-release-fix
Use GitHub's latest release API endpoint
2024-06-11 20:43:35 +01:00
Svilen Markov 790ecef1c1 Merge pull request #137 from ralphocdol/search-new-tab-property
feat: added property new-tab to type: search
2024-06-11 20:34:05 +01:00
Svilen Markov edb59ffdc6 Small change to implementation for new tab 2024-06-11 20:30:10 +01:00
Svilen Markov e137038586 Fix overflowing text 2024-06-11 01:16:39 +01:00
Svilen Markov 761eb343f9 Fix search input color 2024-06-11 00:35:26 +01:00
ralphocdol 06b21e3d27 feat: added property new-tab to type: search 2024-06-09 21:18:56 +08:00
Wyatt Gill 263d2e6f30 Use GitHub's latest release API endpoint
The current releases widget uses the releases endpoint to pull the 10
most recent releases and filter them to find the latest release. This
causes a problem when a repository's latest release is outside of the 10
most recent (e.g. 10 prereleases):

ERROR No live release found repository=cross-seed/cross-seed url="https://api.github.com/repos/cross-seed/cross-seed/releases?per_page=10"

This is no longer a problem when using the latest release endpoint which
grabs the latest release, ignoring draft releases and prereleases.
2024-06-07 16:27:13 -05:00
Svilen Markov c97fec66f4 Update dependencies 2024-06-04 23:42:40 +01:00
Svilen Markov 757f68c872 Increase lobsters widget cache time 2024-06-03 00:46:26 +01:00
33 changed files with 720 additions and 178 deletions
-1
View File
@@ -1 +0,0 @@
github: [glanceapp]
+107 -1
View File
@@ -13,6 +13,7 @@
- [Lobsters](#lobsters) - [Lobsters](#lobsters)
- [Reddit](#reddit) - [Reddit](#reddit)
- [Search](#search-widget) - [Search](#search-widget)
- [Group](#group)
- [Extension](#extension) - [Extension](#extension)
- [Weather](#weather) - [Weather](#weather)
- [Monitor](#monitor) - [Monitor](#monitor)
@@ -123,6 +124,7 @@ server:
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| host | string | no | | | host | string | no | |
| port | number | no | 8080 | | port | number | no | 8080 |
| base-url | string | no | |
| assets-path | string | no | | | assets-path | string | no | |
#### `host` #### `host`
@@ -131,6 +133,9 @@ The address which the server will listen on. Setting it to `localhost` means tha
#### `port` #### `port`
A number between 1 and 65,535, so long as that port isn't already used by anything else. A number between 1 and 65,535, so long as that port isn't already used by anything else.
#### `base-url`
The base URL that Glance is hosted under. No need to specify this unless you're using a reverse proxy and are hosting Glance under a directory. If that's the case then you can set this value to `/glance` or whatever the directory is called. Note that the forward slash (`/`) in the beginning is required unless you specify the full domain and path.
#### `assets-path` #### `assets-path`
The path to a directory that will be served by the server under the `/assets/` path. This is handy for widgets like the Monitor where you have to specify an icon URL and you want to self host all the icons rather than pointing to an external source. The path to a directory that will be served by the server under the `/assets/` path. This is handy for widgets like the Monitor where you have to specify an icon URL and you want to self host all the icons rather than pointing to an external source.
@@ -234,6 +239,8 @@ theme:
> .widget-type-rss a { > .widget-type-rss a {
> font-size: 1.5rem; > font-size: 1.5rem;
> } > }
>
> In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets.
## Pages & Columns ## Pages & Columns
@@ -261,6 +268,8 @@ pages:
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| title | string | yes | | | title | string | yes | |
| slug | string | no | | | slug | string | no | |
| width | string | no | |
| hide-desktop-navigation | boolean | no | false |
| show-mobile-header | boolean | no | false | | show-mobile-header | boolean | no | false |
| columns | array | yes | | | columns | array | yes | |
@@ -270,6 +279,21 @@ The name of the page which gets shown in the navigation bar.
#### `slug` #### `slug`
The URL friendly version of the title which is used to access the page. For example if the title of the page is "RSS Feeds" you can make the page accessible via `localhost:8080/feeds` by setting the slug to `feeds`. If not defined, it will automatically be generated from the title. The URL friendly version of the title which is used to access the page. For example if the title of the page is "RSS Feeds" you can make the page accessible via `localhost:8080/feeds` by setting the slug to `feeds`. If not defined, it will automatically be generated from the title.
#### `width`
The maximum width of the page on desktop. Possible values are `slim` and `wide`.
* default: `1600px`
* slim: `1100px`
* wide: `1920px`
> [!NOTE]
>
> When using `slim`, the maximum number of columns allowed for that page is `2`.
#### `hide-desktop-navigation`
Whether to show the navigation links at the top of the page on desktop.
#### `show-mobile-header` #### `show-mobile-header`
Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices. Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices.
@@ -354,7 +378,9 @@ pages:
| ---- | ---- | -------- | | ---- | ---- | -------- |
| type | string | yes | | type | string | yes |
| title | string | no | | title | string | no |
| title-url | string | no |
| cache | string | no | | cache | string | no |
| css-class | string | no |
#### `type` #### `type`
Used to specify the widget. Used to specify the widget.
@@ -362,6 +388,9 @@ Used to specify the widget.
#### `title` #### `title`
The title of the widget. If left blank it will be defined by the widget. The title of the widget. If left blank it will be defined by the widget.
#### `title-url`
The URL to go to when clicking on the widget's title. If left blank it will be defined by the widget (if available).
#### `cache` #### `cache`
How long to keep the fetched data in memory. The value is a string and must be a number followed by one of s, m, h, d. Examples: How long to keep the fetched data in memory. The value is a string and must be a number followed by one of s, m, h, d. Examples:
@@ -376,6 +405,9 @@ cache: 1d # 1 day
> >
> Not all widgets can have their cache duration modified. The calendar and weather widgets update on the hour and this cannot be changed. > Not all widgets can have their cache duration modified. The calendar and weather widgets update on the hour and this cannot be changed.
#### `css-class`
Set custom CSS classes for the specific widget instance.
### RSS ### RSS
Display a list of articles from multiple RSS feeds. Display a list of articles from multiple RSS feeds.
@@ -473,6 +505,7 @@ Preview:
| limit | integer | no | 25 | | limit | integer | no | 25 |
| style | string | no | horizontal-cards | | style | string | no | horizontal-cards |
| collapse-after-rows | integer | no | 4 | | collapse-after-rows | integer | no | 4 |
| include-shorts | boolean | no | false |
| video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} | | video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} |
##### `channels` ##### `channels`
@@ -572,11 +605,23 @@ Preview:
#### Properties #### Properties
| Name | Type | Required | Default | | Name | Type | Required | Default |
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| instance-url | string | no | https://lobste.rs/ |
| custom-url | string | no | |
| limit | integer | no | 15 | | limit | integer | no | 15 |
| collapse-after | integer | no | 5 | | collapse-after | integer | no | 5 |
| sort-by | string | no | hot | | sort-by | string | no | hot |
| tags | array | no | | | tags | array | no | |
##### `instance-url`
The base URL for a lobsters instance hosted somewhere other than on lobste.rs. Example:
```yaml
instance-url: https://www.journalduhacker.net/
```
##### `custom-url`
A custom URL to retrieve lobsters posts from. If this is specified, the `instance-url`, `sort-by` and `tags` properties are ignored.
##### `limit` ##### `limit`
The maximum number of posts to show. The maximum number of posts to show.
@@ -724,10 +769,16 @@ Preview:
| <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Perform search in a new 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 | | <kbd>Escape</kbd> | Leave focus | Search input is focused |
> [!TIP]
>
> You can use the property `new-tab` with a value of `true` if you want to show search results in a new tab by default. <kbd>Ctrl</kbd> + <kbd>Enter</kbd> will then show results in the same tab.
#### Properties #### Properties
| Name | Type | Required | Default | | Name | Type | Required | Default |
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| search-engine | string | no | duckduckgo | | search-engine | string | no | duckduckgo |
| new-tab | boolean | no | false |
| autofocus | boolean | no | false |
| bangs | array | no | | | bangs | array | no | |
##### `search-engine` ##### `search-engine`
@@ -738,6 +789,12 @@ Either a value from the table below or a URL to a custom search engine. Use `{QU
| duckduckgo | `https://duckduckgo.com/?q={QUERY}` | | duckduckgo | `https://duckduckgo.com/?q={QUERY}` |
| google | `https://www.google.com/search?q={QUERY}` | | google | `https://www.google.com/search?q={QUERY}` |
##### `new-tab`
When set to `true`, swaps the shortcuts for showing results in the same or new tab, defaulting to showing results in a new tab.
##### `new-tab`
When set to `true`, automatically focuses the search input on page load.
##### `bangs` ##### `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: What now? [Bangs](https://duckduckgo.com/bangs). They're shortcuts that allow you to use the same search box for many different sites. Assuming you have it configured, if for example you start your search input with `!yt` you'd be able to perform a search on YouTube:
@@ -772,6 +829,50 @@ url: https://store.steampowered.com/search/?term={QUERY}
url: https://www.amazon.com/s?k={QUERY} url: https://www.amazon.com/s?k={QUERY}
``` ```
### Group
Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget within a group widget.
Example:
```yaml
- type: group
widgets:
- type: reddit
subreddit: gamingnews
show-thumbnails: true
collapse-after: 6
- type: reddit
subreddit: games
- type: reddit
subreddit: pcgaming
show-thumbnails: true
```
Preview:
![](images/group-widget-preview.png)
#### Sharing properties
To avoid repetition you can use [YAML anchors](https://support.atlassian.com/bitbucket-cloud/docs/yaml-anchors/) and share properties between widgets.
Example:
```yaml
- type: group
define: &shared-properties
type: reddit
show-thumbnails: true
collapse-after: 6
widgets:
- subreddit: gamingnews
<<: *shared-properties
- subreddit: games
<<: *shared-properties
- subreddit: pcgaming
<<: *shared-properties
```
### Extension ### Extension
Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP). Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP).
@@ -917,6 +1018,7 @@ Properties for each site:
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| title | string | yes | | | title | string | yes | |
| url | string | yes | | | url | string | yes | |
| check-url | string | no | |
| icon | string | no | | | icon | string | no | |
| allow-insecure | boolean | no | false | | allow-insecure | boolean | no | false |
| same-tab | boolean | no | false | | same-tab | boolean | no | false |
@@ -927,7 +1029,11 @@ The title used to indicate the site.
`url` `url`
The URL which will be requested and its response will determine the status of the site. Optionally, you can specify this using an environment variable with the syntax `${VARIABLE_NAME}`. The public facing URL of a monitored service, the user will be redirected here. If `check-url` is not specified, this is used as the status check.
`check-url`
The URL which will be requested and its response will determine the status of the site. If not specified, the `url` property is used.
`icon` `icon`
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+4 -4
View File
@@ -1,19 +1,19 @@
module github.com/glanceapp/glance module github.com/glanceapp/glance
go 1.22.3 go 1.22.5
require ( require (
github.com/mmcdole/gofeed v1.3.0 github.com/mmcdole/gofeed v1.3.0
golang.org/x/text v0.14.0 golang.org/x/text v0.16.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/PuerkitoBio/goquery v1.9.1 // indirect github.com/PuerkitoBio/goquery v1.9.2 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/mmcdole/goxpp v1.1.1 // indirect github.com/mmcdole/goxpp v1.1.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
golang.org/x/net v0.24.0 // indirect golang.org/x/net v0.27.0 // indirect
) )
+6 -6
View File
@@ -1,5 +1,5 @@
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI= github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -33,8 +33,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -54,8 +54,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+41
View File
@@ -1,8 +1,14 @@
package assets package assets
import ( import (
"crypto/md5"
"embed" "embed"
"encoding/hex"
"io"
"io/fs" "io/fs"
"log/slog"
"strconv"
"time"
) )
//go:embed static //go:embed static
@@ -13,3 +19,38 @@ var _templateFS embed.FS
var PublicFS, _ = fs.Sub(_publicFS, "static") var PublicFS, _ = fs.Sub(_publicFS, "static")
var TemplateFS, _ = fs.Sub(_templateFS, "templates") var TemplateFS, _ = fs.Sub(_templateFS, "templates")
func getFSHash(files fs.FS) string {
hash := md5.New()
err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
file, err := files.Open(path)
if err != nil {
return err
}
if _, err := io.Copy(hash, file); err != nil {
return err
}
return nil
})
if err == nil {
return hex.EncodeToString(hash.Sum(nil))[:10]
}
slog.Warn("Could not compute assets cache", "err", err)
return strconv.FormatInt(time.Now().Unix(), 10)
}
var PublicFSHash = getFSHash(PublicFS)
+87 -7
View File
@@ -58,6 +58,11 @@
font-size: var(--font-size-h4); font-size: var(--font-size-h4);
} }
.page {
height: 100%;
padding-block: var(--widget-gap);
}
.page-content, .page.content-ready .page-loading-container { .page-content, .page.content-ready .page-loading-container {
display: none; display: none;
} }
@@ -184,6 +189,57 @@
transform: rotate(-90deg); transform: rotate(-90deg);
} }
.widget-group-header {
overflow-x: auto;
scrollbar-width: thin;
}
.widget-group-title {
background: none;
font: inherit;
border: none;
color: inherit;
text-transform: uppercase;
border-bottom: 1px solid transparent;
cursor: pointer;
flex-shrink: 0;
padding-bottom: 0.1rem;
transition: color .3s, border-color .3s;
}
.widget-group-title:hover:not(.widget-group-title-current) {
border-bottom-color: var(--color-text-subdue);
color: var(--color-text-highlight);
}
.widget-group-title-current {
border-bottom-color: var(--color-primary);
color: var(--color-text-highlight);
}
.widget-group-content {
animation: widgetGroupContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
.widget-group-content[data-direction="right"] {
--direction: 5px;
}
.widget-group-content[data-direction="left"] {
--direction: -5px;
}
@keyframes widgetGroupContentEntrance {
from {
opacity: 0;
transform: translateX(var(--direction));
}
}
.widget-group-content:not(.widget-group-content-current) {
display: none;
}
.widget-content:has(.expand-toggle-button:last-child) { .widget-content:has(.expand-toggle-button:last-child) {
padding-bottom: 0; padding-bottom: 0;
} }
@@ -260,9 +316,14 @@ html {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
html, body {
height: 100%;
}
a { a {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
overflow-wrap: break-word;
} }
ul { ul {
@@ -292,7 +353,6 @@ body {
.page-columns { .page-columns {
display: flex; display: flex;
gap: var(--widget-gap); gap: var(--widget-gap);
margin: var(--widget-gap) 0;
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards; animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
} }
@@ -304,13 +364,19 @@ body {
} }
.page-loading-container { .page-loading-container {
margin: 50px auto; height: 100%;
width: fit-content; display: flex;
align-items: center;
justify-content: center;
animation: loadingContainerEntrance 200ms backwards; animation: loadingContainerEntrance 200ms backwards;
animation-delay: 150ms; animation-delay: 150ms;
font-size: 2rem; font-size: 2rem;
} }
.page-loading-container > .loading-icon {
translate: 0 -250%;
}
@keyframes loadingContainerEntrance { @keyframes loadingContainerEntrance {
from { from {
opacity: 0; opacity: 0;
@@ -372,10 +438,19 @@ kbd:active {
.content-bounds { .content-bounds {
max-width: 1600px; max-width: 1600px;
width: 100%;
margin-inline: auto; margin-inline: auto;
padding: 0 var(--content-bounds-padding); padding: 0 var(--content-bounds-padding);
} }
.page-width-wide .content-bounds {
max-width: 1920px;
}
.page-width-slim .content-bounds {
max-width: 1100px;
}
.dynamic-columns { .dynamic-columns {
gap: calc(var(--widget-content-vertical-padding) / 2); gap: calc(var(--widget-content-vertical-padding) / 2);
display: grid; display: grid;
@@ -594,7 +669,8 @@ kbd:active {
} }
.footer { .footer {
margin-block: calc(var(--widget-gap) * 1.5); padding-bottom: calc(var(--widget-gap) * 1.5);
padding-top: calc(var(--widget-gap) / 2);
animation: loadingContainerEntrance 200ms backwards; animation: loadingContainerEntrance 200ms backwards;
animation-delay: 150ms; animation-delay: 150ms;
} }
@@ -1167,8 +1243,9 @@ kbd:active {
} }
} }
body { .mobile-navigation-offset {
padding-bottom: calc(var(--mobile-navigation-height) + var(--content-bounds-padding)); height: var(--mobile-navigation-height);
flex-shrink: 0;
} }
.mobile-navigation { .mobile-navigation {
@@ -1201,7 +1278,7 @@ kbd:active {
padding: 15px var(--content-bounds-padding); padding: 15px var(--content-bounds-padding);
display: flex; display: flex;
align-items: center; align-items: center;
overflow-x: scroll; overflow-x: auto;
gap: 2.5rem; gap: 2.5rem;
} }
@@ -1371,6 +1448,7 @@ kbd:active {
.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%; } .max-width-100 { max-width: 100%; }
.height-100 { height: 100%; }
.block { display: block; } .block { display: block; }
.inline-block { display: inline-block; } .inline-block { display: inline-block; }
.overflow-hidden { overflow: hidden; } .overflow-hidden { overflow: hidden; }
@@ -1392,6 +1470,7 @@ kbd:active {
.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-20 { gap: 2rem; }
.gap-25 { gap: 2.5rem; } .gap-25 { gap: 2.5rem; }
.gap-35 { gap: 3.5rem; } .gap-35 { gap: 3.5rem; }
.gap-45 { gap: 4.5rem; } .gap-45 { gap: 4.5rem; }
@@ -1401,6 +1480,7 @@ kbd:active {
.margin-top-7 { margin-top: 0.7rem; } .margin-top-7 { margin-top: 0.7rem; }
.margin-top-10 { margin-top: 1rem; } .margin-top-10 { margin-top: 1rem; }
.margin-top-15 { margin-top: 1.5rem; } .margin-top-15 { margin-top: 1.5rem; }
.margin-top-auto { margin-top: auto; }
.margin-block-3 { margin-block: 0.3rem; } .margin-block-3 { margin-block: 0.3rem; }
.margin-block-5 { margin-block: 0.5rem; } .margin-block-5 { margin-block: 0.5rem; }
.margin-block-7 { margin-block: 0.7rem; } .margin-block-7 { margin-block: 0.7rem; }
+56 -15
View File
@@ -21,10 +21,10 @@ function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
}; };
async function fetchPageContent(pageSlug) { async function fetchPageContent(pageData) {
// 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(`${pageData.baseURL}/api/pages/${pageData.slug}/content/`);
const content = await response.text(); const content = await response.text();
return content; return content;
@@ -107,7 +107,7 @@ function updateRelativeTimeForElements(elements)
} }
} }
function setupSearchboxes() { function setupSearchBoxes() {
const searchWidgets = document.getElementsByClassName("search"); const searchWidgets = document.getElementsByClassName("search");
if (searchWidgets.length == 0) { if (searchWidgets.length == 0) {
@@ -117,6 +117,7 @@ function setupSearchboxes() {
for (let i = 0; i < searchWidgets.length; i++) { for (let i = 0; i < searchWidgets.length; i++) {
const widget = searchWidgets[i]; const widget = searchWidgets[i];
const defaultSearchUrl = widget.dataset.defaultSearchUrl; const defaultSearchUrl = widget.dataset.defaultSearchUrl;
const newTab = widget.dataset.newTab === "true";
const inputElement = widget.getElementsByClassName("search-input")[0]; const inputElement = widget.getElementsByClassName("search-input")[0];
const bangElement = widget.getElementsByClassName("search-bang")[0]; const bangElement = widget.getElementsByClassName("search-bang")[0];
const bangs = widget.querySelectorAll(".search-bangs > input"); const bangs = widget.querySelectorAll(".search-bangs > input");
@@ -147,14 +148,13 @@ function setupSearchboxes() {
query = input; query = input;
searchUrlTemplate = defaultSearchUrl; searchUrlTemplate = defaultSearchUrl;
} }
if (query.length == 0 && currentBang == null) {
if (query.length == 0) {
return; return;
} }
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query)); const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
if (event.ctrlKey) { if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
window.open(url, '_blank').focus(); window.open(url, '_blank').focus();
} else { } else {
window.location.href = url; window.location.href = url;
@@ -170,9 +170,13 @@ function setupSearchboxes() {
} }
const handleInput = (event) => { const handleInput = (event) => {
const value = event.target.value.trimStart(); const value = event.target.value.trim();
const words = value.split(" "); if (value in bangsMap) {
changeCurrentBang(bangsMap[value]);
return;
}
const words = value.split(" ");
if (words.length >= 2 && words[0] in bangsMap) { if (words.length >= 2 && words[0] in bangsMap) {
changeCurrentBang(bangsMap[words[0]]); changeCurrentBang(bangsMap[words[0]]);
return; return;
@@ -246,6 +250,46 @@ function setupDynamicRelativeTime() {
}); });
} }
function setupGroups() {
const groups = document.getElementsByClassName("widget-type-group");
if (groups.length == 0) {
return;
}
for (let g = 0; g < groups.length; g++) {
const group = groups[g];
const titles = group.getElementsByClassName("widget-header")[0].children;
const tabs = group.getElementsByClassName("widget-group-contents")[0].children;
let current = 0;
for (let t = 0; t < titles.length; t++) {
const title = titles[t];
title.addEventListener("click", () => {
if (t == current) {
return;
}
for (let i = 0; i < titles.length; i++) {
titles[i].classList.remove("widget-group-title-current");
tabs[i].classList.remove("widget-group-content-current");
}
if (current < t) {
tabs[t].dataset.direction = "right";
} else {
tabs[t].dataset.direction = "left";
}
current = t;
title.classList.add("widget-group-title-current");
tabs[t].classList.add("widget-group-content-current");
});
}
}
}
function setupLazyImages() { function setupLazyImages() {
const images = document.querySelectorAll("img[loading=lazy]"); const images = document.querySelectorAll("img[loading=lazy]");
@@ -544,16 +588,17 @@ function setupClocks() {
async function setupPage() { async function setupPage() {
const pageElement = document.getElementById("page"); const pageElement = document.getElementById("page");
const pageContentElement = document.getElementById("page-content"); const pageContentElement = document.getElementById("page-content");
const pageContent = await fetchPageContent(pageData.slug); const pageContent = await fetchPageContent(pageData);
pageContentElement.innerHTML = pageContent; pageContentElement.innerHTML = pageContent;
try { try {
setupClocks() setupClocks()
setupCarousels(); setupCarousels();
setupSearchboxes(); setupSearchBoxes();
setupCollapsibleLists(); setupCollapsibleLists();
setupCollapsibleGrids(); setupCollapsibleGrids();
setupGroups();
setupDynamicRelativeTime(); setupDynamicRelativeTime();
setupLazyImages(); setupLazyImages();
} finally { } finally {
@@ -569,8 +614,4 @@ async function setupPage() {
} }
} }
if (document.readyState === "loading") { setupPage();
document.addEventListener("DOMContentLoaded", setupPage);
} else {
setupPage();
}
+1 -1
View File
@@ -6,7 +6,7 @@
"start_url": "/", "start_url": "/",
"icons": [ "icons": [
{ {
"src": "/static/app-icon.png", "src": "app-icon.png",
"type": "image/png", "type": "image/png",
"sizes": "512x512" "sizes": "512x512"
} }
+1
View File
@@ -37,6 +37,7 @@ var (
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html") RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
SearchTemplate = compileTemplate("search.html", "widget-base.html") SearchTemplate = compileTemplate("search.html", "widget-base.html")
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html") ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
GroupTemplate = compileTemplate("group.html", "widget-base.html")
) )
var globalTemplateFunctions = template.FuncMap{ var globalTemplateFunctions = template.FuncMap{
+6 -6
View File
@@ -11,12 +11,12 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Glance"> <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 }}"> <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="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}">
<link rel="icon" type="image/png" sizes="50x50" href="/static/favicon.png"> <link rel="icon" type="image/png" sizes="50x50" href="{{ .App.AssetPath "favicon.png" }}">
<link rel="manifest" href="/static/manifest.json"> <link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
<link rel="icon" type="image/png" href="/static/favicon.png" /> <link rel="icon" type="image/png" href="{{ .App.AssetPath "favicon.png" }}" />
<link rel="stylesheet" href="/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}"> <link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
<script async src="/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script> <script type="module" src="{{ .App.AssetPath "main.js" }}"></script>
{{ block "document-head-after" . }}{{ end }} {{ block "document-head-after" . }}{{ end }}
</head> </head>
<body> <body>
+20
View File
@@ -0,0 +1,20 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="widget-group-header">
<div class="widget-header gap-20">
{{ range $i, $widget := .Widgets }}
<button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}">{{ $widget.Title }}</button>
{{ end }}
</div>
</div>
<div class="widget-group-contents">
{{ range $i, $widget := .Widgets }}
<div class="widget-group-content{{ if eq $i 0 }} widget-group-content-current{{ end }}">{{ .Render }}</div>
{{ end }}
</div>
{{ end }}
+40 -33
View File
@@ -1,16 +1,18 @@
{{ template "document.html" . }} {{ template "document.html" . }}
{{ define "document-title" }}{{ .Page.Title }} - Glance{{ end }} {{ define "document-title" }}{{ .Page.Title }}{{ end }}
{{ define "document-head-before" }} {{ define "document-head-before" }}
<script> <script>
const pageData = { const pageData = {
slug: "{{ .Page.Slug }}", slug: "{{ .Page.Slug }}",
baseURL: "{{ .App.Config.Server.BaseURL }}",
}; };
</script> </script>
{{ end }} {{ end }}
{{ define "document-root-attrs" }}{{ if .App.Config.Theme.Light }}class="light-scheme"{{ end }}{{ end }} {{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }}{{ end }}"{{ end }}
{{ define "document-head-after" }} {{ define "document-head-after" }}
{{ template "page-style-overrides.gotmpl" . }} {{ template "page-style-overrides.gotmpl" . }}
{{ if ne "" .App.Config.Theme.CustomCSSFile }} {{ if ne "" .App.Config.Theme.CustomCSSFile }}
@@ -20,48 +22,53 @@
{{ define "navigation-links" }} {{ define "navigation-links" }}
{{ range .App.Config.Pages }} {{ range .App.Config.Pages }}
<a href="/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a> <a href="{{ $.App.Config.Server.BaseURL }}/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a>
{{ end }} {{ end }}
{{ end }} {{ end }}
{{ define "document-body" }} {{ define "document-body" }}
<div class="header-container content-bounds"> <div class="flex flex-column height-100">
<div class="header flex padding-inline-widget widget-content-frame"> {{ if not .Page.HideDesktopNavigation }}
<!-- TODO: Replace G with actual logo, first need an actual logo --> <div class="header-container content-bounds">
<div class="logo">G</div> <div class="header flex padding-inline-widget widget-content-frame">
<div class="nav flex grow"> <!-- TODO: Replace G with actual logo, first need an actual logo -->
<div class="logo">G</div>
<div class="nav flex grow">
{{ template "navigation-links" . }}
</div>
</div>
</div>
{{ end }}
<div class="mobile-navigation">
<div class="mobile-navigation-icons">
<a class="mobile-navigation-label" href="#top"></a>
{{ range $i, $column := .Page.Columns }}
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
{{ end }}
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
</div>
<div class="mobile-navigation-page-links">
{{ template "navigation-links" . }} {{ template "navigation-links" . }}
</div> </div>
</div> </div>
</div>
<div class="mobile-navigation"> <div class="content-bounds grow">
<div class="mobile-navigation-icons"> <div class="page" id="page">
<a class="mobile-navigation-label" href="#top"></a> <div class="page-content" id="page-content"></div>
{{ range $i, $column := .Page.Columns }} <div class="page-loading-container">
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label> <!-- TODO: add a bigger/better loading indicator -->
{{ end }} <div class="loading-icon"></div>
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label> </div>
</div>
<div class="mobile-navigation-page-links">
{{ template "navigation-links" . }}
</div>
</div>
<div class="content-bounds">
<div class="page" id="page">
<div class="page-content" id="page-content"></div>
<div class="page-loading-container">
<!-- TODO: add a bigger/better loading indicator -->
<div class="loading-icon"></div>
</div> </div>
</div> </div>
</div>
<div class="footer flex items-center flex-column"> <div class="footer flex items-center flex-column">
<div> <div>
<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 }} <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> </div>
<a class="color-primary block margin-top-5 size-h5" href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a>
<div class="mobile-navigation-offset"></div>
</div> </div>
{{ end }} {{ end }}
+2 -2
View File
@@ -3,7 +3,7 @@
{{ define "widget-content-classes" }}widget-content-frameless{{ end }} {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }} {{ 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 widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}" data-new-tab="{{ .NewTab }}">
<div class="search-bangs"> <div class="search-bangs">
{{ range .Bangs }} {{ range .Bangs }}
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}"> <input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
@@ -16,7 +16,7 @@
</svg> </svg>
</div> </div>
<input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off"> <input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off"{{ if .Autofocus }} autofocus{{ end }}>
<div class="search-bang"></div> <div class="search-bang"></div>
<kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd> <kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
@@ -7,7 +7,9 @@
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent"> <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 .Exists }} {{ if .Exists }}
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy"> <a href="https://twitch.tv/{{ .Login }}" class="twitch-channel-avatar-link" target="_blank" rel="noreferrer">
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
</a>
{{ 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" />
@@ -18,7 +20,9 @@
<a href="https://twitch.tv/{{ .Login }}" class="size-h3{{ if .IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ .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 .Exists }} {{ if .Exists }}
{{ if .IsLive }} {{ if .IsLive }}
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a> {{ if .Category }}
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a>
{{ end }}
<ul class="list-horizontal-text"> <ul class="list-horizontal-text">
<li {{ dynamicRelativeTimeAttrs .LiveSince }}></li> <li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
<li>{{ .ViewersCount | formatViewerCount }} viewers</li> <li>{{ .ViewersCount | formatViewerCount }} viewers</li>
+5 -3
View File
@@ -1,13 +1,15 @@
<div class="widget widget-type-{{ .GetType }}"> <div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
{{ if not .HideHeader}}
<div class="widget-header"> <div class="widget-header">
<div class="uppercase">{{ .Title }}</div> {{ if ne "" .TitleURL}}<a href="{{ .TitleURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>{{ else }}<div class="uppercase">{{ .Title }}</div>{{ end }}
{{ if and .Error .ContentAvailable }} {{ if and .Error .ContentAvailable }}
<div class="notice-icon notice-icon-major" title="{{ .Error }}"></div> <div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
{{ else if .Notice }} {{ else if .Notice }}
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div> <div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
{{ end }} {{ end }}
</div> </div>
<div class="widget-content {{ if .ContentAvailable }}{{ block "widget-content-classes" . }}{{ end }}{{ end }}"> {{ end }}
<div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}">
{{ if .ContentAvailable }} {{ if .ContentAvailable }}
{{ block "widget-content" . }}{{ end }} {{ block "widget-content" . }}{{ end }}
{{ else }} {{ else }}
+21 -11
View File
@@ -55,20 +55,30 @@ func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) {
return posts, nil return posts, nil
} }
func FetchLobstersPosts(sortBy string, tags []string) (ForumPosts, error) { func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) {
var feedUrl string var feedUrl string
if sortBy == "hot" { if customURL != "" {
sortBy = "hottest" feedUrl = customURL
} else if sortBy == "new" {
sortBy = "newest"
}
if len(tags) == 0 {
feedUrl = "https://lobste.rs/" + sortBy + ".json"
} else { } else {
tags := strings.Join(tags, ",") if instanceURL != "" {
feedUrl = "https://lobste.rs/t/" + tags + ".json" instanceURL = strings.TrimRight(instanceURL, "/") + "/"
} else {
instanceURL = "https://lobste.rs/"
}
if sortBy == "hot" {
sortBy = "hottest"
} else if sortBy == "new" {
sortBy = "newest"
}
if len(tags) == 0 {
feedUrl = instanceURL + sortBy + ".json"
} else {
tags := strings.Join(tags, ",")
feedUrl = instanceURL + "t/" + tags + ".json"
}
} }
posts, err := getLobstersPostsFromFeed(feedUrl) posts, err := getLobstersPostsFromFeed(feedUrl)
+8 -1
View File
@@ -9,6 +9,7 @@ import (
type SiteStatusRequest struct { type SiteStatusRequest struct {
URL string `yaml:"url"` URL string `yaml:"url"`
CheckURL string `yaml:"check-url"`
AllowInsecure bool `yaml:"allow-insecure"` AllowInsecure bool `yaml:"allow-insecure"`
} }
@@ -20,7 +21,13 @@ type SiteStatus struct {
} }
func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) { func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
request, err := http.NewRequest(http.MethodGet, statusRequest.URL, nil) var url string
if statusRequest.CheckURL != "" {
url = statusRequest.CheckURL
} else {
url = statusRequest.URL
}
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil { if err != nil {
return SiteStatus{ return SiteStatus{
+37 -25
View File
@@ -44,12 +44,25 @@ func sanitizeFeedDescription(description string) string {
return description return description
} }
func shortenFeedDescriptionLen(description string, maxLen int) string {
description, _ = limitStringLength(description, 1000)
description = sanitizeFeedDescription(description)
description, limited := limitStringLength(description, maxLen)
if limited {
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"` HideCategories bool `yaml:"hide-categories"`
HideDescription bool `yaml:"hide-description"` HideDescription bool `yaml:"hide-description"`
ItemLinkPrefix string `yaml:"item-link-prefix"` ItemLinkPrefix string `yaml:"item-link-prefix"`
IsDetailed bool `yaml:"-"`
} }
type RSSFeedItems []RSSFeedItem type RSSFeedItems []RSSFeedItem
@@ -81,7 +94,6 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
rssItem := RSSFeedItem{ rssItem := RSSFeedItem{
ChannelURL: feed.Link, ChannelURL: feed.Link,
Title: item.Title,
} }
if request.ItemLinkPrefix != "" { if request.ItemLinkPrefix != "" {
@@ -108,34 +120,34 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
} }
} }
if !request.HideDescription && item.Description != "" { if item.Title != "" {
description, _ := limitStringLength(item.Description, 1000) rssItem.Title = item.Title
description = sanitizeFeedDescription(description) } else {
description, limited := limitStringLength(description, 200) rssItem.Title = shortenFeedDescriptionLen(item.Description, 100)
if limited {
description += "…"
}
rssItem.Description = description
} }
if !request.HideCategories { if request.IsDetailed {
var categories = make([]string, 0, 6) if !request.HideDescription && item.Description != "" && item.Title != "" {
rssItem.Description = shortenFeedDescriptionLen(item.Description, 200)
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.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 != "" {
+5 -3
View File
@@ -204,9 +204,11 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
result.IsLive = true result.IsLive = true
result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil && streamMetadata.UserOrNull.Stream.Game != nil { if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil {
result.Category = streamMetadata.UserOrNull.Stream.Game.Name if streamMetadata.UserOrNull.Stream.Game != nil {
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug result.Category = streamMetadata.UserOrNull.Stream.Game.Name
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
}
startedAt, err := time.Parse("2006-01-02T15:04:05Z", streamMetadata.UserOrNull.Stream.StartedAt) startedAt, err := time.Parse("2006-01-02T15:04:05Z", streamMetadata.UserOrNull.Stream.StartedAt)
if err == nil { if err == nil {
+11 -9
View File
@@ -10,7 +10,7 @@ import (
) )
type youtubeFeedResponseXml struct { type youtubeFeedResponseXml struct {
Channel string `xml:"title"` Channel string `xml:"author>name"`
ChannelLink struct { ChannelLink struct {
Href string `xml:"href,attr"` Href string `xml:"href,attr"`
} `xml:"link"` } `xml:"link"`
@@ -39,11 +39,19 @@ func parseYoutubeFeedTime(t string) time.Time {
return parsedTime return parsedTime
} }
func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (Videos, error) { func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, includeShorts bool) (Videos, error) {
requests := make([]*http.Request, 0, len(channelIds)) requests := make([]*http.Request, 0, len(channelIds))
for i := range channelIds { for i := range channelIds {
request, _ := http.NewRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id="+channelIds[i], nil) var feedUrl string
if !includeShorts && strings.HasPrefix(channelIds[i], "UC") {
playlistId := strings.Replace(channelIds[i], "UC", "UULF", 1)
feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId
} else {
feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelIds[i]
}
request, _ := http.NewRequest("GET", feedUrl, nil)
requests = append(requests, request) requests = append(requests, request)
} }
@@ -70,12 +78,6 @@ func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (V
for j := range response.Videos { for j := range response.Videos {
video := &response.Videos[j] video := &response.Videos[j]
// TODO: figure out a better way of skipping shorts
if strings.Contains(video.Title, "#shorts") {
continue
}
var videoUrl string var videoUrl string
if videoUrlTemplate == "" { if videoUrlTemplate == "" {
+22 -2
View File
@@ -32,6 +32,16 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
return nil, err return nil, err
} }
for p := range config.Pages {
for c := range config.Pages[p].Columns {
for w := range config.Pages[p].Columns[c].Widgets {
if err := config.Pages[p].Columns[c].Widgets[w].Initialize(); err != nil {
return nil, err
}
}
}
}
return config, nil return config, nil
} }
@@ -50,12 +60,22 @@ func configIsValid(config *Config) error {
return fmt.Errorf("Page %d has no title", i+1) return fmt.Errorf("Page %d has no title", i+1)
} }
if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") {
return fmt.Errorf("Page %d: width can only be either wide or slim", i+1)
}
if len(config.Pages[i].Columns) == 0 { if len(config.Pages[i].Columns) == 0 {
return fmt.Errorf("Page %d has no columns", i+1) return fmt.Errorf("Page %d has no columns", i+1)
} }
if len(config.Pages[i].Columns) > 3 { if config.Pages[i].Width == "slim" {
return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns)) if len(config.Pages[i].Columns) > 2 {
return fmt.Errorf("Page %d is slim and cannot have more than 2 columns", i+1)
}
} else {
if len(config.Pages[i].Columns) > 3 {
return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns))
}
} }
columnSizesCount := make(map[string]int) columnSizesCount := make(map[string]int)
+68 -12
View File
@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -24,6 +25,7 @@ type Application struct {
Version string Version string
Config Config Config Config
slugToPage map[string]*Page slugToPage map[string]*Page
widgetByID map[uint64]widget.Widget
} }
type Theme struct { type Theme struct {
@@ -41,7 +43,9 @@ type Server struct {
Host string `yaml:"host"` Host string `yaml:"host"`
Port uint16 `yaml:"port"` Port uint16 `yaml:"port"`
AssetsPath string `yaml:"assets-path"` AssetsPath string `yaml:"assets-path"`
StartedAt time.Time `yaml:"-"` BaseURL string `yaml:"base-url"`
AssetsHash string `yaml:"-"`
StartedAt time.Time `yaml:"-"` // used in custom css file
} }
type Column struct { type Column struct {
@@ -55,11 +59,13 @@ type templateData struct {
} }
type Page struct { type Page struct {
Title string `yaml:"name"` Title string `yaml:"name"`
Slug string `yaml:"slug"` Slug string `yaml:"slug"`
ShowMobileHeader bool `yaml:"show-mobile-header"` Width string `yaml:"width"`
Columns []Column `yaml:"columns"` ShowMobileHeader bool `yaml:"show-mobile-header"`
mu sync.Mutex HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
Columns []Column `yaml:"columns"`
mu sync.Mutex
} }
func (p *Page) UpdateOutdatedWidgets() { func (p *Page) UpdateOutdatedWidgets() {
@@ -105,16 +111,34 @@ func NewApplication(config *Config) (*Application, error) {
Version: buildVersion, Version: buildVersion,
Config: *config, Config: *config,
slugToPage: make(map[string]*Page), slugToPage: make(map[string]*Page),
widgetByID: make(map[uint64]widget.Widget),
} }
app.slugToPage[""] = &config.Pages[0] app.slugToPage[""] = &config.Pages[0]
for i := range config.Pages { for p := range config.Pages {
if config.Pages[i].Slug == "" { if config.Pages[p].Slug == "" {
config.Pages[i].Slug = titleToSlug(config.Pages[i].Title) config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
} }
app.slugToPage[config.Pages[i].Slug] = &config.Pages[i] app.slugToPage[config.Pages[p].Slug] = &config.Pages[p]
for c := range config.Pages[p].Columns {
for w := range config.Pages[p].Columns[c].Widgets {
widget := config.Pages[p].Columns[c].Widgets[w]
app.widgetByID[widget.GetID()] = widget
}
}
}
config = &app.Config
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
if config.Server.BaseURL != "" &&
config.Theme.CustomCSSFile != "" &&
strings.HasPrefix(config.Theme.CustomCSSFile, "/assets/") {
config.Theme.CustomCSSFile = config.Server.BaseURL + config.Theme.CustomCSSFile
} }
return app, nil return app, nil
@@ -189,15 +213,47 @@ func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.H
}) })
} }
func (a *Application) HandleWidgetRequest(w http.ResponseWriter, r *http.Request) {
widgetValue := r.PathValue("widget")
widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
if err != nil {
a.HandleNotFound(w, r)
return
}
widget, exists := a.widgetByID[widgetID]
if !exists {
a.HandleNotFound(w, r)
return
}
widget.HandleRequest(w, r)
}
func (a *Application) AssetPath(asset string) string {
return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset
}
func (a *Application) Serve() error { func (a *Application) Serve() error {
a.Config.Server.AssetsHash = assets.PublicFSHash
// TODO: add gzip support, static files must have their gzipped contents cached // TODO: add gzip support, static files must have their gzipped contents cached
// TODO: add HTTPS support // TODO: add HTTPS support
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", a.HandlePageRequest) mux.HandleFunc("GET /{$}", a.HandlePageRequest)
mux.HandleFunc("GET /{page}", a.HandlePageRequest) mux.HandleFunc("GET /{page}", a.HandlePageRequest)
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest) mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
mux.Handle("GET /static/{path...}", http.StripPrefix("/static/", FileServerWithCache(http.FS(assets.PublicFS), 2*time.Hour))) mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest)
mux.Handle(
fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 8*time.Hour)),
)
if a.Config.Server.AssetsPath != "" { if a.Config.Server.AssetsPath != "" {
absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath) absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
@@ -217,7 +273,7 @@ func (a *Application) Serve() error {
} }
a.Config.Server.StartedAt = time.Now() a.Config.Server.StartedAt = time.Now()
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL)
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port)
return server.ListenAndServe() return server.ListenAndServe()
} }
+70
View File
@@ -0,0 +1,70 @@
package widget
import (
"context"
"errors"
"html/template"
"sync"
"time"
"github.com/glanceapp/glance/internal/assets"
)
type Group struct {
widgetBase `yaml:",inline"`
Widgets Widgets `yaml:"widgets"`
}
func (widget *Group) Initialize() error {
widget.withError(nil)
widget.HideHeader = true
for i := range widget.Widgets {
widget.Widgets[i].SetHideHeader(true)
if widget.Widgets[i].GetType() == "group" {
return errors.New("nested groups are not allowed")
}
if err := widget.Widgets[i].Initialize(); err != nil {
return err
}
}
return nil
}
func (widget *Group) Update(ctx context.Context) {
var wg sync.WaitGroup
now := time.Now()
for w := range widget.Widgets {
widget := widget.Widgets[w]
if !widget.RequiresUpdate(&now) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
widget.Update(ctx)
}()
}
wg.Wait()
}
func (widget *Group) RequiresUpdate(now *time.Time) bool {
for i := range widget.Widgets {
if widget.Widgets[i].RequiresUpdate(now) {
return true
}
}
return false
}
func (widget *Group) Render() template.HTML {
return widget.render(widget, assets.GroupTemplate)
}
+4 -1
View File
@@ -21,7 +21,10 @@ type HackerNews struct {
} }
func (widget *HackerNews) Initialize() error { func (widget *HackerNews) Initialize() error {
widget.withTitle("Hacker News").withCacheDuration(30 * time.Minute) widget.
withTitle("Hacker News").
withTitleURL("https://news.ycombinator.com/").
withCacheDuration(30 * time.Minute)
if widget.Limit <= 0 { if widget.Limit <= 0 {
widget.Limit = 15 widget.Limit = 15
+10 -2
View File
@@ -12,6 +12,8 @@ import (
type Lobsters struct { type Lobsters struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
Posts feed.ForumPosts `yaml:"-"` Posts feed.ForumPosts `yaml:"-"`
InstanceURL string `yaml:"instance-url"`
CustomURL string `yaml:"custom-url"`
Limit int `yaml:"limit"` Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"` CollapseAfter int `yaml:"collapse-after"`
SortBy string `yaml:"sort-by"` SortBy string `yaml:"sort-by"`
@@ -20,7 +22,13 @@ type Lobsters struct {
} }
func (widget *Lobsters) Initialize() error { func (widget *Lobsters) Initialize() error {
widget.withTitle("Lobsters").withCacheDuration(30 * time.Minute) widget.withTitle("Lobsters").withCacheDuration(time.Hour)
if widget.InstanceURL == "" {
widget.withTitleURL("https://lobste.rs")
} else {
widget.withTitleURL(widget.InstanceURL)
}
if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") { if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") {
widget.SortBy = "hot" widget.SortBy = "hot"
@@ -38,7 +46,7 @@ func (widget *Lobsters) Initialize() error {
} }
func (widget *Lobsters) Update(ctx context.Context) { func (widget *Lobsters) Update(ctx context.Context) {
posts, err := feed.FetchLobstersPosts(widget.SortBy, widget.Tags) posts, err := feed.FetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags)
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
return return
+4 -1
View File
@@ -54,7 +54,10 @@ func (widget *Reddit) Initialize() error {
} }
} }
widget.withTitle("/r/" + widget.Subreddit).withCacheDuration(30 * time.Minute) widget.
withTitle("/r/" + widget.Subreddit).
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
withCacheDuration(30 * time.Minute)
return nil return nil
} }
+2 -3
View File
@@ -39,10 +39,9 @@ func (widget *RSS) Initialize() error {
widget.CardHeight = 0 widget.CardHeight = 0
} }
if widget.Style != "detailed-list" { if widget.Style == "detailed-list" {
for i := range widget.FeedRequests { for i := range widget.FeedRequests {
widget.FeedRequests[i].HideCategories = true widget.FeedRequests[i].IsDetailed = true
widget.FeedRequests[i].HideDescription = true
} }
} }
+2
View File
@@ -19,6 +19,8 @@ type Search struct {
cachedHTML template.HTML `yaml:"-"` cachedHTML template.HTML `yaml:"-"`
SearchEngine string `yaml:"search-engine"` SearchEngine string `yaml:"search-engine"`
Bangs []SearchBang `yaml:"bangs"` Bangs []SearchBang `yaml:"bangs"`
NewTab bool `yaml:"new-tab"`
Autofocus bool `yaml:"autofocus"`
} }
func convertSearchUrl(url string) string { func convertSearchUrl(url string) string {
+4 -1
View File
@@ -18,7 +18,10 @@ type TwitchChannels struct {
} }
func (widget *TwitchChannels) Initialize() error { func (widget *TwitchChannels) Initialize() error {
widget.withTitle("Twitch Channels").withCacheDuration(time.Minute * 10) widget.
withTitle("Twitch Channels").
withTitleURL("https://www.twitch.tv/directory/following").
withCacheDuration(time.Minute * 10)
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 5 widget.CollapseAfter = 5
+4 -1
View File
@@ -18,7 +18,10 @@ type TwitchGames struct {
} }
func (widget *TwitchGames) Initialize() error { func (widget *TwitchGames) Initialize() error {
widget.withTitle("Top games on Twitch").withCacheDuration(time.Minute * 10) widget.
withTitle("Top games on Twitch").
withTitleURL("https://www.twitch.tv/directory?sort=VIEWER_COUNT").
withCacheDuration(time.Minute * 10)
if widget.Limit <= 0 { if widget.Limit <= 0 {
widget.Limit = 10 widget.Limit = 10
+2 -1
View File
@@ -17,6 +17,7 @@ type Videos struct {
CollapseAfterRows int `yaml:"collapse-after-rows"` CollapseAfterRows int `yaml:"collapse-after-rows"`
Channels []string `yaml:"channels"` Channels []string `yaml:"channels"`
Limit int `yaml:"limit"` Limit int `yaml:"limit"`
IncludeShorts bool `yaml:"include-shorts"`
} }
func (widget *Videos) Initialize() error { func (widget *Videos) Initialize() error {
@@ -34,7 +35,7 @@ func (widget *Videos) Initialize() error {
} }
func (widget *Videos) Update(ctx context.Context) { func (widget *Videos) Update(ctx context.Context) {
videos, err := feed.FetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate) videos, err := feed.FetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate, widget.IncludeShorts)
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
return return
+64 -24
View File
@@ -8,6 +8,8 @@ import (
"html/template" "html/template"
"log/slog" "log/slog"
"math" "math"
"net/http"
"sync/atomic"
"time" "time"
"github.com/glanceapp/glance/internal/feed" "github.com/glanceapp/glance/internal/feed"
@@ -15,51 +17,61 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
var uniqueID atomic.Uint64
func New(widgetType string) (Widget, error) { func New(widgetType string) (Widget, error) {
var widget Widget
switch widgetType { switch widgetType {
case "calendar": case "calendar":
return &Calendar{}, nil widget = &Calendar{}
case "clock": case "clock":
return &Clock{}, nil widget = &Clock{}
case "weather": case "weather":
return &Weather{}, nil widget = &Weather{}
case "bookmarks": case "bookmarks":
return &Bookmarks{}, nil widget = &Bookmarks{}
case "iframe": case "iframe":
return &IFrame{}, nil widget = &IFrame{}
case "html": case "html":
return &HTML{}, nil widget = &HTML{}
case "hacker-news": case "hacker-news":
return &HackerNews{}, nil widget = &HackerNews{}
case "releases": case "releases":
return &Releases{}, nil widget = &Releases{}
case "videos": case "videos":
return &Videos{}, nil widget = &Videos{}
case "markets", "stocks": case "markets", "stocks":
return &Markets{}, nil widget = &Markets{}
case "reddit": case "reddit":
return &Reddit{}, nil widget = &Reddit{}
case "rss": case "rss":
return &RSS{}, nil widget = &RSS{}
case "monitor": case "monitor":
return &Monitor{}, nil widget = &Monitor{}
case "twitch-top-games": case "twitch-top-games":
return &TwitchGames{}, nil widget = &TwitchGames{}
case "twitch-channels": case "twitch-channels":
return &TwitchChannels{}, nil widget = &TwitchChannels{}
case "lobsters": case "lobsters":
return &Lobsters{}, nil widget = &Lobsters{}
case "change-detection": case "change-detection":
return &ChangeDetection{}, nil widget = &ChangeDetection{}
case "repository": case "repository":
return &Repository{}, nil widget = &Repository{}
case "search": case "search":
return &Search{}, nil widget = &Search{}
case "extension": case "extension":
return &Extension{}, nil widget = &Extension{}
case "group":
widget = &Group{}
default: default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType) return nil, fmt.Errorf("unknown widget type: %s", widgetType)
} }
widget.SetID(uniqueID.Add(1))
return widget, nil
} }
type Widgets []Widget type Widgets []Widget
@@ -90,10 +102,6 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
return err return err
} }
if err = widget.Initialize(); err != nil {
return err
}
*w = append(*w, widget) *w = append(*w, widget)
} }
@@ -106,6 +114,10 @@ type Widget interface {
Update(context.Context) Update(context.Context)
Render() template.HTML Render() template.HTML
GetType() string GetType() string
GetID() uint64
SetID(uint64)
HandleRequest(w http.ResponseWriter, r *http.Request)
SetHideHeader(bool)
} }
type cacheType int type cacheType int
@@ -117,8 +129,11 @@ const (
) )
type widgetBase struct { type widgetBase struct {
ID uint64 `yaml:"-"`
Type string `yaml:"type"` Type string `yaml:"type"`
Title string `yaml:"title"` Title string `yaml:"title"`
TitleURL string `yaml:"title-url"`
CSSClass string `yaml:"css-class"`
CustomCacheDuration DurationField `yaml:"cache"` CustomCacheDuration DurationField `yaml:"cache"`
ContentAvailable bool `yaml:"-"` ContentAvailable bool `yaml:"-"`
Error error `yaml:"-"` Error error `yaml:"-"`
@@ -128,6 +143,7 @@ type widgetBase struct {
cacheType cacheType `yaml:"-"` cacheType cacheType `yaml:"-"`
nextUpdate time.Time `yaml:"-"` nextUpdate time.Time `yaml:"-"`
updateRetriedTimes int `yaml:"-"` updateRetriedTimes int `yaml:"-"`
HideHeader bool `yaml:"-"`
} }
func (w *widgetBase) RequiresUpdate(now *time.Time) bool { func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
@@ -146,6 +162,22 @@ func (w *widgetBase) Update(ctx context.Context) {
} }
func (w *widgetBase) GetID() uint64 {
return w.ID
}
func (w *widgetBase) SetID(id uint64) {
w.ID = id
}
func (w *widgetBase) SetHideHeader(value bool) {
w.HideHeader = value
}
func (widget *widgetBase) HandleRequest(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not implemented", http.StatusNotImplemented)
}
func (w *widgetBase) GetType() string { func (w *widgetBase) GetType() string {
return w.Type return w.Type
} }
@@ -185,6 +217,14 @@ func (w *widgetBase) withTitle(title string) *widgetBase {
return w return w
} }
func (w *widgetBase) withTitleURL(titleURL string) *widgetBase {
if w.TitleURL == "" {
w.TitleURL = titleURL
}
return w
}
func (w *widgetBase) withCacheDuration(duration time.Duration) *widgetBase { func (w *widgetBase) withCacheDuration(duration time.Duration) *widgetBase {
w.cacheType = cacheTypeDuration w.cacheType = cacheTypeDuration