Merge branch 'release/v0.6.0' into github-commit

This commit is contained in:
Svilen Markov 2024-08-29 17:40:14 +01:00 committed by GitHub
commit c6bfbf4ac1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
99 changed files with 3840 additions and 790 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
# https://docs.docker.com/build/building/context/#dockerignore-files
# Ignore all files by default
*
# Only add necessary files to the Docker build context (Dockerfiles are always included implicitly)
!/build/
!/internal/
!/go.mod
!/go.sum
!main.go

128
.github/CODE_OF_CONDUCT.md vendored Normal file
View File

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
glanceapp@duck.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

7
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,7 @@
<!--
If your pull request adds new features or changes existing ones please use the latest release/* branch as the base.
Documentation updates (including new themes) can be submitted to the main branch.
-->

9
.github/SECURITY.md vendored Normal file
View File

@ -0,0 +1,9 @@
# Security Policy
## Supported Versions
Security updates will be applied to the latest as well as previous minor version release depending on severity and if applicable.
## Reporting a Vulnerability
Please report any suspected security vulnerabilities to [glanceapp@duck.com](mailto:glanceapp@duck.com) and do not disclose them publicly. You should receive a response within a few days and if confirmed the issue will be resolved as soon as possible.

39
.github/workflows/release.yaml vendored Normal file
View File

@ -0,0 +1,39 @@
name: Create release
permissions:
contents: write
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout the target Git reference
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Golang
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Set up Docker buildx
uses: docker/setup-buildx-action@v3
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: release

69
.goreleaser.yaml Normal file
View File

@ -0,0 +1,69 @@
project_name: glanceapp/glance
checksum:
disable: true
builds:
- binary: glance
env:
- CGO_ENABLED=0
goos:
- linux
- openbsd
- freebsd
- windows
- darwin
goarch:
- amd64
- arm64
- arm
- 386
goarm:
- 7
ldflags:
- -s -w -X github.com/glanceapp/glance/internal/glance.buildVersion={{ .Tag }}
archives:
-
name_template: "glance-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}"
files:
- nothing*
format_overrides:
- goos: windows
format: zip
dockers:
- image_templates:
- &amd64_image "{{ .ProjectName }}:{{ .Tag }}-amd64"
build_flag_templates:
- --platform=linux/amd64
goarch: amd64
use: buildx
dockerfile: Dockerfile.goreleaser
- image_templates:
- &arm64v8_image "{{ .ProjectName }}:{{ .Tag }}-arm64"
build_flag_templates:
- --platform=linux/arm64
goarch: arm64
use: buildx
dockerfile: Dockerfile.goreleaser
- image_templates:
- &armv7_image "{{ .ProjectName }}:{{ .Tag }}-armv7"
build_flag_templates:
- --platform=linux/arm/v7
goarch: arm
goarm: 7
use: buildx
dockerfile: Dockerfile.goreleaser
docker_manifests:
- name_template: "{{ .ProjectName }}:{{ .Tag }}"
image_templates: &multiarch_images
- *amd64_image
- *arm64v8_image
- *armv7_image
- name_template: "{{ .ProjectName }}:latest"
skip_push: auto
image_templates: *multiarch_images

View File

@ -1,11 +1,13 @@
FROM alpine:3.19
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
FROM golang:1.22.5-alpine3.20 AS builder
WORKDIR /app
COPY build/glance-$TARGETOS-$TARGETARCH${TARGETVARIANT} /app/glance
COPY . /app
RUN CGO_ENABLED=0 go build .
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /app/glance .
EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance"]

View File

@ -1,7 +1,8 @@
FROM alpine:3.19
FROM alpine:3.20
WORKDIR /app
COPY build/glance /app/glance
COPY glance .
EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance"]

View File

@ -10,7 +10,10 @@
* Subreddit posts
* Weather
* Bookmarks
* Hacker News
* Lobsters
* Latest YouTube videos from specific channels
* Clock
* Calendar
* Stocks
* iframe
@ -18,6 +21,7 @@
* GitHub releases
* Repository overview
* Site monitor
* Search box
#### Themeable
![multiple color schemes example](docs/images/themes-example.png)
@ -92,18 +96,12 @@ go run .
### Building Docker image
Build Glance with CGO disabled:
```bash
CGO_ENABLED=0 go build -o build/glance .
```
Build the image:
**Make sure to replace "owner" with your name or organization.**
```bash
docker build -t owner/glance:latest -f Dockerfile.single-platform .
docker build -t owner/glance:latest .
```
Push the image to your registry:

View File

@ -3,6 +3,7 @@
- [Intro](#intro)
- [Preconfigured page](#preconfigured-page)
- [Server](#server)
- [Branding](#branding)
- [Theme](#theme)
- [Themes](#themes)
- [Pages & Columns](#pages--columns)
@ -10,17 +11,24 @@
- [RSS](#rss)
- [Videos](#videos)
- [Hacker News](#hacker-news)
- [Lobsters](#lobsters)
- [Reddit](#reddit)
- [Search](#search-widget)
- [Group](#group)
- [Extension](#extension)
- [Weather](#weather)
- [Monitor](#monitor)
- [Releases](#releases)
- [Repository](#repository)
- [Bookmarks](#bookmarks)
- [Calendar](#calendar)
- [Stocks](#stocks)
- [ChangeDetection.io](#changedetectionio)
- [Clock](#clock)
- [Markets](#markets)
- [Twitch Channels](#twitch-channels)
- [Twitch Top Games](#twitch-top-games)
- [iframe](#iframe)
- [HTML](#html)
## Intro
Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error.
@ -76,8 +84,8 @@ pages:
- type: weather
location: London, United Kingdom
- type: stocks
stocks:
- type: markets
markets:
- symbol: SPY
name: S&P 500
- symbol: BTC-USD
@ -117,6 +125,7 @@ server:
| ---- | ---- | -------- | ------- |
| host | string | no | |
| port | number | no | 8080 |
| base-url | string | no | |
| assets-path | string | no | |
#### `host`
@ -125,6 +134,9 @@ The address which the server will listen on. Setting it to `localhost` means tha
#### `port`
A number between 1 and 65,535, so long as that port isn't already used by anything else.
#### `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`
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.
@ -162,6 +174,42 @@ To be able to point to an asset from your assets path, use the `/assets/` path l
icon: /assets/gitea-icon.png
```
## Branding
You can adjust the various parts of the branding through a top level `branding` property. Example:
```yaml
branding:
custom-footer: |
<p>Powered by <a href="https://github.com/glanceapp/glance">Glance</a></p>
logo-url: /assets/logo.png
favicon-url: /assets/logo.png
```
### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| hide-footer | bool | no | false |
| custom-footer | string | no | |
| logo-text | string | no | G |
| logo-url | string | no | |
| favicon-url | string | no | |
#### `hide-footer`
Hides the footer when set to `true`.
#### `custom-footer`
Specify custom HTML to use for the footer.
#### `logo-text`
Specify custom text to use instead of the "G" found in the navigation.
#### `logo-url`
Specify a URL to a custom image to use instead of the "G" found in the navigation. If both `logo-text` and `logo-url` are set, only `logo-url` will be used.
#### `favicon-url`
Specify a URL to a custom image to use for the favicon.
## Theme
Theming is done through a top level `theme` property. Values for the colors are in [HSL](https://giggster.com/guide/basics/hue-saturation-lightness/) (hue, saturation, lightness) format. You can use a color picker [like this one](https://hslpicker.com/) to convert colors from other formats to HSL. The values are separated by a space and `%` is not required for any of the numbers.
@ -228,6 +276,8 @@ theme:
> .widget-type-rss a {
> font-size: 1.5rem;
> }
>
> In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets.
## Pages & Columns
@ -255,6 +305,8 @@ pages:
| ---- | ---- | -------- | ------- |
| title | string | yes | |
| slug | string | no | |
| width | string | no | |
| hide-desktop-navigation | boolean | no | false |
| show-mobile-header | boolean | no | false |
| columns | array | yes | |
@ -264,6 +316,21 @@ The name of the page which gets shown in the navigation bar.
#### `slug`
The URL friendly version of the title which is used to access the page. For example if the title of the page is "RSS Feeds" you can make the page accessible via `localhost:8080/feeds` by setting the slug to `feeds`. If not defined, it will automatically be generated from the title.
#### `width`
The maximum width of the page on desktop. Possible values are `slim` and `wide`.
* default: `1600px`
* slim: `1100px`
* wide: `1920px`
> [!NOTE]
>
> When using `slim`, the maximum number of columns allowed for that page is `2`.
#### `hide-desktop-navigation`
Whether to show the navigation links at the top of the page on desktop.
#### `show-mobile-header`
Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices.
@ -348,7 +415,9 @@ pages:
| ---- | ---- | -------- |
| type | string | yes |
| title | string | no |
| title-url | string | no |
| cache | string | no |
| css-class | string | no |
#### `type`
Used to specify the widget.
@ -356,6 +425,9 @@ Used to specify the widget.
#### `title`
The title of the widget. If left blank it will be defined by the widget.
#### `title-url`
The URL to go to when clicking on the widget's title. If left blank it will be defined by the widget (if available).
#### `cache`
How long to keep the fetched data in memory. The value is a string and must be a number followed by one of s, m, h, d. Examples:
@ -370,6 +442,9 @@ cache: 1d # 1 day
>
> Not all widgets can have their cache duration modified. The calendar and weather widgets update on the hour and this cannot be changed.
#### `css-class`
Set custom CSS classes for the specific widget instance.
### RSS
Display a list of articles from multiple RSS feeds.
@ -405,6 +480,10 @@ Used to change the appearance of the widget. Possible values are `vertical-list`
![preview of vertical-list style for RSS widget](images/rss-feed-vertical-list-preview.png)
`detailed-list`
![preview of detailed-list style for RSS widget](images/rss-widget-detailed-list-preview.png)
`horizontal-cards`
![preview of horizontal-cards style for RSS widget](images/rss-feed-horizontal-cards-preview.png)
@ -423,10 +502,16 @@ Used to modify the height of cards when using the `horizontal-cards-2` style. Th
An array of RSS/atom feeds. The title can optionally be changed.
###### Properties for each feed
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| url | string | yes | |
| title | string | no | the title provided by the feed |
| Name | Type | Required | Default | Notes |
| ---- | ---- | -------- | ------- | ----- |
| url | string | yes | | |
| title | string | no | the title provided by the feed | |
| hide-categories | boolean | no | false | Only applicable for `detailed-list` style |
| hide-description | boolean | no | false | Only applicable for `detailed-list` style |
| item-link-prefix | string | no | | |
###### `item-link-prefix`
If an RSS feed isn't returning item links with a base domain and Glance has failed to automatically detect the correct domain you can manually add a prefix to each link with this property.
##### `limit`
The maximum number of articles to show.
@ -456,6 +541,8 @@ Preview:
| channels | array | yes | |
| limit | integer | no | 25 |
| style | string | no | horizontal-cards |
| collapse-after-rows | integer | no | 4 |
| include-shorts | boolean | no | false |
| video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} |
##### `channels`
@ -470,6 +557,9 @@ Then scroll down and click on "Share channel", then "Copy channel ID":
##### `limit`
The maximum number of videos to show.
##### `collapse-after-rows`
Specify the number of rows to show when using the `grid-cards` style before the "SHOW MORE" button appears.
##### `style`
Used to change the appearance of the widget. Possible values are `horizontal-cards` and `grid-cards`.
@ -530,6 +620,57 @@ Can be used to specify an additional sort which will be applied on top of the al
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
### Lobsters
Display a list of posts from [Lobsters](https://lobste.rs).
Example:
```yaml
- type: lobsters
sort-by: hot
tags:
- go
- security
- linux
limit: 15
collapse-after: 5
```
Preview:
![](images/lobsters-widget-preview.png)
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| instance-url | string | no | https://lobste.rs/ |
| custom-url | string | no | |
| limit | integer | no | 15 |
| collapse-after | integer | no | 5 |
| sort-by | string | no | hot |
| tags | array | no | |
##### `instance-url`
The base URL for a lobsters instance hosted somewhere other than on lobste.rs. Example:
```yaml
instance-url: https://www.journalduhacker.net/
```
##### `custom-url`
A custom URL to retrieve lobsters posts from. If this is specified, the `instance-url`, `sort-by` and `tags` properties are ignored.
##### `limit`
The maximum number of posts to show.
##### `collapse-after`
How many posts are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
##### `sort-by`
The sort order in which posts are returned. Possible options are `hot` and `new`.
##### `tags`
Limit to posts containing one of the given tags. **You cannot specify a sort order when filtering by tags, it will default to `hot`.**
### Reddit
Display a list of posts from a specific subreddit.
@ -550,6 +691,7 @@ Example:
| subreddit | string | yes | |
| style | string | no | vertical-list |
| show-thumbnails | boolean | no | false |
| show-flairs | boolean | no | false |
| limit | integer | no | 15 |
| collapse-after | integer | no | 5 |
| comments-url-template | string | no | https://www.reddit.com/{POST-PATH} |
@ -586,6 +728,9 @@ Shows or hides thumbnails next to the post. This only works if the `style` is `v
>
> Thumbnails don't work for some subreddits due to Reddit's API not returning the thumbnail URL. No workaround for this yet.
##### `show-flairs`
Shows post flairs when set to `true`.
##### `limit`
The maximum number of posts to show.
@ -626,7 +771,7 @@ https://your.proxy/?url={REQUEST-URL}
##### `sort-by`
Can be used to specify the order in which the posts should get returned. Possible values are `hot`, `new`, `top` and `rising`.
##### `top-perid`
##### `top-period`
Available only when `sort-by` is set to `top`. Possible values are `hour`, `day`, `week`, `month`, `year` and `all`.
##### `search`
@ -639,6 +784,167 @@ Can be used to specify an additional sort which will be applied on top of the al
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
### Search Widget
Display a search bar that can be used to search for specific terms on various search engines.
Example:
```yaml
- type: search
search-engine: duckduckgo
bangs:
- title: YouTube
shortcut: "!yt"
url: https://www.youtube.com/results?search_query={QUERY}
```
Preview:
![](images/search-widget-preview.png)
#### Keyboard shortcuts
| Keys | Action | Condition |
| ---- | ------ | --------- |
| <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 |
> [!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
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| search-engine | string | no | duckduckgo |
| new-tab | boolean | no | false |
| autofocus | boolean | no | false |
| bangs | array | no | |
##### `search-engine`
Either a value from the table below or a URL to a custom search engine. Use `{QUERY}` to indicate where the query value gets placed.
| Name | URL |
| ---- | --- |
| duckduckgo | `https://duckduckgo.com/?q={QUERY}` |
| google | `https://www.google.com/search?q={QUERY}` |
##### `new-tab`
When set to `true`, swaps the shortcuts for showing results in the same or new tab, defaulting to showing results in a new tab.
##### `new-tab`
When set to `true`, automatically focuses the search input on page load.
##### `bangs`
What now? [Bangs](https://duckduckgo.com/bangs). They're shortcuts that allow you to use the same search box for many different sites. Assuming you have it configured, if for example you start your search input with `!yt` you'd be able to perform a search on YouTube:
![](images/search-widget-bangs-preview.png)
##### Properties for each bang
| Name | Type | Required |
| ---- | ---- | -------- |
| title | string | no |
| shortcut | string | yes |
| url | string | yes |
###### `title`
Optional title that will appear on the right side of the search bar when the query starts with the associated shortcut.
###### `shortcut`
Any value you wish to use as the shortcut for the search engine. It does not have to start with `!`.
> [!IMPORTANT]
>
> In YAML some characters have special meaning when placed in the beginning of a value. If your shortcut starts with `!` (and potentially some other special characters) you'll have to wrap the value in quotes:
> ```yaml
> shortcut: "!yt"
>```
###### `url`
The URL of the search engine. Use `{QUERY}` to indicate where the query value gets placed. Examples:
```yaml
url: https://www.reddit.com/search?q={QUERY}
url: https://store.steampowered.com/search/?term={QUERY}
url: https://www.amazon.com/s?k={QUERY}
```
### Group
Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget within a group widget.
Example:
```yaml
- type: group
widgets:
- type: reddit
subreddit: gamingnews
show-thumbnails: true
collapse-after: 6
- type: reddit
subreddit: games
- type: reddit
subreddit: pcgaming
show-thumbnails: true
```
Preview:
![](images/group-widget-preview.png)
#### Sharing properties
To avoid repetition you can use [YAML anchors](https://support.atlassian.com/bitbucket-cloud/docs/yaml-anchors/) and share properties between widgets.
Example:
```yaml
- type: group
define: &shared-properties
type: reddit
show-thumbnails: true
collapse-after: 6
widgets:
- subreddit: gamingnews
<<: *shared-properties
- subreddit: games
<<: *shared-properties
- subreddit: pcgaming
<<: *shared-properties
```
### Extension
Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP).
```yaml
- type: extension
url: https://domain.com/widget/display-a-message
allow-potentially-dangerous-html: true
parameters:
message: Hello, world!
```
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| url | string | yes | |
| allow-potentially-dangerous-html | boolean | no | false |
| parameters | key & value | no | |
##### `url`
The URL of the extension.
##### `allow-potentially-dangerous-html`
Whether to allow the extension to display HTML.
> [!WARNING]
>
> There's a reason this property is scary-sounding. It's intended to be used by developers who are comfortable with developing and using their own extensions. Do not enable it if you have no idea what it means or if you're not **absolutely sure** that the extension URL you're using is safe.
##### `parameters`
A list of keys and values that will be sent to the extension as query paramters.
### Weather
Display weather information for a specific location. The data is provided by https://open-meteo.com/.
@ -647,6 +953,7 @@ Example:
```yaml
- type: weather
units: metric
hour-format: 12h
location: London, United Kingdom
```
@ -671,6 +978,7 @@ Each bar represents a 2 hour interval. The yellow background represents sunrise
| ---- | ---- | -------- | ------- |
| location | string | yes | |
| units | string | no | metric |
| hour-format | string | no | 12h |
| hide-location | boolean | no | false |
| show-area-name | boolean | no | false |
@ -680,6 +988,9 @@ The name of the city and country to fetch weather information for. Attempting to
##### `units`
Whether to show the temperature in celsius or fahrenheit, possible values are `metric` or `imperial`.
#### `hour-format`
Whether to show the hours of the day in 12-hour format or 24-hour format. Possible values are `12h` and `24h`.
##### `hide-location`
Optionally don't display the location name on the widget.
@ -697,7 +1008,7 @@ Greenville, United States
```
### Monitor
Display a list of sites and whether they are reachable (online) or not. This is determined by sending a HEAD request to the specified URL, if the response is 200 then the site is OK. The time it took to receive a response is also shown in milliseconds.
Display a list of sites and whether they are reachable (online) or not. This is determined by sending a GET request to the specified URL, if the response is 200 then the site is OK. The time it took to receive a response is also shown in milliseconds.
Example:
@ -748,7 +1059,9 @@ Properties for each site:
| ---- | ---- | -------- | ------- |
| title | string | yes | |
| url | string | yes | |
| check-url | string | no | |
| icon | string | no | |
| allow-insecure | boolean | no | false |
| same-tab | boolean | no | false |
`title`
@ -757,28 +1070,48 @@ The title used to indicate the site.
`url`
The URL which will be requested and its response will determine the status of the site. Optionally, you can specify this using an environment variable with the syntax `${VARIABLE_NAME}`.
The public facing URL of a monitored service, the user will be redirected here. If `check-url` is not specified, this is used as the status check.
`check-url`
The URL which will be requested and its response will determine the status of the site. If not specified, the `url` property is used.
`icon`
Optional URL to an image which will be used as the icon for the site. Can be an external URL or internal via [server configured assets](#assets-path).
Optional URL to an image which will be used as the icon for the site. Can be an external URL or internal via [server configured assets](#assets-path). You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix:
```yaml
icon: si:jellyfin
icon: si:gitea
icon: si:adguard
```
> [!WARNING]
>
> Simple Icons are loaded externally and are hosted on `cdnjs.cloudflare.com`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
`allow-insecure`
Whether to ignore invalid/self-signed certificates.
`same-tab`
Whether to open the link in the same or a new tab.
### Releases
Display a list of releases for specific repositories on Github. Draft releases and prereleases will not be shown.
Display a list of latest releases for specific repositories on Github, GitLab or Docker Hub.
Example:
```yaml
- type: releases
show-source-icon: true
repositories:
- immich-app/immich
- go-gitea/gitea
- dani-garcia/vaultwarden
- jellyfin/jellyfin
- glanceapp/glance
- gitlab:fdroid/fdroidclient
- dockerhub:gotify/server
```
Preview:
@ -790,12 +1123,41 @@ Preview:
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| repositories | array | yes | |
| show-source-icon | boolean | no | false | |
| token | string | no | |
| gitlab-token | string | no | |
| limit | integer | no | 10 |
| collapse-after | integer | no | 5 |
##### `repositories`
A list of repositores for which to fetch the latest release for. Only the name/repo is required, not the full URL.
A list of repositores to fetch the latest release for. Only the name/repo is required, not the full URL. A prefix can be specified for repositories hosted elsewhere such as GitLab and Docker Hub. Example:
```yaml
repositories:
- gitlab:inkscape/inkscape
- dockerhub:glanceapp/glance
```
Official images on Docker Hub can be specified by ommiting the owner:
```yaml
repositories:
- dockerhub:nginx
- dockerhub:node
- dockerhub:alpine
```
You can also specify specific tags for Docker Hub images:
```yaml
repositories:
- dockerhub:nginx:latest
- dockerhub:nginx:stable-alpine
```
##### `show-source-icon`
Shows an icon of the source (GitHub/GitLab/Docker Hub) next to the repository name when set to `true`.
##### `token`
Without authentication Github allows for up to 60 requests per hour. You can easily exceed this limit and start seeing errors if you're tracking lots of repositories or your cache time is low. To circumvent this you can [create a read only token from your Github account](https://github.com/settings/personal-access-tokens/new) and provide it here.
@ -820,6 +1182,9 @@ and then use it in your `glance.yml` like this:
This way you can safely check your `glance.yml` in version control without exposing the token.
##### `gitlab-token`
Same as the above but used when fetching GitLab releases.
##### `limit`
The maximum number of releases to show.
@ -963,6 +1328,98 @@ Whether to open the link in the same tab or a new one.
Whether to hide the colored arrow on each link.
### ChangeDetection.io
Display a list watches from changedetection.io.
Example
```yaml
- type: change-detection
instance-url: https://changedetection.mydomain.com/
token: ${CHANGE_DETECTION_TOKEN}
```
Preview:
![](images/change-detection-widget-preview.png)
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| instance-url | string | no | `https://www.changedetection.io` |
| token | string | no | |
| limit | integer | no | 10 |
| collapse-after | integer | no | 5 |
| watches | array of strings | no | |
##### `instance-url`
The URL pointing to your instance of `changedetection.io`.
##### `token`
The API access token which can be found in `SETTINGS > API`. Optionally, you can specify this using an environment variable with the syntax `${VARIABLE_NAME}`.
##### `limit`
The maximum number of watches to show.
##### `collapse-after`
How many watches are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
##### `watches`
By default all of the configured watches will be shown. Optionally, you can specify a list of UUIDs for the specific watches you want to have listed:
```yaml
- type: change-detection
watches:
- 1abca041-6d4f-4554-aa19-809147f538d3
- 705ed3e4-ea86-4d25-a064-822a6425be2c
```
### Clock
Display a clock showing the current time and date. Optionally, also display the the time in other timezones.
Example:
```yaml
- type: clock
hour-format: 24h
timezones:
- timezone: Europe/Paris
label: Paris
- timezone: America/New_York
label: New York
- timezone: Asia/Tokyo
label: Tokyo
```
Preview:
![](images/clock-widget-preview.png)
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| hour-format | string | no | 24h |
| timezones | array | no | |
##### `hour-format`
Whether to show the time in 12 or 24 hour format. Possible values are `12h` and `24h`.
#### Properties for each timezone
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| timezone | string | yes | |
| label | string | no | |
##### `timezone`
A timezone identifier such as `Europe/London`, `America/New_York`, etc. The full list of available identifiers can be found [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
##### `label`
Optionally, override the display value for the timezone to something more meaningful such as "Home", "Work" or anything else.
### Calendar
Display a calendar.
@ -980,14 +1437,14 @@ Preview:
>
> There is currently no customizability available for the calendar. Extra features will be added in the future.
### Stocks
Display a list of stocks, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance.
### Markets
Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance.
Example:
```yaml
- type: stocks
stocks:
- type: markets
markets:
- symbol: SPY
name: S&P 500
- symbol: BTC-USD
@ -1002,21 +1459,21 @@ Example:
Preview:
![](images/stocks-widget-preview.png)
![](images/markets-widget-preview.png)
#### Properties
| Name | Type | Required |
| ---- | ---- | -------- |
| stocks | array | yes |
| markets | array | yes |
| sort-by | string | no |
| style | string | no |
##### `stocks`
An array of stocks for which to display information about.
##### `markets`
An array of markets for which to display information about.
##### `sort-by`
By default the stocks are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change.
By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change.
##### `style`
To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option.
@ -1068,6 +1525,7 @@ Preview:
| ---- | ---- | -------- | ------- |
| channels | array | yes | |
| collapse-after | integer | no | 5 |
| sort-by | string | no | viewers |
##### `channels`
A list of channels to display.
@ -1075,6 +1533,9 @@ A list of channels to display.
##### `collapse-after`
How many channels are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
##### `sort-by`
Can be used to specify the order in which the channels are displayed. Possible values are `viewers` and `live`.
### Twitch top games
Display a list of games with the most viewers on Twitch.
@ -1137,3 +1598,16 @@ The source of the iframe.
##### `height`
The height of the iframe. The minimum allowed height is 50.
### HTML
Embed any HTML.
Example:
```yaml
- type: html
source: |
<p>Hello, <span class="color-primary">World</span>!</p>
```
Note the use of `|` after `source:`, this allows you to insert a multi-line string.

158
docs/extensions.md Normal file
View File

@ -0,0 +1,158 @@
# Extensions
> [!IMPORTANT]
>
> **This document as well as the extensions feature are a work in progress. The API may change in the future. You are responsible for maintaining your own extensions.**
## Overview
With the intention of requiring minimal knowledge in order to develop extensions, rather than being a convoluted protocol they are nothing more than an HTTP request to a server that returns a few special headers. The exchange between Glance and extensions can be seen in the following diagram:
![](images/extension-overview.png)
If you know how to setup an HTTP server and a bit of HTML and CSS you're ready to start building your own extensions.
> [!TIP]
>
> By default, the extension widget has a cache time of 30 minutes. To avoid having to restart Glance after every extension change you can set the cache time of the widget to 1 second:
> ```yaml
> - type: extension
> url: http://localhost:8081
> cache: 1s
> ```
## Headers
### `Widget-Title`
Used to specify the title of the widget. If not provided, the widget's title will be "Extension".
### `Widget-Content-Type`
Used to specify the content type that will be returned by the extension. If not provided, the content will be shown as plain text.
## Content Types
> [!NOTE]
>
> Currently, `html` is the only supported content type. The long-term goal is to have generic content types such as `videos`, `forum-posts`, `markets`, `streams`, etc. which will be returned in JSON format and displayed by Glance using existing styles and functionality, allowing extension developers to achieve a native look while only focusing on providing data from their preferred source.
### `html`
Displays the content as HTML. This requires the user to have the `allow-potentially-dangerous-html` property set to `true`, otherwise the content will be shown as plain text.
#### Using existing classes and functionality
Most of the features seen throughout Glance can easily be used in your custom HTML extensions. Below is an example of some of these features:
```html
<p class="color-subdue">Text with subdued color</p>
<p>Text with base color</p>
<p class="color-highlight">Text with highlighted color</p>
<p class="color-primary">Text with primary color</p>
<p class="color-positive">Text with positive color</p>
<p class="color-negative">Text with negative color</p>
<hr class="margin-block-15">
<p class="size-h1">Font size 1</p>
<p class="size-h2">Font size 2</p>
<p class="size-h3">Font size 3</p>
<p class="size-h4">Font size 4</p>
<p class="size-base">Font size base</p>
<p class="size-h5">Font size 5</p>
<p class="size-h6">Font size 6</p>
<hr class="margin-block-15">
<a class="visited-indicator" href="#notvisitedprobably">Link with visited indicator</a>
<hr class="margin-block-15">
<a class="color-primary-if-not-visited" href="#notvisitedprobably">Link with primary color if not visited</a>
<hr class="margin-block-15">
<p>Event happened <span data-dynamic-relative-time="<unix timestamp>"></span> ago</p>
<hr class="margin-block-15">
<ul class="list-horizontal-text">
<li>horizontal</li>
<li>list</li>
<li>with</li>
<li>multiple</li>
<li>text</li>
<li>items</li>
</ul>
<hr class="margin-block-15">
<ul class="list list-gap-10 list-with-separator">
<li>list</li>
<li>with</li>
<li>gap</li>
<li>and</li>
<li>horizontal</li>
<li>lines</li>
</ul>
<hr class="margin-block-15">
<ul class="list collapsible-container" data-collapse-after="3">
<li>collapsible</li>
<li>list</li>
<li>with</li>
<li>many</li>
<li>items</li>
<li>that</li>
<li>will</li>
<li>appear</li>
<li>when</li>
<li>you</li>
<li>click</li>
<li>the</li>
<li>button</li>
<li>below</li>
</ul>
<hr class="margin-bottom-15">
<p class="margin-bottom-10">Lazily loaded image:</p>
<img src="https://picsum.photos/200" alt="" loading="lazy">
<hr class="margin-block-15">
<p class="margin-bottom-10">List of posts:</p>
<ul class="list list-gap-14 collapsible-container" data-collapse-after="5">
<li>
<a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
<ul class="list-horizontal-text">
<li data-dynamic-relative-time="<unix timestamp>"></li>
<li>3,321 points</li>
<li>139 comments</li>
</ul>
</li>
<li>
<a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
<ul class="list-horizontal-text">
<li data-dynamic-relative-time="<unix timestamp>"></li>
<li>3,321 points</li>
<li>139 comments</li>
</ul>
</li>
<li>
<a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
<ul class="list-horizontal-text">
<li data-dynamic-relative-time="<unix timestamp>"></li>
<li>3,321 points</li>
<li>139 comments</li>
</ul>
</li>
</ul>
```
All of that will result in the following:
![](images/extension-html-reusing-existing-features-preview.png)
**Class names or features may change, once again, you are responsible for maintaining your own extensions.**

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

View File

@ -53,6 +53,16 @@ theme:
primary-color: 97 13 80
```
### Kanagawa Dark
![screenshot](images/themes/kanagawa-dark.png)
```yaml
theme:
background-color: 240 13 14
primary-color: 51 33 68
negative-color: 358 100 68
contrast-multiplier: 1.2
```
### Tucan
![screenshot](images/themes/tucan.png)
```yaml

8
go.mod
View File

@ -1,19 +1,19 @@
module github.com/glanceapp/glance
go 1.22.0
go 1.22.5
require (
github.com/mmcdole/gofeed v1.3.0
golang.org/x/text v0.14.0
golang.org/x/text v0.16.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/PuerkitoBio/goquery v1.9.1 // indirect
github.com/PuerkitoBio/goquery v1.9.2 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mmcdole/goxpp v1.1.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/net v0.27.0 // indirect
)

12
go.sum
View File

@ -1,5 +1,5 @@
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -33,8 +33,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -54,8 +54,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View File

@ -1,8 +1,14 @@
package assets
import (
"crypto/md5"
"embed"
"encoding/hex"
"io"
"io/fs"
"log/slog"
"strconv"
"time"
)
//go:embed static
@ -13,3 +19,38 @@ var _templateFS embed.FS
var PublicFS, _ = fs.Sub(_publicFS, "static")
var TemplateFS, _ = fs.Sub(_templateFS, "templates")
func getFSHash(files fs.FS) string {
hash := md5.New()
err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
file, err := files.Open(path)
if err != nil {
return err
}
if _, err := io.Copy(hash, file); err != nil {
return err
}
return nil
})
if err == nil {
return hex.EncodeToString(hash.Sum(nil))[:10]
}
slog.Warn("Could not compute assets cache", "err", err)
return strconv.FormatInt(time.Now().Unix(), 10)
}
var PublicFSHash = getFSHash(PublicFS)

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>

After

Width:  |  Height:  |  Size: 802 B

View File

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"/></svg>

After

Width:  |  Height:  |  Size: 553 B

View File

@ -37,6 +37,7 @@
--ths: var(--bgh), calc(var(--bgs) * var(--tsm));
--color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
--color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%));
--color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
--color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
@ -53,7 +54,20 @@
--scheme: 100% -;
}
.size-title-dynamic {
.page {
height: 100%;
padding-block: var(--widget-gap);
}
.page-content, .page.content-ready .page-loading-container {
display: none;
}
.page.content-ready > .page-content {
display: block;
}
.page-column-small .size-title-dynamic {
font-size: var(--font-size-h4);
}
@ -71,14 +85,16 @@
white-space: nowrap;
}
.text-truncate-3-lines {
.text-truncate-2-lines, .text-truncate-3-lines {
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 3;
display: -webkit-box;
-webkit-box-orient: vertical;
}
.text-truncate-3-lines { -webkit-line-clamp: 3; }
.text-truncate-2-lines { -webkit-line-clamp: 2; }
.visited-indicator:not(.text-truncate)::after,
.visited-indicator.text-truncate::before,
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
@ -106,6 +122,7 @@
.list-gap-14 { --list-half-gap: 0.7rem; }
.list-gap-20 { --list-half-gap: 1rem; }
.list-gap-24 { --list-half-gap: 1.2rem; }
.list-gap-34 { --list-half-gap: 1.7rem; }
.list > *:not(:first-child) {
margin-top: calc(var(--list-half-gap) * 2);
@ -117,70 +134,135 @@
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 {
opacity: 0;
transform: translateY(10px);
}
}
.list-collapsible-item {
display: none;
animation: listItemReveal 0.3s backwards;
animation-delay: var(--animation-delay);
}
.list-collapsible-label {
display: flex;
align-items: center;
gap: 1rem;
.expand-toggle-button {
font: inherit;
border: 0;
cursor: pointer;
display: block;
width: 100%;
text-align: left;
color: var(--color-text-base);
text-transform: uppercase;
font-size: var(--font-size-h4);
padding: var(--widget-content-vertical-padding) 0;
background: var(--color-widget-background);
}
.list-collapsible-label:has(.list-collapsible-input:checked) {
.expand-toggle-button.container-expanded {
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 {
display: block;
.expand-toggle-button-icon {
display: inline-block;
margin-left: 1rem;
position: relative;
top: -.2rem;
}
.list-collapsible-input {
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 {
.expand-toggle-button-icon::before {
content: '';
font-size: 0.8rem;
transform: rotate(90deg);
line-height: 1;
display: inline-block;
transition: transform 0.3s;
}
.list-collapsible-label:has(.list-collapsible-input:checked)::after {
.expand-toggle-button.container-expanded .expand-toggle-button-icon::before {
transform: rotate(-90deg);
}
.widget-content:has(.list-collapsible-label:last-child) {
.widget-group-header {
overflow-x: auto;
scrollbar-width: thin;
}
.widget-group-title {
background: none;
font: inherit;
border: none;
color: inherit;
text-transform: uppercase;
border-bottom: 1px solid transparent;
cursor: pointer;
flex-shrink: 0;
padding-bottom: 0.1rem;
transition: color .3s, border-color .3s;
}
.widget-group-title:hover:not(.widget-group-title-current) {
border-bottom-color: var(--color-text-subdue);
color: var(--color-text-highlight);
}
.widget-group-title-current {
border-bottom-color: var(--color-primary);
color: var(--color-text-highlight);
}
.widget-group-content {
animation: widgetGroupContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
.widget-group-content[data-direction="right"] {
--direction: 5px;
}
.widget-group-content[data-direction="left"] {
--direction: -5px;
}
@keyframes widgetGroupContentEntrance {
from {
opacity: 0;
transform: translateX(var(--direction));
}
}
.widget-group-content:not(.widget-group-content-current) {
display: none;
}
.widget-content:has(.expand-toggle-button:last-child) {
padding-bottom: 0;
}
.cards-grid.collapsible-container + .expand-toggle-button {
text-align: center;
margin-top: 0.5rem;
background-color: var(--color-background);
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.attachments > * {
border-radius: var(--border-radius);
padding: 0.1rem 0.5rem;
font-size: var(--font-size-h6);
background-color: var(--color-separator);
}
::selection {
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
color: var(--color-text-highlight);
@ -234,9 +316,14 @@ html {
scroll-behavior: smooth;
}
html, body {
height: 100%;
}
a {
text-decoration: none;
color: inherit;
overflow-wrap: break-word;
}
ul {
@ -266,7 +353,6 @@ body {
.page-columns {
display: flex;
gap: var(--widget-gap);
margin: var(--widget-gap) 0;
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
@ -278,13 +364,19 @@ body {
}
.page-loading-container {
margin: 50px auto;
width: fit-content;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
animation: loadingContainerEntrance 200ms backwards;
animation-delay: 150ms;
font-size: 2rem;
}
.page-loading-container > .loading-icon {
translate: 0 -250%;
}
@keyframes loadingContainerEntrance {
from {
opacity: 0;
@ -327,12 +419,38 @@ body {
border: 1px solid var(--color-negative);
}
kbd {
font: inherit;
padding: 0.1rem 0.8rem;
border-radius: var(--border-radius);
border: 2px solid var(--color-widget-background-highlight);
box-shadow: 0 2px 0 var(--color-widget-background-highlight);
user-select: none;
transition: transform .1s, box-shadow .1s;
font-size: var(--font-size-h5);
cursor: pointer;
}
kbd:active {
transform: translateY(2px);
box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
}
.content-bounds {
max-width: 1600px;
width: 100%;
margin-inline: auto;
padding: 0 var(--content-bounds-padding);
}
.page-width-wide .content-bounds {
max-width: 1920px;
}
.page-width-slim .content-bounds {
max-width: 1100px;
}
.dynamic-columns {
gap: calc(var(--widget-content-vertical-padding) / 2);
display: grid;
@ -401,6 +519,7 @@ body {
.cards-horizontal {
overflow-x: auto;
scrollbar-width: thin;
padding-bottom: 1rem;
}
@ -424,8 +543,6 @@ body {
@container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } }
@container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } }
.widget-error-header {
display: flex;
align-items: center;
@ -495,7 +612,7 @@ body {
.widget-header {
padding: 0 calc(var(--widget-content-horizontal-padding) + 1px);
font-size: var(--font-size-h4);
margin-bottom: 1rem;
margin-bottom: 0.9rem;
display: flex;
align-items: center;
gap: 1rem;
@ -541,6 +658,15 @@ body {
padding-right: var(--widget-content-horizontal-padding);
}
.logo:has(img) {
display: flex;
align-items: center;
}
.logo img {
max-height: 2.7rem;
}
.nav {
height: 100%;
gap: var(--header-items-gap);
@ -551,7 +677,8 @@ body {
}
.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-delay: 150ms;
}
@ -578,16 +705,23 @@ body {
color: var(--color-text-highlight);
}
.stock-chart {
.release-source-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
opacity: 0.4;
}
.market-chart {
margin-left: auto;
width: 6.5rem;
}
.stock-chart svg {
.market-chart svg {
width: 100%;
}
.stock-values {
.market-values {
min-width: 8rem;
}
@ -638,6 +772,86 @@ body {
-webkit-box-orient: vertical;
}
.search-icon {
width: 2.3rem;
}
.search-icon-container {
position: relative;
flex-shrink: 0;
}
/* gives a wider hit area for the 3 people that will notice the animation : ) */
.search-icon-container::before {
content: '';
position: absolute;
inset: -1rem;
}
.search-icon-container:hover > .search-icon {
animation: searchIconHover 2.9s forwards;
}
@keyframes searchIconHover {
0%, 39% { translate: 0 0; }
20% { scale: 1.3; }
40% { scale: 1; }
50% { translate: -30% 30%; }
70% { translate: 30% -30%; }
90% { translate: -30% -30%; }
100% { translate: 0 0; }
}
.search {
transition: border-color .2s;
position: relative;
}
.search:hover {
border-color: var(--color-text-subdue);
}
.search:focus-within {
border-color: var(--color-primary);
}
.search-input {
border: 0;
background: none;
width: 100%;
height: 6rem;
font: inherit;
outline: none;
color: var(--color-text-highlight);
}
.search-input::placeholder {
color: var(--color-text-base-muted);
opacity: 1;
}
.search-bangs { display: none; }
.search-bang {
border-radius: calc(var(--border-radius) * 2);
background: var(--color-widget-background-highlight);
padding: 0.3rem 1rem;
flex-shrink: 0;
font-size: var(--font-size-h5);
animation: searchBangsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
@keyframes searchBangsEntrance {
0% {
opacity: 0;
transform: translateX(-10px);
}
}
.search-bang:empty {
display: none;
}
.forum-post-list-item {
display: flex;
gap: 1.2rem;
@ -653,6 +867,10 @@ body {
margin-top: 0.1rem;
}
.forum-post-tags-container {
transform: translateY(-0.15rem);
}
.bookmarks-group {
--bookmarks-group-color: var(--color-primary);
}
@ -670,6 +888,7 @@ body {
background-color: var(--color-widget-background-highlight);
border-radius: var(--border-radius);
padding: 0.5rem;
opacity: 0.7;
}
.bookmarks-icon {
@ -678,10 +897,6 @@ body {
opacity: 0.8;
}
.simple-icon {
opacity: 0.7;
}
:root:not(.light-scheme) .simple-icon {
filter: invert(1);
}
@ -706,7 +921,7 @@ body {
flex-direction: column;
width: calc(100% / 12);
padding-top: 3px;
max-width: 3.5rem;
max-width: 30px;
}
.weather-column-value, .weather-columns:hover .weather-column-value {
@ -840,6 +1055,10 @@ body {
transform: translate(-50%, -50%);
}
.clock-time span {
color: var(--color-text-highlight);
}
.monitor-site-icon {
display: block;
opacity: 0.8;
@ -852,6 +1071,10 @@ body {
transition: filter 0.3s, opacity 0.3s;
}
.monitor-site-icon.simple-icon {
opacity: 0.7;
}
.monitor-site:hover .monitor-site-icon {
filter: grayscale(0);
opacity: 1;
@ -866,11 +1089,22 @@ body {
.thumbnail {
filter: grayscale(0.2) contrast(0.9);
transition: all 0.2s;
opacity: 0.8;
transition: filter 0.2s, opacity .2s;
}
.thumbnail-container:hover .thumbnail {
.thumbnail-container {
flex-shrink: 0;
border: 1px solid var(--color-separator);
border-radius: var(--border-radius);
}
.thumbnail-container > * {
border-radius: var(--border-radius);
object-fit: cover;
}
.thumbnail-parent:hover .thumbnail {
opacity: 1;
filter: none;
}
@ -918,8 +1152,23 @@ body {
z-index: 3;
}
.rss-detailed-description {
max-width: 55rem;
color: var(--color-text-base-muted);
}
.rss-detailed-thumbnail {
margin-top: 0.3rem;
}
.rss-detailed-thumbnail > * {
aspect-ratio: 3 / 2;
height: 8.7rem;
}
.twitch-category-thumbnail {
width: 5rem;
aspect-ratio: 3 / 4;
border-radius: var(--border-radius);
}
@ -985,7 +1234,7 @@ body {
display: none;
}
.page-column-full .size-title-dynamic {
.page-column-small .size-title-dynamic {
font-size: var(--font-size-h3);
}
@ -996,10 +1245,10 @@ body {
.page-column {
display: none;
animation: columnEntrance 0s cubic-bezier(0.25, 1, 0.5, 1) backwards;
animation: columnEntrance .0s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
.animate-element-transition .page-column {
.page-columns-transitioned .page-column {
animation-duration: .3s;
}
@ -1010,8 +1259,9 @@ body {
}
}
body {
padding-bottom: calc(var(--mobile-navigation-height) + var(--content-bounds-padding));
.mobile-navigation-offset {
height: var(--mobile-navigation-height);
flex-shrink: 0;
}
.mobile-navigation {
@ -1044,7 +1294,8 @@ body {
padding: 15px var(--content-bounds-padding);
display: flex;
align-items: center;
overflow-x: scroll;
overflow-x: auto;
scrollbar-width: thin;
gap: 2.5rem;
}
@ -1107,9 +1358,48 @@ body {
box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor;
}
.list-collapsible-label:has(.list-collapsible-input:checked) {
.expand-toggle-button.container-expanded {
bottom: var(--mobile-navigation-height);
}
.cards-grid + .expand-toggle-button.container-expanded {
/* hides content that peeks through the rounded borders of the mobile navigation */
box-shadow: 0 var(--border-radius) 0 0 var(--color-background);
}
.weather-column-rain::before {
background-size: 7px 7px;
}
}
@media (max-width: 1190px) and (display-mode: standalone) {
:root {
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0);
}
.list-collapsible-label:has(.list-collapsible-input:checked) {
bottom: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
}
.mobile-navigation {
transform: translateY(calc(100% - var(--mobile-navigation-height) - var(--safe-area-inset-bottom)));
padding-bottom: var(--safe-area-inset-bottom);
}
.mobile-navigation-icons {
padding-bottom: var(--safe-area-inset-bottom);
transition: padding-bottom .3s;
}
.mobile-navigation-icons:has(.mobile-navigation-page-links-input:checked) {
padding-bottom: 0;
}
}
@media (display-mode: standalone) {
body {
padding-top: env(safe-area-inset-top, 0);
}
}
@media (max-width: 550px) {
@ -1123,22 +1413,30 @@ body {
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
.forum-post-list-item {
flex-flow: row-reverse;
.row-reverse-on-mobile {
flex-direction: row-reverse;
}
.hide-on-mobile {
.hide-on-mobile, .thumbnail-container:has(> .hide-on-mobile) {
display: none
}
.mobile-reachability-header {
display: block;
font-size: 3rem;
padding: 10dvh 1rem;
padding: 10vh 1rem;
text-align: center;
color: var(--color-text-highlight);
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
.rss-detailed-thumbnail > * {
height: 6rem;
}
.rss-detailed-description {
-webkit-line-clamp: 3;
}
}
.size-h1 { font-size: var(--font-size-h1); }
@ -1166,7 +1464,10 @@ body {
.shrink { flex-shrink: 1; }
.shrink-0 { flex-shrink: 0; }
.min-width-0 { min-width: 0; }
.max-width-100 { max-width: 100%; }
.height-100 { height: 100%; }
.block { display: block; }
.inline-block { display: inline-block; }
.overflow-hidden { overflow: hidden; }
.relative { position: relative; }
.flex { display: flex; }
@ -1174,6 +1475,7 @@ body {
.flex-nowrap { flex-wrap: nowrap; }
.justify-between { justify-content: space-between; }
.justify-stretch { justify-content: stretch; }
.justify-evenly { justify-content: space-evenly; }
.justify-center { justify-content: center; }
.justify-end { justify-content: end; }
.uppercase { text-transform: uppercase; }
@ -1185,11 +1487,17 @@ body {
.gap-7 { gap: 0.7rem; }
.gap-10 { gap: 1rem; }
.gap-15 { gap: 1.5rem; }
.gap-20 { gap: 2rem; }
.gap-25 { gap: 2.5rem; }
.gap-35 { gap: 3.5rem; }
.gap-45 { gap: 4.5rem; }
.gap-55 { gap: 5.5rem; }
.margin-top-3 { margin-top: 0.3rem; }
.margin-top-5 { margin-top: 0.5rem; }
.margin-top-7 { margin-top: 0.7rem; }
.margin-top-10 { margin-top: 1rem; }
.margin-top-15 { margin-top: 1.5rem; }
.margin-top-auto { margin-top: auto; }
.margin-block-3 { margin-block: 0.3rem; }
.margin-block-5 { margin-block: 0.5rem; }
.margin-block-7 { margin-block: 0.7rem; }
@ -1201,3 +1509,4 @@ body {
.margin-bottom-10 { margin-bottom: 1rem; }
.margin-bottom-15 { margin-bottom: 1.5rem; }
.margin-bottom-auto { margin-bottom: auto; }
.scale-half { transform: scale(0.5); }

View File

@ -21,10 +21,10 @@ function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
};
async function fetchPageContents (pageSlug) {
async function fetchPageContent(pageData) {
// TODO: handle non 200 status codes/time outs
// 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();
return content;
@ -33,8 +33,13 @@ async function fetchPageContents (pageSlug) {
function setupCarousels() {
const carouselElements = document.getElementsByClassName("carousel-container");
if (carouselElements.length == 0) {
return;
}
for (let i = 0; i < carouselElements.length; i++) {
const carousel = carouselElements[i];
carousel.classList.add("show-right-cutoff");
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
const determineSideCutoffs = () => {
@ -54,9 +59,9 @@ function setupCarousels() {
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
document.addEventListener("resize", determineSideCutoffsRateLimited);
window.addEventListener("resize", determineSideCutoffsRateLimited);
determineSideCutoffs();
afterContentReady(determineSideCutoffs);
}
}
@ -98,7 +103,108 @@ function updateRelativeTimeForElements(elements)
if (timestamp === undefined)
continue
element.innerText = relativeTimeSince(timestamp);
element.textContent = relativeTimeSince(timestamp);
}
}
function setupSearchBoxes() {
const searchWidgets = document.getElementsByClassName("search");
if (searchWidgets.length == 0) {
return;
}
for (let i = 0; i < searchWidgets.length; i++) {
const widget = searchWidgets[i];
const defaultSearchUrl = widget.dataset.defaultSearchUrl;
const newTab = widget.dataset.newTab === "true";
const inputElement = widget.getElementsByClassName("search-input")[0];
const bangElement = widget.getElementsByClassName("search-bang")[0];
const bangs = widget.querySelectorAll(".search-bangs > input");
const bangsMap = {};
const kbdElement = widget.getElementsByTagName("kbd")[0];
let currentBang = null;
for (let j = 0; j < bangs.length; j++) {
const bang = bangs[j];
bangsMap[bang.dataset.shortcut] = bang;
}
const handleKeyDown = (event) => {
if (event.key == "Escape") {
inputElement.blur();
return;
}
if (event.key == "Enter") {
const input = inputElement.value.trim();
let query;
let searchUrlTemplate;
if (currentBang != null) {
query = input.slice(currentBang.dataset.shortcut.length + 1);
searchUrlTemplate = currentBang.dataset.url;
} else {
query = input;
searchUrlTemplate = defaultSearchUrl;
}
if (query.length == 0 && currentBang == null) {
return;
}
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
window.open(url, '_blank').focus();
} else {
window.location.href = url;
}
return;
}
};
const changeCurrentBang = (bang) => {
currentBang = bang;
bangElement.textContent = bang != null ? bang.dataset.title : "";
}
const handleInput = (event) => {
const value = event.target.value.trim();
if (value in bangsMap) {
changeCurrentBang(bangsMap[value]);
return;
}
const words = value.split(" ");
if (words.length >= 2 && words[0] in bangsMap) {
changeCurrentBang(bangsMap[words[0]]);
return;
}
changeCurrentBang(null);
};
inputElement.addEventListener("focus", () => {
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("input", handleInput);
});
inputElement.addEventListener("blur", () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("input", handleInput);
});
document.addEventListener("keydown", (event) => {
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
if (event.key != "s") return;
inputElement.focus();
event.preventDefault();
});
kbdElement.addEventListener("mousedown", () => {
requestAnimationFrame(() => inputElement.focus());
});
}
}
@ -107,6 +213,8 @@ function setupDynamicRelativeTime() {
const updateInterval = 60 * 1000;
let lastUpdateTime = Date.now();
updateRelativeTimeForElements(elements);
const updateElementsAndTimestamp = () => {
updateRelativeTimeForElements(elements);
lastUpdateTime = Date.now();
@ -142,6 +250,46 @@ function setupDynamicRelativeTime() {
});
}
function setupGroups() {
const groups = document.getElementsByClassName("widget-type-group");
if (groups.length == 0) {
return;
}
for (let g = 0; g < groups.length; g++) {
const group = groups[g];
const titles = group.getElementsByClassName("widget-header")[0].children;
const tabs = group.getElementsByClassName("widget-group-contents")[0].children;
let current = 0;
for (let t = 0; t < titles.length; t++) {
const title = titles[t];
title.addEventListener("click", () => {
if (t == current) {
return;
}
for (let i = 0; i < titles.length; i++) {
titles[i].classList.remove("widget-group-title-current");
tabs[i].classList.remove("widget-group-content-current");
}
if (current < t) {
tabs[t].dataset.direction = "right";
} else {
tabs[t].dataset.direction = "left";
}
current = t;
title.classList.add("widget-group-title-current");
tabs[t].classList.add("widget-group-content-current");
});
}
}
}
function setupLazyImages() {
const images = document.querySelectorAll("img[loading=lazy]");
@ -153,39 +301,317 @@ function setupLazyImages() {
image.classList.add("finished-transition");
}
for (let i = 0; i < images.length; i++) {
const image = images[i];
afterContentReady(() => {
setTimeout(() => {
for (let i = 0; i < images.length; i++) {
const image = images[i];
if (image.complete) {
image.classList.add("cached");
setTimeout(() => imageFinishedTransition(image), 5);
} else {
// TODO: also handle error event
image.addEventListener("load", () => {
image.classList.add("loaded");
setTimeout(() => imageFinishedTransition(image), 500);
});
if (image.complete) {
image.classList.add("cached");
setTimeout(() => imageFinishedTransition(image), 1);
} else {
// TODO: also handle error event
image.addEventListener("load", () => {
image.classList.add("loaded");
setTimeout(() => imageFinishedTransition(image), 400);
});
}
}
}, 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() {
const pageElement = document.getElementById("page");
const pageContents = await fetchPageContents(pageData.slug);
const pageContentElement = document.getElementById("page-content");
const pageContent = await fetchPageContent(pageData);
pageElement.innerHTML = pageContents;
pageContentElement.innerHTML = pageContent;
setTimeout(() => {
document.body.classList.add("animate-element-transition");
}, 150);
try {
setupClocks()
setupCarousels();
setupSearchBoxes();
setupCollapsibleLists();
setupCollapsibleGrids();
setupGroups();
setupDynamicRelativeTime();
setupLazyImages();
} finally {
pageElement.classList.add("content-ready");
setTimeout(setupLazyImages, 5);
setupCarousels();
setupDynamicRelativeTime();
for (let i = 0; i < contentReadyCallbacks.length; i++) {
contentReadyCallbacks[i]();
}
setTimeout(() => {
document.body.classList.add("page-columns-transitioned");
}, 300);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", setupPage);
} else {
setupPage();
}
setupPage();

View File

@ -0,0 +1,14 @@
{
"name": "Glance",
"display": "standalone",
"background_color": "#151519",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "app-icon.png",
"type": "image/png",
"sizes": "512x512"
}
]
}

View File

@ -15,6 +15,7 @@ var (
PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
PageContentTemplate = compileTemplate("content.html")
CalendarTemplate = compileTemplate("calendar.html", "widget-base.html")
ClockTemplate = compileTemplate("clock.html", "widget-base.html")
BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html")
IFrameTemplate = compileTemplate("iframe.html", "widget-base.html")
WeatherTemplate = compileTemplate("weather.html", "widget-base.html")
@ -22,16 +23,21 @@ var (
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html")
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
StocksTemplate = compileTemplate("stocks.html", "widget-base.html")
MarketsTemplate = compileTemplate("markets.html", "widget-base.html")
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html")
RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
SearchTemplate = compileTemplate("search.html", "widget-base.html")
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
GroupTemplate = compileTemplate("group.html", "widget-base.html")
)
var globalTemplateFunctions = template.FuncMap{

View 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 }}

View 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-4">
{{ 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 }}

View File

@ -5,10 +5,17 @@
<title>{{ block "document-title" . }}{{ end }}</title>
<meta charset="UTF-8">
<meta name="color-scheme" content="dark">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="/static/favicon.png" />
<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>
<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="{{ .App.AssetPath "app-icon.png" }}">
<link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
<link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
<script type="module" src="{{ .App.AssetPath "main.js" }}"></script>
{{ block "document-head-after" . }}{{ end }}
</head>
<body>

View File

@ -0,0 +1,5 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
{{ .Extension.Content }}
{{ end }}

View File

@ -1,14 +1,18 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 list-collapsible">
{{ range $i, $post := .Posts }}
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
<div class="forum-post-list-item thumbnail-container">
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Posts }}
<li>
<div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
{{ if $.ShowThumbnails }}
{{ if ne $post.ThumbnailUrl "" }}
<img class="forum-post-list-thumbnail thumbnail" src="{{ $post.ThumbnailUrl }}" alt="" loading="lazy">
{{ else if $post.HasTargetUrl }}
{{ if .IsCrosspost }}
<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="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
{{ else if ne .ThumbnailUrl "" }}
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
{{ 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)">
<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>
@ -18,14 +22,23 @@
</svg>
{{ end }}
{{ end }}
<div class="grow">
<a href="{{ $post.DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
<div class="grow min-width-0">
<a href="{{ .DiscussionUrl }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
{{ if gt (len .Tags) 0 }}
<div class="inline-block forum-post-tags-container">
<ul class="attachments">
{{ range .Tags }}
<li>{{ . }}</li>
{{ end }}
</ul>
</div>
{{ end }}
<ul class="list-horizontal-text">
<li title="{{ $post.TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs $post.TimePosted }}>{{ $post.TimePosted | relativeTime }}</li>
<li>{{ $post.Score | formatNumber }} points</li>
<li>{{ $post.CommentCount | formatNumber }} comments</li>
{{ if $post.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 {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ .Score | formatNumber }} points</li>
<li>{{ .CommentCount | formatNumber }} comments</li>
{{ if .HasTargetUrl }}
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
{{ end }}
</ul>
</div>
@ -33,7 +46,4 @@
</li>
{{ end }}
</ul>
{{ if gt (len .Posts) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }}

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 }}

View File

@ -3,36 +3,36 @@
{{ define "widget-content" }}
{{ if ne .Style "dynamic-columns-experimental" }}
<ul class="list list-gap-20 list-with-separator">
{{ range .Stocks }}
{{ range .Markets }}
<li class="flex items-center gap-15">
{{ template "stock" . }}
{{ template "market" . }}
</li>
{{ end }}
</ul>
{{ else }}
<div class="dynamic-columns">
{{ range .Stocks }}
{{ range .Markets }}
<div class="flex items-center gap-15">
{{ template "stock" . }}
{{ template "market" . }}
</div>
{{ end }}
</div>
{{ end }}
{{ end }}
{{ define "stock" }}
<div class="shrink min-width-0">
{{ define "market" }}
<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>
<div class="text-truncate">{{ .Name }}</div>
</div>
<a class="stock-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
<svg class="stock-chart shrink-0" viewBox="0 0 100 50">
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
<svg class="market-chart shrink-0" viewBox="0 0 100 50">
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
</svg>
</a>
<div class="stock-values shrink-0">
<div class="market-values shrink-0">
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
</div>

View File

@ -22,13 +22,13 @@
{{ define "site" }}
{{ if .IconUrl }}
<img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy">
<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy">
{{ end }}
<div>
<a class="size-h3 color-highlight" href="{{ .Url }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
<a class="size-h3 color-highlight" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text">
{{ if not .Status.Error }}
<li>{{ .StatusText }}</li>
<li title="{{ .Status.Code }}">{{ .StatusText }}</li>
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
{{ else if .Status.TimedOut }}
<li class="color-negative">Timed Out</li>
@ -37,7 +37,7 @@
{{ end }}
</ul>
</div>
{{ if eq .StatusStyle "good" }}
{{ if eq .StatusStyle "ok" }}
<div class="monitor-site-status-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />

View File

@ -1,16 +1,18 @@
{{ template "document.html" . }}
{{ define "document-title" }}{{ .Page.Title }} - Glance{{ end }}
{{ define "document-title" }}{{ .Page.Title }}{{ end }}
{{ define "document-head-before" }}
<script>
const pageData = {
slug: "{{ .Page.Slug }}",
baseURL: "{{ .App.Config.Server.BaseURL }}",
};
</script>
{{ 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" }}
{{ template "page-style-overrides.gotmpl" . }}
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
@ -20,50 +22,59 @@
{{ define "navigation-links" }}
{{ 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 }}
{{ define "document-body" }}
<div class="header-container content-bounds">
<div class="header flex padding-inline-widget widget-content-frame">
<!-- TODO: Replace G with actual logo, first need an actual logo -->
<div class="logo">G</div>
<div class="nav flex grow">
<div class="flex flex-column height-100">
{{ if not .Page.HideDesktopNavigation }}
<div class="header-container content-bounds">
<div class="header flex padding-inline-widget widget-content-frame">
<!-- TODO: Replace G with actual logo, first need an actual logo -->
<div class="logo">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</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" . }}
</div>
</div>
</div>
<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" . }}
</div>
</div>
<div class="content-bounds">
<div class="page" id="page">
<div class="page-loading-container">
<!-- TODO: add a bigger/better loading indicator -->
<div class="loading-icon"></div>
<div class="content-bounds grow">
<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 class="footer flex items-center flex-column">
<div>
<span class="size-h3">Glance</span> ({{ .App.Version }})
{{ if not .App.Config.Branding.HideFooter }}
<div class="footer flex items-center flex-column">
{{ if eq "" .App.Config.Branding.CustomFooter }}
<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 }}
</div>
{{ else }}
{{ .App.Config.Branding.CustomFooter }}
{{ end }}
</div>
<ul class="list-horizontal-text margin-top-5 size-h5 color-primary">
<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>
{{ end }}
<div class="mobile-navigation-offset"></div>
</div>
{{ end }}

View File

@ -20,7 +20,7 @@
{{ 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>
<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>
</ul>
</div>

View File

@ -19,7 +19,7 @@
{{ 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>
<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>
</ul>
</div>

View File

@ -1,21 +1,23 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-10 list-collapsible">
{{ range $i, $release := .Releases }}
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Releases }}
<li>
<div class="flex items-center gap-10">
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ .NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
{{ if $.ShowSourceIcon }}
<img class="simple-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
{{ end }}
</div>
<ul class="list-horizontal-text">
<li title="{{ $release.TimeReleased | formatTime }}" {{ dynamicRelativeTimeAttrs $release.TimeReleased }}>{{ $release.TimeReleased | relativeTime }}</li>
<li>{{ $release.Version }}</li>
{{ if gt $release.Downvotes 3 }}
<li>{{ $release.Downvotes | formatNumber }} ⚠</li>
<li {{ dynamicRelativeTimeAttrs .TimeReleased }}></li>
<li>{{ .Version }}</li>
{{ if gt .Downvotes 3 }}
<li>{{ .Downvotes | formatNumber }} ⚠</li>
{{ end }}
</ul>
</li>
{{ end }}
</ul>
{{ if gt (len .Releases) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }}

View File

@ -30,7 +30,7 @@
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .RepositoryDetails.PullRequests }}
<li title="{{ .CreatedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .CreatedAt }}>{{ .CreatedAt | relativeTime }}</li>
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
@ -47,7 +47,7 @@
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .RepositoryDetails.Issues }}
<li title="{{ .CreatedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .CreatedAt }}>{{ .CreatedAt | relativeTime }}</li>
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">

View File

@ -0,0 +1,40 @@
{{ 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>
{{ else }}
<li>{{ .NoItemsMessage }}</li>
{{ end }}
</ul>
{{ end }}

View File

@ -3,10 +3,11 @@
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
{{ if gt (len .Items) 0 }}
<div class="carousel-container">
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
{{ 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 }}
<img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
{{ else }}
@ -17,12 +18,15 @@
<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>
<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 min-width-0 text-truncate">{{ .ChannelName }}</li>
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
</ul>
</div>
</div>
{{ end }}
</div>
</div>
{{ else }}
<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
{{ end }}
{{ end }}

View File

@ -3,10 +3,11 @@
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
{{ if gt (len .Items) 0 }}
<div class="carousel-container">
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
{{ range .Items }}
<div class="card widget-content-frame thumbnail-container">
<div class="card widget-content-frame thumbnail-parent">
{{ if ne "" .ImageURL }}
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
{{ else }}
@ -17,12 +18,15 @@
<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>
<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 min-width-0 text-truncate">{{ .ChannelName }}</li>
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
</ul>
</div>
</div>
{{ end }}
</div>
</div>
{{ else }}
<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
{{ end }}
{{ end }}

View File

@ -1,20 +1,19 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 list-collapsible">
{{ range $i, $item := .Items }}
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Items }}
<li>
<a class="size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text">
<li title="{{ $item.PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs $item.PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
{{ if gt (len $.FeedRequests) 1 }}
<li><a href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a></li>
{{ end }}
<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>
</li>
{{ else }}
<li>{{ .NoItemsMessage }}</li>
{{ end }}
</ul>
{{ if gt (len .Items) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }}

View 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 }}" data-new-tab="{{ .NewTab }}">
<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"{{ if .Autofocus }} autofocus{{ end }}>
<div class="search-bang"></div>
<kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
</div>
{{ end }}

View File

@ -1,27 +1,31 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 list-collapsible">
{{ range $i, $channel := .Channels }}
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
<div class="{{ if $channel.IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-container">
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Channels }}
<li>
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
<div class="twitch-channel-avatar-container">
{{ if $channel.Exists }}
<img class="twitch-channel-avatar thumbnail" src="{{ $channel.AvatarUrl }}" alt="" loading="lazy">
{{ if .Exists }}
<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 }}
<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" />
</svg>
{{ end }}
</div>
<div class="shrink 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>
{{ if $channel.Exists }}
{{ if $channel.IsLive }}
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ $channel.CategorySlug }}" target="_blank" rel="noreferrer">{{ $channel.Category }}</a>
<div class="min-width-0">
<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 .IsLive }}
{{ 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">
<li title="{{ $channel.LiveSince | formatTime }}" {{ dynamicRelativeTimeAttrs $channel.LiveSince }}>{{ $channel.LiveSince | relativeTime }}</li>
<li>{{ $channel.ViewersCount | formatViewerCount }} viewers</li>
<li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
</ul>
{{ else }}
<div>Offline</div>
@ -34,7 +38,4 @@
</li>
{{ end }}
</ul>
{{ if gt (len .Channels) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }}

View File

@ -1,26 +1,25 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 list-collapsible">
{{ range $i, $category := .Categories }}
{{ $shouldCollapseItem := shouldCollapse $i $.CollapseAfter }}
<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">
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ $category.AvatarUrl }}" alt="">
<div class="shrink 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>
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Categories }}
<li class="twitch-category thumbnail-parent">
<div class="flex gap-10 items-start">
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ .AvatarUrl }}" alt="">
<div class="min-width-0">
<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">
<li>{{ $category.ViewersCount | formatViewerCount }} viewers</li>
{{ if $category.IsNew }}
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
{{ if .IsNew }}
<li class="color-primary">NEW</li>
{{ end }}
</ul>
<ul class="list-horizontal-text flex-nowrap">
{{ range $i, $tag := $category.Tags }}
{{ range $i, $tag := .Tags }}
{{ if eq $i 0 }}
<li class="shrink-0">{{ $tag.Name }}</li>
{{ else }}
<li class="text-truncate shrink min-width-0">{{ $tag.Name }}</li>
<li class="text-truncate min-width-0">{{ $tag.Name }}</li>
{{ end }}
{{ end }}
</ul>
@ -29,7 +28,4 @@
</li>
{{ end }}
</ul>
{{ if gt (len .Categories) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }}

View File

@ -3,8 +3,8 @@
<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>
<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 min-width-0">
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li class="min-width-0">
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
</li>
</ul>

View File

@ -3,9 +3,9 @@
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="cards-grid">
<div class="cards-grid collapsible-container" data-collapse-after-rows="{{ .CollapseAfterRows }}">
{{ range .Videos }}
<div class="card widget-content-frame thumbnail-container">
<div class="card widget-content-frame thumbnail-parent">
{{ template "video-card-contents" . }}
</div>
{{ end }}

View File

@ -6,7 +6,7 @@
<div class="carousel-container">
<div class="cards-horizontal carousel-items-container">
{{ range .Videos }}
<div class="card widget-content-frame thumbnail-container">
<div class="card widget-content-frame thumbnail-parent">
{{ template "video-card-contents" . }}
</div>
{{ end }}

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="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 }}
<div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
{{ else if .Notice }}
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
{{ end }}
</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 }}
{{ block "widget-content" . }}{{ end }}
{{ else }}

View 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
}

102
internal/feed/dockerhub.go Normal file
View File

@ -0,0 +1,102 @@
package feed
import (
"fmt"
"net/http"
"strings"
)
type dockerHubRepositoryTagsResponse struct {
Results []dockerHubRepositoryTagResponse `json:"results"`
}
type dockerHubRepositoryTagResponse struct {
Name string `json:"name"`
LastPushed string `json:"tag_last_pushed"`
}
const dockerHubOfficialRepoTagURLFormat = "https://hub.docker.com/_/%s/tags?name=%s"
const dockerHubRepoTagURLFormat = "https://hub.docker.com/r/%s/tags?name=%s"
const dockerHubTagsURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags"
const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags/%s"
func fetchLatestDockerHubRelease(request *ReleaseRequest) (*AppRelease, error) {
nameParts := strings.Split(request.Repository, "/")
if len(nameParts) > 2 {
return nil, fmt.Errorf("invalid repository name: %s", request.Repository)
} else if len(nameParts) == 1 {
nameParts = []string{"library", nameParts[0]}
}
tagParts := strings.SplitN(nameParts[1], ":", 2)
var requestURL string
if len(tagParts) == 2 {
requestURL = fmt.Sprintf(dockerHubSpecificTagURLFormat, nameParts[0], tagParts[0], tagParts[1])
} else {
requestURL = fmt.Sprintf(dockerHubTagsURLFormat, nameParts[0], nameParts[1])
}
httpRequest, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
if request.Token != nil {
httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
}
var tag *dockerHubRepositoryTagResponse
if len(tagParts) == 1 {
response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultClient, httpRequest)
if err != nil {
return nil, err
}
if len(response.Results) == 0 {
return nil, fmt.Errorf("no tags found for repository: %s", request.Repository)
}
tag = &response.Results[0]
} else {
response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultClient, httpRequest)
if err != nil {
return nil, err
}
tag = &response
}
var repo string
var displayName string
var notesURL string
if len(tagParts) == 1 {
repo = nameParts[1]
} else {
repo = tagParts[0]
}
if nameParts[0] == "library" {
displayName = repo
notesURL = fmt.Sprintf(dockerHubOfficialRepoTagURLFormat, repo, tag.Name)
} else {
displayName = nameParts[0] + "/" + repo
notesURL = fmt.Sprintf(dockerHubRepoTagURLFormat, displayName, tag.Name)
}
return &AppRelease{
Source: ReleaseSourceDockerHub,
NotesUrl: notesURL,
Name: displayName,
Version: tag.Name,
TimeReleased: parseRFC3339Time(tag.LastPushed),
}, nil
}

View File

@ -0,0 +1,97 @@
package feed
import (
"fmt"
"html"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
)
type ExtensionType int
const (
ExtensionContentHTML ExtensionType = iota
ExtensionContentUnknown = iota
)
var ExtensionStringToType = map[string]ExtensionType{
"html": ExtensionContentHTML,
}
const (
ExtensionHeaderTitle = "Widget-Title"
ExtensionHeaderContentType = "Widget-Content-Type"
)
type ExtensionRequestOptions struct {
URL string `yaml:"url"`
Parameters map[string]string `yaml:"parameters"`
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
}
type Extension struct {
Title string
Content template.HTML
}
func convertExtensionContent(options ExtensionRequestOptions, content []byte, contentType ExtensionType) template.HTML {
switch contentType {
case ExtensionContentHTML:
if options.AllowHtml {
return template.HTML(content)
}
fallthrough
default:
return template.HTML(html.EscapeString(string(content)))
}
}
func FetchExtension(options ExtensionRequestOptions) (Extension, error) {
request, _ := http.NewRequest("GET", options.URL, nil)
query := url.Values{}
for key, value := range options.Parameters {
query.Set(key, value)
}
request.URL.RawQuery = query.Encode()
response, err := http.DefaultClient.Do(request)
if err != nil {
slog.Error("failed fetching extension", "error", err, "url", options.URL)
return Extension{}, fmt.Errorf("%w: request failed: %w", ErrNoContent, err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
slog.Error("failed reading response body of extension", "error", err, "url", options.URL)
return Extension{}, fmt.Errorf("%w: could not read body: %w", ErrNoContent, err)
}
extension := Extension{}
if response.Header.Get(ExtensionHeaderTitle) == "" {
extension.Title = "Extension"
} else {
extension.Title = response.Header.Get(ExtensionHeaderTitle)
}
contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)]
if !ok {
contentType = ExtensionContentUnknown
}
extension.Content = convertExtensionContent(options, body, contentType)
return extension, nil
}

View File

@ -2,118 +2,57 @@ package feed
import (
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
)
type githubReleaseResponseJson struct {
type githubReleaseLatestResponseJson struct {
TagName string `json:"tag_name"`
PublishedAt string `json:"published_at"`
HtmlUrl string `json:"html_url"`
Draft bool `json:"draft"`
PreRelease bool `json:"prerelease"`
Reactions struct {
Downvotes int `json:"-1"`
} `json:"reactions"`
}
func parseGithubTime(t string) time.Time {
parsedTime, err := time.Parse("2006-01-02T15:04:05Z", t)
if err != nil {
return time.Now()
}
return parsedTime
}
func fetchLatestGithubRelease(request *ReleaseRequest) (*AppRelease, error) {
httpRequest, err := http.NewRequest(
"GET",
fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository),
nil,
)
func FetchLatestReleasesFromGithub(repositories []string, token string) (AppReleases, error) {
appReleases := make(AppReleases, 0, len(repositories))
if len(repositories) == 0 {
return appReleases, nil
}
requests := make([]*http.Request, len(repositories))
for i, repository := range repositories {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=10", repository), nil)
if token != "" {
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
}
requests[i] = request
}
task := decodeJsonFromRequestTask[[]githubReleaseResponseJson](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 github release", "error", errs[i], "url", requests[i].URL)
continue
}
releases := responses[i]
if len(releases) < 1 {
failed++
slog.Error("No releases found", "repository", repositories[i], "url", requests[i].URL)
continue
}
var liveRelease *githubReleaseResponseJson
for i := range releases {
release := &releases[i]
if !release.Draft && !release.PreRelease {
liveRelease = release
break
}
}
if liveRelease == nil {
slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
continue
}
version := liveRelease.TagName
if version[0] != 'v' {
version = "v" + version
}
appReleases = append(appReleases, AppRelease{
Name: repositories[i],
Version: version,
NotesUrl: liveRelease.HtmlUrl,
TimeReleased: parseGithubTime(liveRelease.PublishedAt),
Downvotes: liveRelease.Reactions.Downvotes,
})
if request.Token != nil {
httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
}
if len(appReleases) == 0 {
return nil, ErrNoContent
response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest)
if err != nil {
return nil, err
}
appReleases.SortByNewest()
version := response.TagName
if failed > 0 {
return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
if len(version) > 0 && version[0] != 'v' {
version = "v" + version
}
return appReleases, nil
return &AppRelease{
Source: ReleaseSourceGithub,
Name: request.Repository,
Version: version,
NotesUrl: response.HtmlUrl,
TimeReleased: parseRFC3339Time(response.PublishedAt),
Downvotes: response.Reactions.Downvotes,
}, nil
}
type GithubTicket struct {
@ -256,7 +195,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
for i := range PRsResponse.Tickets {
details.PullRequests = append(details.PullRequests, GithubTicket{
Number: PRsResponse.Tickets[i].Number,
CreatedAt: parseGithubTime(PRsResponse.Tickets[i].CreatedAt),
CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
Title: PRsResponse.Tickets[i].Title,
})
}
@ -273,7 +212,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
for i := range issuesResponse.Tickets {
details.Issues = append(details.Issues, GithubTicket{
Number: issuesResponse.Tickets[i].Number,
CreatedAt: parseGithubTime(issuesResponse.Tickets[i].CreatedAt),
CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
Title: issuesResponse.Tickets[i].Title,
})
}

54
internal/feed/gitlab.go Normal file
View File

@ -0,0 +1,54 @@
package feed
import (
"fmt"
"net/http"
"net/url"
)
type gitlabReleaseResponseJson struct {
TagName string `json:"tag_name"`
ReleasedAt string `json:"released_at"`
Links struct {
Self string `json:"self"`
} `json:"_links"`
}
func fetchLatestGitLabRelease(request *ReleaseRequest) (*AppRelease, error) {
httpRequest, err := http.NewRequest(
"GET",
fmt.Sprintf(
"https://gitlab.com/api/v4/projects/%s/releases/permalink/latest",
url.QueryEscape(request.Repository),
),
nil,
)
if err != nil {
return nil, err
}
if request.Token != nil {
httpRequest.Header.Add("PRIVATE-TOKEN", *request.Token)
}
response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultClient, httpRequest)
if err != nil {
return nil, err
}
version := response.TagName
if len(version) > 0 && version[0] != 'v' {
version = "v" + version
}
return &AppRelease{
Source: ReleaseSourceGitlab,
Name: request.Repository,
Version: version,
NotesUrl: response.Links.Self,
TimeReleased: parseRFC3339Time(response.ReleasedAt),
}, nil
}

91
internal/feed/lobsters.go Normal file
View File

@ -0,0 +1,91 @@
package feed
import (
"net/http"
"strings"
"time"
)
type lobstersPostResponseJson struct {
CreatedAt string `json:"created_at"`
Title string `json:"title"`
URL string `json:"url"`
Score int `json:"score"`
CommentCount int `json:"comment_count"`
CommentsURL string `json:"comments_url"`
Tags []string `json:"tags"`
}
type lobstersFeedResponseJson []lobstersPostResponseJson
func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) {
request, err := http.NewRequest("GET", feedUrl, nil)
if err != nil {
return nil, err
}
feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request)
if err != nil {
return nil, err
}
posts := make(ForumPosts, 0, len(feed))
for i := range feed {
createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt)
posts = append(posts, ForumPost{
Title: feed[i].Title,
DiscussionUrl: feed[i].CommentsURL,
TargetUrl: feed[i].URL,
TargetUrlDomain: extractDomainFromUrl(feed[i].URL),
CommentCount: feed[i].CommentCount,
Score: feed[i].Score,
TimePosted: createdAt,
Tags: feed[i].Tags,
})
}
if len(posts) == 0 {
return nil, ErrNoContent
}
return posts, nil
}
func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) {
var feedUrl string
if customURL != "" {
feedUrl = customURL
} else {
if instanceURL != "" {
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)
if err != nil {
return nil, err
}
return posts, nil
}

View File

@ -7,6 +7,12 @@ import (
"time"
)
type SiteStatusRequest struct {
URL string `yaml:"url"`
CheckURL string `yaml:"check-url"`
AllowInsecure bool `yaml:"allow-insecure"`
}
type SiteStatus struct {
Code int
TimedOut bool
@ -14,14 +20,34 @@ type SiteStatus struct {
Error error
}
func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
var url string
if statusRequest.CheckURL != "" {
url = statusRequest.CheckURL
} else {
url = statusRequest.URL
}
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return SiteStatus{
Error: err,
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel()
request = request.WithContext(ctx)
start := time.Now()
response, err := http.DefaultClient.Do(request)
took := time.Since(start)
status := SiteStatus{ResponseTime: took}
requestSentAt := time.Now()
var response *http.Response
if !statusRequest.AllowInsecure {
response, err = defaultClient.Do(request)
} else {
response, err = defaultInsecureClient.Do(request)
}
status := SiteStatus{ResponseTime: time.Since(requestSentAt)}
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
@ -29,7 +55,7 @@ func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
}
status.Error = err
return status, err
return status, nil
}
defer response.Body.Close()
@ -39,7 +65,7 @@ func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
return status, nil
}
func FetchStatusesForRequests(requests []*http.Request) ([]SiteStatus, error) {
func FetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) {
job := newJob(getSiteStatusTask, requests).withWorkers(20)
results, _, err := workerPoolDo(job)

View File

@ -16,6 +16,8 @@ type ForumPost struct {
Score int
Engagement float64
TimePosted time.Time
Tags []string
IsCrosspost bool
}
type ForumPosts []ForumPost
@ -39,11 +41,13 @@ type Weather struct {
}
type AppRelease struct {
Name string
Version string
NotesUrl string
TimeReleased time.Time
Downvotes int
Source ReleaseSource
SourceIconURL string
Name string
Version string
NotesUrl string
TimeReleased time.Time
Downvotes int
}
type AppReleases []AppRelease
@ -84,20 +88,24 @@ var currencyToSymbol = map[string]string{
"PHP": "₱",
}
type Stock struct {
Name string `yaml:"name"`
Symbol string `yaml:"symbol"`
ChartLink string `yaml:"chart-link"`
SymbolLink string `yaml:"symbol-link"`
type MarketRequest struct {
Name string `yaml:"name"`
Symbol string `yaml:"symbol"`
ChartLink string `yaml:"chart-link"`
SymbolLink string `yaml:"symbol-link"`
}
type Market struct {
MarketRequest
Currency string `yaml:"-"`
Price float64 `yaml:"-"`
PercentChange float64 `yaml:"-"`
SvgChartPoints string `yaml:"-"`
}
type Stocks []Stock
type Markets []Market
func (t Stocks) SortByAbsChange() {
func (t Markets) SortByAbsChange() {
sort.Slice(t, func(i, j int) bool {
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
})

View File

@ -25,12 +25,26 @@ type subredditResponseJson struct {
Pinned bool `json:"pinned"`
IsSelf bool `json:"is_self"`
Thumbnail string `json:"thumbnail"`
Flair string `json:"link_flair_text"`
ParentList []struct {
Id string `json:"id"`
Subreddit string `json:"subreddit"`
Permalink string `json:"permalink"`
} `json:"crosspost_parent_list"`
} `json:"data"`
} `json:"children"`
} `json:"data"`
}
func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string) (ForumPosts, error) {
func templateRedditCommentsURL(template, subreddit, postId, postPath string) string {
template = strings.ReplaceAll(template, "{SUBREDDIT}", subreddit)
template = strings.ReplaceAll(template, "{POST-ID}", postId)
template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/"))
return template
}
func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (ForumPosts, error) {
query := url.Values{}
var requestUrl string
@ -85,9 +99,7 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
if commentsUrlTemplate == "" {
commentsUrl = "https://www.reddit.com" + post.Permalink
} else {
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{SUBREDDIT}", subreddit)
commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-ID}", post.Id)
commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-PATH}", strings.TrimLeft(post.Permalink, "/"))
commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
}
forumPost := ForumPost{
@ -107,6 +119,26 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
forumPost.TargetUrl = post.Url
}
if showFlairs && post.Flair != "" {
forumPost.Tags = append(forumPost.Tags, post.Flair)
}
if len(post.ParentList) > 0 {
forumPost.IsCrosspost = true
forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit
if commentsUrlTemplate == "" {
forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink
} else {
forumPost.TargetUrl = templateRedditCommentsURL(
commentsUrlTemplate,
post.ParentList[0].Subreddit,
post.ParentList[0].Id,
post.ParentList[0].Permalink,
)
}
}
posts = append(posts, forumPost)
}

69
internal/feed/releases.go Normal file
View File

@ -0,0 +1,69 @@
package feed
import (
"errors"
"fmt"
"log/slog"
)
type ReleaseSource string
const (
ReleaseSourceGithub ReleaseSource = "github"
ReleaseSourceGitlab ReleaseSource = "gitlab"
ReleaseSourceDockerHub ReleaseSource = "dockerhub"
)
type ReleaseRequest struct {
Source ReleaseSource
Repository string
Token *string
}
func FetchLatestReleases(requests []*ReleaseRequest) (AppReleases, error) {
job := newJob(fetchLatestReleaseTask, requests).withWorkers(20)
results, errs, err := workerPoolDo(job)
if err != nil {
return nil, err
}
var failed int
releases := make(AppReleases, 0, len(requests))
for i := range results {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch release", "source", requests[i].Source, "repository", requests[i].Repository, "error", errs[i])
continue
}
releases = append(releases, *results[i])
}
if failed == len(requests) {
return nil, ErrNoContent
}
releases.SortByNewest()
if failed > 0 {
return releases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
}
return releases, nil
}
func fetchLatestReleaseTask(request *ReleaseRequest) (*AppRelease, error) {
switch request.Source {
case ReleaseSourceGithub:
return fetchLatestGithubRelease(request)
case ReleaseSourceGitlab:
return fetchLatestGitLabRelease(request)
case ReleaseSourceDockerHub:
return fetchLatestDockerHubRelease(request)
}
return nil, errors.New("unsupported source")
}

View File

@ -2,6 +2,7 @@ package feed
import (
"context"
"crypto/tls"
"encoding/json"
"encoding/xml"
"fmt"
@ -11,8 +12,19 @@ import (
"time"
)
const defaultClientTimeout = 5 * time.Second
var defaultClient = &http.Client{
Timeout: 5 * time.Second,
Timeout: defaultClientTimeout,
}
var insecureClientTransport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
var defaultInsecureClient = &http.Client{
Timeout: defaultClientTimeout,
Transport: insecureClientTransport,
}
type RequestDoer interface {

View File

@ -3,11 +3,16 @@ package feed
import (
"context"
"fmt"
"html"
"log/slog"
"net/url"
"regexp"
"sort"
"strings"
"time"
"github.com/mmcdole/gofeed"
gofeedext "github.com/mmcdole/gofeed/extensions"
)
type RSSFeedItem struct {
@ -16,12 +21,48 @@ type RSSFeedItem struct {
Title string
Link string
ImageURL string
Categories []string
Description string
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
}
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 {
Url string `yaml:"url"`
Title string `yaml:"title"`
Url string `yaml:"url"`
Title string `yaml:"title"`
HideCategories bool `yaml:"hide-categories"`
HideDescription bool `yaml:"hide-description"`
ItemLinkPrefix string `yaml:"item-link-prefix"`
IsDetailed bool `yaml:"-"`
}
type RSSFeedItems []RSSFeedItem
@ -53,8 +94,60 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
rssItem := RSSFeedItem{
ChannelURL: feed.Link,
Title: item.Title,
Link: item.Link,
}
if request.ItemLinkPrefix != "" {
rssItem.Link = request.ItemLinkPrefix + item.Link
} else if strings.HasPrefix(item.Link, "http://") || strings.HasPrefix(item.Link, "https://") {
rssItem.Link = item.Link
} else {
parsedUrl, err := url.Parse(feed.Link)
if err != nil {
parsedUrl, err = url.Parse(request.Url)
}
if err == nil {
var link string
if item.Link[0] == '/' {
link = item.Link
} else {
link = "/" + item.Link
}
rssItem.Link = parsedUrl.Scheme + "://" + parsedUrl.Host + link
}
}
if item.Title != "" {
rssItem.Title = item.Title
} else {
rssItem.Title = shortenFeedDescriptionLen(item.Description, 100)
}
if request.IsDetailed {
if !request.HideDescription && item.Description != "" && item.Title != "" {
rssItem.Description = shortenFeedDescriptionLen(item.Description, 200)
}
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 != "" {
@ -65,8 +158,14 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
if item.Image != nil {
rssItem.ImageURL = item.Image.URL
} else if url := findThumbnailInItemExtensions(item); url != "" {
rssItem.ImageURL = url
} else if feed.Image != nil {
rssItem.ImageURL = feed.Image.URL
if len(feed.Image.URL) > 0 && feed.Image.URL[0] == '/' {
rssItem.ImageURL = strings.TrimRight(feed.Link, "/") + feed.Image.URL
} else {
rssItem.ImageURL = feed.Image.URL
}
}
if item.PublishedParsed != nil {
@ -81,6 +180,36 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
return items, nil
}
func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extension) string {
for _, exts := range extensions {
for _, ext := range exts {
if ext.Name == "thumbnail" || ext.Name == "image" {
if url, ok := ext.Attrs["url"]; ok {
return url
}
}
if ext.Children != nil {
if url := recursiveFindThumbnailInExtensions(ext.Children); url != "" {
return url
}
}
}
}
return ""
}
func findThumbnailInItemExtensions(item *gofeed.Item) string {
media, ok := item.Extensions["media"]
if !ok {
return ""
}
return recursiveFindThumbnailInExtensions(media)
}
func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10)
feeds, errs, err := workerPoolDo(job)
@ -103,7 +232,7 @@ func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
entries = append(entries, feeds[i]...)
}
if len(entries) == 0 {
if failed == len(requests) {
return nil, ErrNoContent
}

View File

@ -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 {
Data json.RawMessage
Extensions struct {
@ -198,9 +204,11 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
result.IsLive = true
result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil && streamMetadata.UserOrNull.Stream.Game != nil {
result.Category = streamMetadata.UserOrNull.Stream.Game.Name
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil {
if streamMetadata.UserOrNull.Stream.Game != nil {
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)
if err == nil {

View File

@ -4,8 +4,10 @@ import (
"errors"
"fmt"
"net/url"
"regexp"
"slices"
"strings"
"time"
)
var (
@ -77,3 +79,29 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
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
}
func parseRFC3339Time(t string) time.Time {
parsed, err := time.Parse(time.RFC3339, t)
if err != nil {
return time.Now()
}
return parsed
}

View File

@ -6,7 +6,7 @@ import (
"net/http"
)
type stockResponseJson struct {
type marketResponseJson struct {
Chart struct {
Result []struct {
Meta struct {
@ -25,30 +25,30 @@ type stockResponseJson struct {
}
// TODO: allow changing chart time frame
const stockChartDays = 21
const marketChartDays = 21
func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
requests := make([]*http.Request, 0, len(stockRequests))
func FetchMarketsDataFromYahoo(marketRequests []MarketRequest) (Markets, error) {
requests := make([]*http.Request, 0, len(marketRequests))
for i := range stockRequests {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", stockRequests[i].Symbol), nil)
for i := range marketRequests {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil)
requests = append(requests, request)
}
job := newJob(decodeJsonFromRequestTask[stockResponseJson](defaultClient), requests)
job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests)
responses, errs, err := workerPoolDo(job)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
}
stocks := make(Stocks, 0, len(responses))
markets := make(Markets, 0, len(responses))
var failed int
for i := range responses {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch stock data", "symbol", stockRequests[i].Symbol, "error", errs[i])
slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i])
continue
}
@ -56,14 +56,14 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
if len(response.Chart.Result) == 0 {
failed++
slog.Error("Stock response contains no data", "symbol", stockRequests[i].Symbol)
slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
continue
}
prices := response.Chart.Result[0].Indicators.Quote[0].Close
if len(prices) > stockChartDays {
prices = prices[len(prices)-stockChartDays:]
if len(prices) > marketChartDays {
prices = prices[len(prices)-marketChartDays:]
}
previous := response.Chart.Result[0].Meta.RegularMarketPrice
@ -80,13 +80,10 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
currency = response.Chart.Result[0].Meta.Currency
}
stocks = append(stocks, Stock{
Name: stockRequests[i].Name,
Symbol: response.Chart.Result[0].Meta.Symbol,
SymbolLink: stockRequests[i].SymbolLink,
ChartLink: stockRequests[i].ChartLink,
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
Currency: currency,
markets = append(markets, Market{
MarketRequest: marketRequests[i],
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
Currency: currency,
PercentChange: percentChange(
response.Chart.Result[0].Meta.RegularMarketPrice,
previous,
@ -95,13 +92,13 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
})
}
if len(stocks) == 0 {
if len(markets) == 0 {
return nil, ErrNoContent
}
if failed > 0 {
return stocks, fmt.Errorf("%w: could not fetch data for %d stock(s)", ErrPartialContent, failed)
return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", ErrPartialContent, failed)
}
return stocks, nil
return markets, nil
}

View File

@ -10,7 +10,7 @@ import (
)
type youtubeFeedResponseXml struct {
Channel string `xml:"title"`
Channel string `xml:"author>name"`
ChannelLink struct {
Href string `xml:"href,attr"`
} `xml:"link"`
@ -39,11 +39,19 @@ func parseYoutubeFeedTime(t string) time.Time {
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))
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)
}
@ -70,12 +78,6 @@ func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (V
for j := range response.Videos {
video := &response.Videos[j]
// TODO: figure out a better way of skipping shorts
if strings.Contains(video.Title, "#shorts") {
continue
}
var videoUrl string
if videoUrlTemplate == "" {

View File

@ -8,9 +8,10 @@ import (
)
type Config struct {
Server Server `yaml:"server"`
Theme Theme `yaml:"theme"`
Pages []Page `yaml:"pages"`
Server Server `yaml:"server"`
Theme Theme `yaml:"theme"`
Branding Branding `yaml:"branding"`
Pages []Page `yaml:"pages"`
}
func NewConfigFromYml(contents io.Reader) (*Config, error) {
@ -32,6 +33,16 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
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
}
@ -50,12 +61,22 @@ func configIsValid(config *Config) error {
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 {
return fmt.Errorf("Page %d has no columns", i+1)
}
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))
if config.Pages[i].Width == "slim" {
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)

View File

@ -4,10 +4,12 @@ import (
"bytes"
"context"
"fmt"
"html/template"
"log/slog"
"net/http"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
@ -24,6 +26,7 @@ type Application struct {
Version string
Config Config
slugToPage map[string]*Page
widgetByID map[uint64]widget.Widget
}
type Theme struct {
@ -41,7 +44,17 @@ type Server struct {
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
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 Branding struct {
HideFooter bool `yaml:"hide-footer"`
CustomFooter template.HTML `yaml:"custom-footer"`
LogoText string `yaml:"logo-text"`
LogoURL string `yaml:"logo-url"`
FaviconURL string `yaml:"favicon-url"`
}
type Column struct {
@ -55,11 +68,13 @@ type templateData struct {
}
type Page struct {
Title string `yaml:"name"`
Slug string `yaml:"slug"`
ShowMobileHeader bool `yaml:"show-mobile-header"`
Columns []Column `yaml:"columns"`
mu sync.Mutex
Title string `yaml:"name"`
Slug string `yaml:"slug"`
Width string `yaml:"width"`
ShowMobileHeader bool `yaml:"show-mobile-header"`
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
Columns []Column `yaml:"columns"`
mu sync.Mutex
}
func (p *Page) UpdateOutdatedWidgets() {
@ -96,6 +111,14 @@ func titleToSlug(s string) string {
return s
}
func (a *Application) TransformUserDefinedAssetPath(path string) string {
if strings.HasPrefix(path, "/assets/") {
return a.Config.Server.BaseURL + path
}
return path
}
func NewApplication(config *Config) (*Application, error) {
if len(config.Pages) == 0 {
return nil, fmt.Errorf("no pages configured")
@ -105,18 +128,46 @@ func NewApplication(config *Config) (*Application, error) {
Version: buildVersion,
Config: *config,
slugToPage: make(map[string]*Page),
widgetByID: make(map[uint64]widget.Widget),
}
app.Config.Server.AssetsHash = assets.PublicFSHash
app.slugToPage[""] = &config.Pages[0]
for i := range config.Pages {
if config.Pages[i].Slug == "" {
config.Pages[i].Slug = titleToSlug(config.Pages[i].Title)
providers := &widget.Providers{
AssetResolver: app.AssetPath,
}
for p := range config.Pages {
if config.Pages[p].Slug == "" {
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
widget.SetProviders(providers)
}
}
}
config = &app.Config
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
config.Theme.CustomCSSFile = app.TransformUserDefinedAssetPath(config.Theme.CustomCSSFile)
if config.Branding.FaviconURL == "" {
config.Branding.FaviconURL = app.AssetPath("favicon.png")
} else {
config.Branding.FaviconURL = app.TransformUserDefinedAssetPath(config.Branding.FaviconURL)
}
config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL)
return app, nil
}
@ -189,6 +240,30 @@ 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 {
// TODO: add gzip support, static files must have their gzipped contents cached
// TODO: add HTTPS support
@ -196,8 +271,14 @@ func (a *Application) Serve() error {
mux.HandleFunc("GET /{$}", a.HandlePageRequest)
mux.HandleFunc("GET /{page}", a.HandlePageRequest)
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), 24*time.Hour)),
)
if a.Config.Server.AssetsPath != "" {
absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
@ -217,7 +298,7 @@ func (a *Application) Serve() error {
}
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()
}

View File

@ -36,7 +36,7 @@ func Main() int {
return 1
}
if app.Serve() != nil {
if err := app.Serve(); err != nil {
fmt.Printf("http server error: %v\n", err)
return 1
}

View File

@ -2,7 +2,6 @@ package widget
import (
"html/template"
"strings"
"github.com/glanceapp/glance/internal/assets"
)
@ -34,11 +33,8 @@ func (widget *Bookmarks) Initialize() error {
continue
}
if strings.HasPrefix(widget.Groups[g].Links[l].Icon, "si:") {
icon := strings.TrimPrefix(widget.Groups[g].Links[l].Icon, "si:")
widget.Groups[g].Links[l].IsSimpleIcon = true
widget.Groups[g].Links[l].Icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
}
link := &widget.Groups[g].Links[l]
link.Icon, link.IsSimpleIcon = toSimpleIconIfPrefixed(link.Icon)
}
}

View 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
View 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
}

View File

@ -0,0 +1,59 @@
package widget
import (
"context"
"errors"
"html/template"
"net/url"
"time"
"github.com/glanceapp/glance/internal/assets"
"github.com/glanceapp/glance/internal/feed"
)
type Extension struct {
widgetBase `yaml:",inline"`
URL string `yaml:"url"`
Parameters map[string]string `yaml:"parameters"`
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
Extension feed.Extension `yaml:"-"`
cachedHTML template.HTML `yaml:"-"`
}
func (widget *Extension) Initialize() error {
widget.withTitle("Extension").withCacheDuration(time.Minute * 30)
if widget.URL == "" {
return errors.New("no extension URL specified")
}
_, err := url.Parse(widget.URL)
if err != nil {
return err
}
return nil
}
func (widget *Extension) Update(ctx context.Context) {
extension, err := feed.FetchExtension(feed.ExtensionRequestOptions{
URL: widget.URL,
Parameters: widget.Parameters,
AllowHtml: widget.AllowHtml,
})
widget.canContinueUpdateAfterHandlingErr(err)
widget.Extension = extension
if extension.Title != "" {
widget.Title = extension.Title
}
widget.cachedHTML = widget.render(widget, assets.ExtensionTemplate)
}
func (widget *Extension) Render() template.HTML {
return widget.cachedHTML
}

View File

@ -6,6 +6,7 @@ import (
"os"
"regexp"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
@ -150,3 +151,18 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
return nil
}
func (f *OptionalEnvString) String() string {
return string(*f)
}
func toSimpleIconIfPrefixed(icon string) (string, bool) {
if !strings.HasPrefix(icon, "si:") {
return icon, false
}
icon = strings.TrimPrefix(icon, "si:")
icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
return icon, true
}

76
internal/widget/group.go Normal file
View File

@ -0,0 +1,76 @@
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) SetProviders(providers *Providers) {
for i := range widget.Widgets {
widget.Widgets[i].SetProviders(providers)
}
}
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)
}

View File

@ -21,7 +21,10 @@ type HackerNews struct {
}
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 {
widget.Limit = 15

20
internal/widget/html.go Normal file
View File

@ -0,0 +1,20 @@
package widget
import (
"html/template"
)
type HTML struct {
widgetBase `yaml:",inline"`
Source template.HTML `yaml:"source"`
}
func (widget *HTML) Initialize() error {
widget.withTitle("").withError(nil)
return nil
}
func (widget *HTML) Render() template.HTML {
return widget.Source
}

View File

@ -0,0 +1,64 @@
package widget
import (
"context"
"html/template"
"time"
"github.com/glanceapp/glance/internal/assets"
"github.com/glanceapp/glance/internal/feed"
)
type Lobsters struct {
widgetBase `yaml:",inline"`
Posts feed.ForumPosts `yaml:"-"`
InstanceURL string `yaml:"instance-url"`
CustomURL string `yaml:"custom-url"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
SortBy string `yaml:"sort-by"`
Tags []string `yaml:"tags"`
ShowThumbnails bool `yaml:"-"`
}
func (widget *Lobsters) Initialize() error {
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") {
widget.SortBy = "hot"
}
if widget.Limit <= 0 {
widget.Limit = 15
}
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 5
}
return nil
}
func (widget *Lobsters) Update(ctx context.Context) {
posts, err := feed.FetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if widget.Limit < len(posts) {
posts = posts[:widget.Limit]
}
widget.Posts = posts
}
func (widget *Lobsters) Render() template.HTML {
return widget.render(widget, assets.ForumPostsTemplate)
}

View File

@ -2,9 +2,7 @@ package widget
import (
"context"
"fmt"
"html/template"
"net/http"
"strconv"
"time"
@ -37,22 +35,23 @@ func statusCodeToText(status int) string {
func statusCodeToStyle(status int) string {
if status == 200 {
return "good"
return "ok"
}
return "bad"
return "error"
}
type Monitor struct {
widgetBase `yaml:",inline"`
Sites []struct {
Title string `yaml:"title"`
Url OptionalEnvString `yaml:"url"`
IconUrl string `yaml:"icon"`
SameTab bool `yaml:"same-tab"`
Status *feed.SiteStatus `yaml:"-"`
StatusText string `yaml:"-"`
StatusStyle string `yaml:"-"`
*feed.SiteStatusRequest `yaml:",inline"`
Status *feed.SiteStatus `yaml:"-"`
Title string `yaml:"title"`
IconUrl string `yaml:"icon"`
IsSimpleIcon bool `yaml:"-"`
SameTab bool `yaml:"same-tab"`
StatusText string `yaml:"-"`
StatusStyle string `yaml:"-"`
} `yaml:"sites"`
Style string `yaml:"style"`
}
@ -60,25 +59,21 @@ type Monitor struct {
func (widget *Monitor) Initialize() error {
widget.withTitle("Monitor").withCacheDuration(5 * time.Minute)
for i := range widget.Sites {
widget.Sites[i].IconUrl, widget.Sites[i].IsSimpleIcon = toSimpleIconIfPrefixed(widget.Sites[i].IconUrl)
}
return nil
}
func (widget *Monitor) Update(ctx context.Context) {
requests := make([]*http.Request, len(widget.Sites))
requests := make([]*feed.SiteStatusRequest, len(widget.Sites))
for i := range widget.Sites {
request, err := http.NewRequest("GET", string(widget.Sites[i].Url), nil)
if err != nil {
message := fmt.Errorf("failed to create http request for %s: %s", widget.Sites[i].Url, err)
widget.withNotice(message)
continue
}
requests[i] = request
requests[i] = widget.Sites[i].SiteStatusRequest
}
statuses, err := feed.FetchStatusesForRequests(requests)
statuses, err := feed.FetchStatusForSites(requests)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return

View File

@ -17,6 +17,7 @@ type Reddit struct {
Subreddit string `yaml:"subreddit"`
Style string `yaml:"style"`
ShowThumbnails bool `yaml:"show-thumbnails"`
ShowFlairs bool `yaml:"show-flairs"`
SortBy string `yaml:"sort-by"`
TopPeriod string `yaml:"top-period"`
Search string `yaml:"search"`
@ -54,7 +55,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
}
@ -84,6 +88,7 @@ func (widget *Reddit) Update(ctx context.Context) {
widget.Search,
widget.CommentsUrlTemplate,
widget.RequestUrlTemplate,
widget.ShowFlairs,
)
if !widget.canContinueUpdateAfterHandlingErr(err) {

View File

@ -2,7 +2,9 @@ package widget
import (
"context"
"errors"
"html/template"
"strings"
"time"
"github.com/glanceapp/glance/internal/assets"
@ -10,12 +12,15 @@ import (
)
type Releases struct {
widgetBase `yaml:",inline"`
Releases feed.AppReleases `yaml:"-"`
Repositories []string `yaml:"repositories"`
Token OptionalEnvString `yaml:"token"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
widgetBase `yaml:",inline"`
Releases feed.AppReleases `yaml:"-"`
releaseRequests []*feed.ReleaseRequest `yaml:"-"`
Repositories []string `yaml:"repositories"`
Token OptionalEnvString `yaml:"token"`
GitLabToken OptionalEnvString `yaml:"gitlab-token"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
ShowSourceIcon bool `yaml:"show-source-icon"`
}
func (widget *Releases) Initialize() error {
@ -29,11 +34,50 @@ func (widget *Releases) Initialize() error {
widget.CollapseAfter = 5
}
var tokenAsString = widget.Token.String()
var gitLabTokenAsString = widget.GitLabToken.String()
for _, repository := range widget.Repositories {
parts := strings.SplitN(repository, ":", 2)
var request *feed.ReleaseRequest
if len(parts) == 1 {
request = &feed.ReleaseRequest{
Source: feed.ReleaseSourceGithub,
Repository: repository,
}
if widget.Token != "" {
request.Token = &tokenAsString
}
} else if len(parts) == 2 {
if parts[0] == string(feed.ReleaseSourceGitlab) {
request = &feed.ReleaseRequest{
Source: feed.ReleaseSourceGitlab,
Repository: parts[1],
}
if widget.GitLabToken != "" {
request.Token = &gitLabTokenAsString
}
} else if parts[0] == string(feed.ReleaseSourceDockerHub) {
request = &feed.ReleaseRequest{
Source: feed.ReleaseSourceDockerHub,
Repository: parts[1],
}
} else {
return errors.New("invalid repository source " + parts[0])
}
}
widget.releaseRequests = append(widget.releaseRequests, request)
}
return nil
}
func (widget *Releases) Update(ctx context.Context) {
releases, err := feed.FetchLatestReleasesFromGithub(widget.Repositories, string(widget.Token))
releases, err := feed.FetchLatestReleases(widget.releaseRequests)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
@ -43,6 +87,10 @@ func (widget *Releases) Update(ctx context.Context) {
releases = releases[:widget.Limit]
}
for i := range releases {
releases[i].SourceIconURL = widget.Providers.AssetResolver("icons/" + string(releases[i].Source) + ".svg")
}
widget.Releases = releases
}

View File

@ -18,6 +18,7 @@ type RSS struct {
Items feed.RSSFeedItems `yaml:"-"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
NoItemsMessage string `yaml:"-"`
}
func (widget *RSS) Initialize() error {
@ -39,6 +40,14 @@ func (widget *RSS) Initialize() error {
widget.CardHeight = 0
}
if widget.Style == "detailed-list" {
for i := range widget.FeedRequests {
widget.FeedRequests[i].IsDetailed = true
}
}
widget.NoItemsMessage = "No items were returned from the feeds."
return nil
}
@ -65,5 +74,9 @@ func (widget *RSS) Render() template.HTML {
return widget.render(widget, assets.RSSHorizontalCards2Template)
}
if widget.Style == "detailed-list" {
return widget.render(widget, assets.RSSDetailedListTemplate)
}
return widget.render(widget, assets.RSSListTemplate)
}

68
internal/widget/search.go Normal file
View File

@ -0,0 +1,68 @@
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"`
NewTab bool `yaml:"new-tab"`
Autofocus bool `yaml:"autofocus"`
}
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
}

View File

@ -9,34 +9,39 @@ import (
"github.com/glanceapp/glance/internal/feed"
)
// TODO: rename to Markets at some point
type Stocks struct {
widgetBase `yaml:",inline"`
Stocks feed.Stocks `yaml:"stocks"`
Sort string `yaml:"sort-by"`
Style string `yaml:"style"`
type Markets struct {
widgetBase `yaml:",inline"`
StocksRequests []feed.MarketRequest `yaml:"stocks"`
MarketRequests []feed.MarketRequest `yaml:"markets"`
Sort string `yaml:"sort-by"`
Style string `yaml:"style"`
Markets feed.Markets `yaml:"-"`
}
func (widget *Stocks) Initialize() error {
widget.withTitle("Stocks").withCacheDuration(time.Hour)
func (widget *Markets) Initialize() error {
widget.withTitle("Markets").withCacheDuration(time.Hour)
if len(widget.MarketRequests) == 0 {
widget.MarketRequests = widget.StocksRequests
}
return nil
}
func (widget *Stocks) Update(ctx context.Context) {
stocks, err := feed.FetchStocksDataFromYahoo(widget.Stocks)
func (widget *Markets) Update(ctx context.Context) {
markets, err := feed.FetchMarketsDataFromYahoo(widget.MarketRequests)
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
if widget.Sort == "absolute-change" {
stocks.SortByAbsChange()
markets.SortByAbsChange()
}
widget.Stocks = stocks
widget.Markets = markets
}
func (widget *Stocks) Render() template.HTML {
return widget.render(widget, assets.StocksTemplate)
func (widget *Markets) Render() template.HTML {
return widget.render(widget, assets.MarketsTemplate)
}

View File

@ -14,15 +14,23 @@ type TwitchChannels struct {
ChannelsRequest []string `yaml:"channels"`
Channels []feed.TwitchChannel `yaml:"-"`
CollapseAfter int `yaml:"collapse-after"`
SortBy string `yaml:"sort-by"`
}
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 {
widget.CollapseAfter = 5
}
if widget.SortBy != "viewers" && widget.SortBy != "live" {
widget.SortBy = "viewers"
}
return nil
}
@ -33,7 +41,12 @@ func (widget *TwitchChannels) Update(ctx context.Context) {
return
}
channels.SortByViewers()
if widget.SortBy == "viewers" {
channels.SortByViewers()
} else if widget.SortBy == "live" {
channels.SortByLive()
}
widget.Channels = channels
}

View File

@ -18,7 +18,10 @@ type TwitchGames struct {
}
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 {
widget.Limit = 10

View File

@ -10,12 +10,14 @@ import (
)
type Videos struct {
widgetBase `yaml:",inline"`
Videos feed.Videos `yaml:"-"`
VideoUrlTemplate string `yaml:"video-url-template"`
Style string `yaml:"style"`
Channels []string `yaml:"channels"`
Limit int `yaml:"limit"`
widgetBase `yaml:",inline"`
Videos feed.Videos `yaml:"-"`
VideoUrlTemplate string `yaml:"video-url-template"`
Style string `yaml:"style"`
CollapseAfterRows int `yaml:"collapse-after-rows"`
Channels []string `yaml:"channels"`
Limit int `yaml:"limit"`
IncludeShorts bool `yaml:"include-shorts"`
}
func (widget *Videos) Initialize() error {
@ -25,11 +27,15 @@ func (widget *Videos) Initialize() error {
widget.Limit = 25
}
if widget.CollapseAfterRows == 0 || widget.CollapseAfterRows < -1 {
widget.CollapseAfterRows = 4
}
return nil
}
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) {
return

View File

@ -14,17 +14,30 @@ type Weather struct {
Location string `yaml:"location"`
ShowAreaName bool `yaml:"show-area-name"`
HideLocation bool `yaml:"hide-location"`
HourFormat string `yaml:"hour-format"`
Units string `yaml:"units"`
Place *feed.PlaceJson `yaml:"-"`
Weather *feed.Weather `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 {
widget.withTitle("Weather").withCacheOnTheHour()
widget.TimeLabels = timeLabels
if widget.Location == "" {
return fmt.Errorf("location must be specified for weather widget")
}
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 == "" {
widget.Units = "metric"
@ -32,18 +45,21 @@ func (widget *Weather) Initialize() error {
return fmt.Errorf("invalid units '%s' for weather, must be either metric or imperial", widget.Units)
}
place, err := feed.FetchPlaceFromName(widget.Location)
if err != nil {
return fmt.Errorf("failed fetching data for %s: %v", widget.Location, err)
}
widget.Place = place
return nil
}
func (widget *Weather) Update(ctx context.Context) {
if widget.Place == nil {
place, err := feed.FetchPlaceFromName(widget.Location)
if err != nil {
widget.withError(err).scheduleEarlyUpdate()
return
}
widget.Place = place
}
weather, err := feed.FetchWeatherForPlace(widget.Place, widget.Units)
if !widget.canContinueUpdateAfterHandlingErr(err) {

View File

@ -8,6 +8,8 @@ import (
"html/template"
"log/slog"
"math"
"net/http"
"sync/atomic"
"time"
"github.com/glanceapp/glance/internal/feed"
@ -15,39 +17,61 @@ import (
"gopkg.in/yaml.v3"
)
var uniqueID atomic.Uint64
func New(widgetType string) (Widget, error) {
var widget Widget
switch widgetType {
case "calendar":
return &Calendar{}, nil
widget = &Calendar{}
case "clock":
widget = &Clock{}
case "weather":
return &Weather{}, nil
widget = &Weather{}
case "bookmarks":
return &Bookmarks{}, nil
widget = &Bookmarks{}
case "iframe":
return &IFrame{}, nil
widget = &IFrame{}
case "html":
widget = &HTML{}
case "hacker-news":
return &HackerNews{}, nil
widget = &HackerNews{}
case "releases":
return &Releases{}, nil
widget = &Releases{}
case "videos":
return &Videos{}, nil
case "stocks":
return &Stocks{}, nil
widget = &Videos{}
case "markets", "stocks":
widget = &Markets{}
case "reddit":
return &Reddit{}, nil
widget = &Reddit{}
case "rss":
return &RSS{}, nil
widget = &RSS{}
case "monitor":
return &Monitor{}, nil
widget = &Monitor{}
case "twitch-top-games":
return &TwitchGames{}, nil
widget = &TwitchGames{}
case "twitch-channels":
return &TwitchChannels{}, nil
widget = &TwitchChannels{}
case "lobsters":
widget = &Lobsters{}
case "change-detection":
widget = &ChangeDetection{}
case "repository":
return &Repository{}, nil
widget = &Repository{}
case "search":
widget = &Search{}
case "extension":
widget = &Extension{}
case "group":
widget = &Group{}
default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
}
widget.SetID(uniqueID.Add(1))
return widget, nil
}
type Widgets []Widget
@ -78,10 +102,6 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
return err
}
if err = widget.Initialize(); err != nil {
return err
}
*w = append(*w, widget)
}
@ -91,9 +111,14 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
type Widget interface {
Initialize() error
RequiresUpdate(*time.Time) bool
SetProviders(*Providers)
Update(context.Context)
Render() template.HTML
GetType() string
GetID() uint64
SetID(uint64)
HandleRequest(w http.ResponseWriter, r *http.Request)
SetHideHeader(bool)
}
type cacheType int
@ -105,8 +130,12 @@ const (
)
type widgetBase struct {
ID uint64 `yaml:"-"`
Providers *Providers `yaml:"-"`
Type string `yaml:"type"`
Title string `yaml:"title"`
TitleURL string `yaml:"title-url"`
CSSClass string `yaml:"css-class"`
CustomCacheDuration DurationField `yaml:"cache"`
ContentAvailable bool `yaml:"-"`
Error error `yaml:"-"`
@ -116,6 +145,11 @@ type widgetBase struct {
cacheType cacheType `yaml:"-"`
nextUpdate time.Time `yaml:"-"`
updateRetriedTimes int `yaml:"-"`
HideHeader bool `yaml:"-"`
}
type Providers struct {
AssetResolver func(string) string
}
func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
@ -134,10 +168,30 @@ 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 {
return w.Type
}
func (w *widgetBase) SetProviders(providers *Providers) {
w.Providers = providers
}
func (w *widgetBase) render(data any, t *template.Template) template.HTML {
w.templateBuffer.Reset()
err := t.Execute(&w.templateBuffer, data)
@ -173,6 +227,14 @@ func (w *widgetBase) withTitle(title string) *widgetBase {
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 {
w.cacheType = cacheTypeDuration

View File

@ -1,245 +0,0 @@
package main
import (
"fmt"
"os"
"os/exec"
"path"
"strings"
)
// bunch of spaget but it does the job for now
// TODO: tidy up and add a proper build system with CI
const buildPath = "./build"
const archivesPath = "./build/archives"
const executableName = "glance"
const ownerAndRepo = "glanceapp/glance"
const moduleName = "github.com/" + ownerAndRepo
type archiveType int
const (
archiveTypeTarGz archiveType = iota
archiveTypeZip
)
type buildInfo struct {
version string
}
type buildTarget struct {
os string
arch string
armV int
extension string
archive archiveType
}
var buildTargets = []buildTarget{
{
os: "windows",
arch: "amd64",
extension: ".exe",
archive: archiveTypeZip,
},
{
os: "windows",
arch: "arm64",
extension: ".exe",
archive: archiveTypeZip,
},
{
os: "linux",
arch: "amd64",
},
{
os: "linux",
arch: "arm64",
},
{
os: "linux",
arch: "arm",
armV: 6,
},
{
os: "linux",
arch: "arm",
armV: 7,
},
{
os: "openbsd",
arch: "amd64",
},
{
os: "openbsd",
arch: "386",
},
}
func main() {
cwd, err := os.Getwd()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
_, err = os.Stat(buildPath)
if err == nil {
fmt.Println("Cleaning up build path")
os.RemoveAll(buildPath)
}
os.Mkdir(buildPath, 0755)
os.Mkdir(archivesPath, 0755)
version, err := getVersionFromGit()
if err != nil {
fmt.Println(version, err)
os.Exit(1)
}
info := buildInfo{
version: version,
}
for _, target := range buildTargets {
fmt.Printf("Building for %s/%s\n", target.os, target.arch)
if err := build(cwd, info, target); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
versionTag := fmt.Sprintf("%s:%s", ownerAndRepo, version)
latestTag := fmt.Sprintf("%s:latest", ownerAndRepo)
fmt.Println("Building docker image")
output, err := exec.Command(
"sudo", "docker", "build",
"--platform=linux/amd64,linux/arm64,linux/arm/v7",
"-t", versionTag,
"-t", latestTag,
".",
).CombinedOutput()
if err != nil {
fmt.Println(string(output))
fmt.Println(err)
os.Exit(1)
}
var input string
fmt.Print("Push docker image? [y/n]: ")
fmt.Scanln(&input)
if input != "y" {
os.Exit(0)
}
output, err = exec.Command(
"sudo", "docker", "push", versionTag,
).CombinedOutput()
if err != nil {
fmt.Printf("Failed pushing %s:\n", versionTag)
fmt.Println(string(output))
fmt.Println(err)
os.Exit(1)
}
output, err = exec.Command(
"sudo", "docker", "push", latestTag,
).CombinedOutput()
if err != nil {
fmt.Printf("Failed pushing %s:\n", latestTag)
fmt.Println(string(output))
fmt.Println(err)
os.Exit(1)
}
}
func getVersionFromGit() (string, error) {
output, err := exec.Command("git", "describe", "--tags", "--abbrev=0").CombinedOutput()
if err == nil {
return strings.TrimSpace(string(output)), err
}
return string(output), err
}
func archiveFile(name string, target string, t archiveType) error {
var output []byte
var err error
if t == archiveTypeZip {
output, err = exec.Command("zip", "-j", path.Join(archivesPath, name+".zip"), target).CombinedOutput()
} else if t == archiveTypeTarGz {
output, err = exec.Command("tar", "-C", buildPath, "-czf", path.Join(archivesPath, name+".tar.gz"), name).CombinedOutput()
}
if err != nil {
fmt.Println(string(output))
return err
}
return nil
}
func build(workingDir string, info buildInfo, target buildTarget) error {
var name string
if target.arch != "arm" {
name = fmt.Sprintf("%s-%s-%s%s", executableName, target.os, target.arch, target.extension)
} else {
name = fmt.Sprintf("%s-%s-%sv%d", executableName, target.os, target.arch, target.armV)
}
binaryPath := path.Join(buildPath, name)
glancePackage := moduleName + "/internal/glance"
flags := "-s -w"
flags += fmt.Sprintf(" -X %s.buildVersion=%s", glancePackage, info.version)
cmd := exec.Command(
"go",
"build",
"--trimpath",
"--ldflags",
flags,
"-o",
binaryPath,
)
cmd.Dir = workingDir
env := append(os.Environ(), "GOOS="+target.os, "GOARCH="+target.arch, "CGO_ENABLED=0")
if target.arch == "arm" {
env = append(env, fmt.Sprintf("GOARM=%d", target.armV))
}
cmd.Env = env
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Println(err)
fmt.Println(string(output))
return err
}
os.Chmod(binaryPath, 0755)
fmt.Println("Creating archive")
if err := archiveFile(name, binaryPath, target.archive); err != nil {
return err
}
return nil
}