Compare commits

..
109 Commits
Author SHA1 Message Date
Svilen Markov b83bf9bc09 Merge pull request #121 from glanceapp/release/v0.5.0
Release v0.5.0
2024-06-02 20:17:50 +01:00
Svilen Markov 8d96e883d6 Update readme 2024-06-02 20:14:35 +01:00
Svilen Markov 75079a981b Add HTML widget 2024-06-02 19:01:20 +01:00
Svilen Markov e8043be107 Add lobsters widget preview 2024-06-02 18:44:27 +01:00
Svilen Markov c276f66017 Add tags to forum posts template 2024-06-02 18:44:17 +01:00
Svilen Markov db7df33b27 Bump versions 2024-06-02 18:24:54 +01:00
Svilen Markov 1b8e355944 Document extension widget 2024-06-02 18:24:54 +01:00
Svilen Markov 342ef90cbe Add extension widget 2024-06-02 18:21:03 +01:00
Svilen Markov b3be60acf6 Document collapse-after-rows property 2024-06-02 18:21:03 +01:00
Svilen Markov b58d3e2849 Document change detection widget 2024-06-02 18:21:03 +01:00
Svilen Markov 4206fb943d Document simple icons usage for monitor icons 2024-06-02 18:21:03 +01:00
Svilen Markov ee58ff40f1 Document allow-insecure property 2024-06-02 18:21:02 +01:00
Svilen Markov 21fd842bde Refactor monitor: allow insecure requests & use of simple icons 2024-06-02 18:21:02 +01:00
Svilen Markov b63b5eb262 Make converting string to simple icon url reusable 2024-06-02 18:21:02 +01:00
Svilen Markov 9c69509a19 Fix markets class 2024-06-02 18:21:02 +01:00
Svilen Markov 511147fb90 Document detailed-list RSS style 2024-06-02 18:21:02 +01:00
Svilen Markov 9ce0030482 Don't offset attachments 2024-06-02 18:21:02 +01:00
Svilen Markov 4164263639 Fix RSS item links without base domain 2024-06-02 18:21:02 +01:00
Svilen Markov 1bebb88d0e Rename Stocks to Markets
Also fix bug that would remove markets if a network request failed
and not show them again until Glance was restarted
2024-06-02 18:21:02 +01:00
Svilen Markov d2f5dbbc26 Fix weather widget making HTTP request as part of validation
Which would prevent app startup if there was no internet connection
2024-06-02 18:21:02 +01:00
Svilen Markov 2af6500028 Add utility class 2024-06-02 18:21:02 +01:00
Svilen Markov a7cf8be8c4 Lower gap between timezones 2024-06-02 18:21:02 +01:00
Svilen Markov 69b3e544bd Fix layout shift on image load 2024-06-02 18:21:02 +01:00
Svilen Markov 0c63c61db5 Position category details to top when tags missing 2024-06-02 18:21:02 +01:00
Svilen Markov 8dc34ddfa7 Merge pull request #56 from jonasknobloch/lobsters-widget
Add Lobsters widget
2024-06-02 18:20:36 +01:00
Svilen Markov 8efda0047c Merge branch 'release/v0.5.0' into lobsters-widget 2024-06-02 18:20:07 +01:00
Svilen Markov e3c17e0347 Merge branch 'main' into lobsters-widget 2024-06-02 18:19:06 +01:00
Svilen Markov 038c43bc09 Document lobsters widget 2024-06-02 18:17:20 +01:00
Svilen Markov 720549ec7e Allow specifying sort order and tags through config 2024-06-02 18:08:29 +01:00
Svilen Markov 3aa1f273b6 Add tags to dedicated field rather than to title 2024-06-02 17:51:49 +01:00
Svilen Markov 5105ebb870 Don't parse unused fields 2024-06-02 17:51:07 +01:00
Svilen Markov 0a55c12f4f Add tags to forum posts 2024-06-02 17:50:46 +01:00
Svilen Markov 67c20c3a5f Merge pull request #46 from knhash/change-detection
feat: add change detection module
2024-05-30 22:55:22 +01:00
Svilen Markov f4f7892123 Merge branch 'release/v0.5.0' into change-detection 2024-05-30 22:55:01 +01:00
Svilen Markov 00a93e466d Update change detection 2024-05-30 22:53:59 +01:00
Svilen Markov f8816ff227 Merge pull request #89 from NoxesP/patch-1
docs: update configuration.md
2024-05-25 04:23:06 +01:00
Svilen Markov a5be460c2e Merge pull request #69 from chand1012/search
Add Search Bar Widget
2024-05-25 04:00:20 +01:00
Svilen Markov e1e7853c34 Update search widget 2024-05-25 03:58:11 +01:00
Svilen Markov 3f862f67ab Merge branch 'release/v0.5.0' into search 2024-05-25 02:51:32 +01:00
Svilen Markov 4bb5b2641b Further attempts at making the rain look less scuffed 2024-05-25 02:38:49 +01:00
Svilen Markov f044fdf5dc Add utility classes 2024-05-25 02:38:49 +01:00
Svilen Markov d1be4637ad Fix registering resize event on wrong object 2024-05-25 02:38:49 +01:00
Svilen Markov d18f645c18 Add detailed-list style for RSS 2024-05-25 02:38:49 +01:00
Svilen Markov 6663360caa Don't add classes that are no longer needed 2024-05-25 02:38:48 +01:00
Svilen Markov c2d24ee4d9 Set background color in manifest 2024-05-25 02:38:48 +01:00
Svilen Markov 37471fcdc1 Remove extra new line 2024-05-25 02:38:48 +01:00
Svilen Markov 0f42567d98 Merge pull request #82 from yardenshoham/add-clock-widget
Add a clock widget
2024-05-25 02:32:18 +01:00
Svilen Markov 8148f09b9c Add more features to clock 2024-05-25 02:28:45 +01:00
NoxesP c270afb5e5 docs: update configuration.md
Won't admit how long it took for me to notice this in my glance.yml file after wondering why it wasn't working ahahah.

Small fix :)
2024-05-20 04:47:57 +03:00
Svilen Markov 6de0f73ec1 Fix build breaking because of missing build dir 2024-05-19 21:33:51 +01:00
Svilen Markov a80a34afb1 Merge pull request #83 from wfg/image-build-rework
Image build rework
2024-05-19 18:06:03 +01:00
Svilen Markov 584f813fc4 Merge pull request #87 from hex-developer/main
Fix column overflow
2024-05-19 18:01:21 +01:00
Svilen Markov a44056dc43 Remove other unnecessary shrinks 2024-05-19 18:00:08 +01:00
Svilen Markov c941c78ebf Truncate RSS channel name 2024-05-19 17:52:17 +01:00
Svilen Markov 9e9f001fdf Add newline at end of file 2024-05-19 17:51:59 +01:00
Svilen Markov 9060887694 Remove unnecessary shrink 2024-05-19 17:51:40 +01:00
Svilen Markov 6d9ea27e9f Merge branch 'release/v0.5.0' into main 2024-05-19 17:48:14 +01:00
Svilen Markov 4f4973157c Merge branch 'main' into release/v0.5.0 2024-05-19 17:46:21 +01:00
hex-developer 859a6d2f27 Fix column overflow 2024-05-19 15:52:13 +00:00
Wyatt Gill e3ad646bae Add .dockerignore to keep build context small 2024-05-18 17:42:02 -05:00
Wyatt Gill 53a661861a Build app in Dockerfile
This change makes it simpler and more convenient to build the app.
2024-05-18 17:41:21 -05:00
Yarden Shoham 4a27ec3271 Add a clock widget
It's a simple clock that shows the current time

Signed-off-by: Yarden Shoham <git@yardenshoham.com>
2024-05-18 22:40:33 +03:00
Svilen Markov 926de71274 Add PR template 2024-05-18 19:49:26 +01:00
Svilen Markov b0e57bae9f Merge pull request #81 from yardenshoham/correct-method-in-docs
Specify the correct method in the Monitor widget documentation
2024-05-18 19:19:14 +01:00
Svilen Markov 0bbb4573b2 Revert setTimeout for images
Reduces instances of cached images fading in by giving
the browser a bit more time to load them
2024-05-18 18:52:54 +01:00
Svilen Markov eeda28a287 Reduce reliance on setTimeout shenanigans 2024-05-18 18:43:11 +01:00
Yarden Shoham 59c55b96be Specify the correct method in the Monitor widget documentation
After 189b8895b0 we use `GET` instead of `HEAD`

Signed-off-by: Yarden Shoham <git@yardenshoham.com>
2024-05-18 18:02:12 +03:00
Svilen Markov 21909e0bda Merge pull request #76 from fawni/feat/twitch-channels-sort
Add custom sorting to the twitch channels widget
2024-05-18 11:05:55 +01:00
Svilen Markov 5e9635a76f Change custom order to live 2024-05-18 11:01:34 +01:00
Svilen Markov 6627a635d5 Update build script 🤦‍♂️ 2024-05-18 10:44:52 +01:00
Svilen Markov 20740f33af Update build script 2024-05-18 10:37:14 +01:00
fawn 0681a04607 Add custom sorting to the twitch channels widget
this allows the channels to be sorted as defined in the config while
still keeping the live channels on the top. the wording could probably
be improved, "custom" is too broad but i'm not sure what else to call
it.
2024-05-18 03:07:34 +03:00
Svilen Markov 21d491843f Create SECURITY.md 2024-05-17 19:56:18 +01:00
Svilen Markov 566ac99a53 Move code of conduct 2024-05-17 19:55:34 +01:00
Svilen Markov fca504901f Create CODE_OF_CONDUCT.md 2024-05-17 19:31:03 +01:00
Svilen Markov e03231cbd7 Update time label 2024-05-17 19:28:15 +01:00
Svilen Markov 693051f4e9 Merge pull request #59 from tarikcoskun/main
Add 24-hour time format support for weather
2024-05-17 18:41:04 +01:00
Svilen Markov 06e891f9a9 Update doc 2024-05-17 18:37:35 +01:00
Svilen Markov a2bb1b88ea Remove unrelated units check 2024-05-17 18:37:11 +01:00
Svilen Markov 9602269f27 Merge pull request #49 from medinnna/main
Add web app support
2024-05-17 17:47:18 +01:00
Svilen Markov 27b9af6067 Don't transition all
Otherwise animates image dimensions when resizing from mobile to desktop
2024-05-17 17:45:50 +01:00
Svilen Markov d43c0749c1 Only apply box shadow on collapsible grids 2024-05-17 17:45:50 +01:00
Svilen Markov f4fca7600a Merge pull request #74 from brian-petersen/serve-error
Adds reporting serve errors
2024-05-17 17:31:10 +01:00
Brian Petersen 6134f1c812 Adds reporting serve errors 2024-05-17 10:11:00 -06:00
chand1012 8392b7a606 Fix some styling issues and query bug 2024-05-17 10:18:58 -04:00
Svilen Markov 94cf82c3d9 Remove link to discussions 2024-05-17 12:21:44 +01:00
Svilen Markov b0018c3f06 Refactor, again
Make collapsible components reusable
Add ability to collapse video rows in videos-grid style
Lose sanity while dealing with all the intricacies
2024-05-17 12:15:44 +01:00
Svilen Markov 8977114806 Fix viewport offset after collapsing 2024-05-16 21:17:10 +01:00
Svilen Markov 36f8eac3e4 Refactor
Delay showing page content until JS has finished setting up page elements
That then allows the following:
Leave relative time to be rendered on the client
Leave collapsible lists to be rendered on the client
Which massively simplfies the backend templates which were error prone
2024-05-16 20:41:50 +01:00
chand1012 0f01a8eda5 Add search box 2024-05-15 20:04:08 -04:00
Svilen Markov de965dace8 Increase delay before adding class 2024-05-14 16:38:54 +01:00
Svilen Markov f0ca2edb25 Reduce max width of weather columns 2024-05-14 16:36:04 +01:00
Svilen Markov 514d5f92d1 Add link to release notes 2024-05-14 14:33:24 +01:00
Svilen Markov 7e05f8a5eb Add carousel right cutoff by default
Prevents it from fading in when the page loads
2024-05-14 14:32:51 +01:00
Svilen Markov 48d4d4e966 Revert back to vh
dvh causes micro jumps when scrolling near the top of the page which looks glitchy
2024-05-14 06:28:42 +01:00
Tarık Coşkun 6a7b50b35f Add 24-hour time format support for weather 2024-05-13 15:08:58 +03:00
Shashank S 6cad5a8efb Merge remote-tracking branch 'origin' into change-detection 2024-05-13 03:32:52 +02:00
Jonas Knobloch c07a1ab636 Update default feed 2024-05-12 17:37:04 +02:00
Jonas Knobloch eb9f92f04a Update tag delimiter 2024-05-12 17:14:40 +02:00
Jonas Knobloch c05607934b Fetch posts via JSON endpoint 2024-05-12 17:14:04 +02:00
Svilen Markov f9ca93e0b0 Account for safe area in label offset 2024-05-12 14:45:13 +01:00
Svilen Markov 09b7d71103 Revert to using the small icon for the favicon
Bit too much whitespace in the app icon to be able to use it as a favicon, makes the G look quite small
2024-05-12 14:27:25 +01:00
Svilen Markov bc06578e53 Change implementation for the safe area 2024-05-12 14:20:10 +01:00
Jonas Knobloch 344f518991 Add Lobsters widget 2024-05-12 13:20:34 +02:00
Carlos Medina 91c75518da Change mobile nav styles 2024-05-10 19:54:43 -06:00
Shashank S aa04904a72 feat: add diff, fix: configurable url 2024-05-09 08:32:27 +02:00
Svilen Markov 461c984317 Check if background color is nil before setting 2024-05-09 04:05:25 +01:00
Carlos Medina ecbad7ce6c Add mobile web app support 2024-05-08 16:51:04 -06:00
Shashank S 7adf624e95 feat: add change detection module 2024-05-08 11:47:25 +02:00
73 changed files with 2541 additions and 311 deletions
+10
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
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
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
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.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.19 FROM alpine:3.20
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
+8 -1
View File
@@ -1,7 +1,14 @@
FROM golang:1.22.3-alpine3.19 AS builder
WORKDIR /app
COPY . /app
RUN CGO_ENABLED=0 go build .
FROM alpine:3.19 FROM alpine:3.19
WORKDIR /app WORKDIR /app
COPY build/glance /app/glance COPY --from=builder /app/glance .
EXPOSE 8080/tcp EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance"] ENTRYPOINT ["/app/glance"]
+4 -6
View File
@@ -10,7 +10,10 @@
* Subreddit posts * Subreddit posts
* Weather * Weather
* Bookmarks * Bookmarks
* Hacker News
* Lobsters
* Latest YouTube videos from specific channels * Latest YouTube videos from specific channels
* Clock
* Calendar * Calendar
* Stocks * Stocks
* iframe * iframe
@@ -18,6 +21,7 @@
* GitHub releases * GitHub releases
* Repository overview * Repository overview
* Site monitor * Site monitor
* Search box
#### Themeable #### Themeable
![multiple color schemes example](docs/images/themes-example.png) ![multiple color schemes example](docs/images/themes-example.png)
@@ -92,12 +96,6 @@ go run .
### Building Docker image ### Building Docker image
Build Glance with CGO disabled:
```bash
CGO_ENABLED=0 go build -o build/glance .
```
Build the image: Build the image:
**Make sure to replace "owner" with your name or organization.** **Make sure to replace "owner" with your name or organization.**
+313 -19
View File
@@ -10,17 +10,23 @@
- [RSS](#rss) - [RSS](#rss)
- [Videos](#videos) - [Videos](#videos)
- [Hacker News](#hacker-news) - [Hacker News](#hacker-news)
- [Lobsters](#lobsters)
- [Reddit](#reddit) - [Reddit](#reddit)
- [Search](#search-widget)
- [Extension](#extension)
- [Weather](#weather) - [Weather](#weather)
- [Monitor](#monitor) - [Monitor](#monitor)
- [Releases](#releases) - [Releases](#releases)
- [Repository](#repository) - [Repository](#repository)
- [Bookmarks](#bookmarks) - [Bookmarks](#bookmarks)
- [Calendar](#calendar) - [Calendar](#calendar)
- [Stocks](#stocks) - [ChangeDetection.io](#changedetectionio)
- [Clock](#clock)
- [Markets](#markets)
- [Twitch Channels](#twitch-channels) - [Twitch Channels](#twitch-channels)
- [Twitch Top Games](#twitch-top-games) - [Twitch Top Games](#twitch-top-games)
- [iframe](#iframe) - [iframe](#iframe)
- [HTML](#html)
## Intro ## 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. Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error.
@@ -34,6 +40,7 @@ pages:
columns: columns:
- size: small - size: small
widgets: widgets:
- type: clock
- type: calendar - type: calendar
- type: rss - type: rss
@@ -76,8 +83,8 @@ pages:
- type: weather - type: weather
location: London, United Kingdom location: London, United Kingdom
- type: stocks - type: markets
stocks: markets:
- symbol: SPY - symbol: SPY
name: S&P 500 name: S&P 500
- symbol: BTC-USD - symbol: BTC-USD
@@ -405,6 +412,10 @@ Used to change the appearance of the widget. Possible values are `vertical-list`
![preview of vertical-list style for RSS widget](images/rss-feed-vertical-list-preview.png) ![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` `horizontal-cards`
![preview of horizontal-cards style for RSS widget](images/rss-feed-horizontal-cards-preview.png) ![preview of horizontal-cards style for RSS widget](images/rss-feed-horizontal-cards-preview.png)
@@ -423,10 +434,16 @@ Used to modify the height of cards when using the `horizontal-cards-2` style. Th
An array of RSS/atom feeds. The title can optionally be changed. An array of RSS/atom feeds. The title can optionally be changed.
###### Properties for each feed ###### Properties for each feed
| Name | Type | Required | Default | | Name | Type | Required | Default | Notes |
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- | ----- |
| url | string | yes | | | url | string | yes | | |
| title | string | no | the title provided by the feed | | 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` ##### `limit`
The maximum number of articles to show. The maximum number of articles to show.
@@ -456,6 +473,7 @@ Preview:
| channels | array | yes | | | channels | array | yes | |
| limit | integer | no | 25 | | limit | integer | no | 25 |
| style | string | no | horizontal-cards | | style | string | no | horizontal-cards |
| collapse-after-rows | integer | no | 4 |
| video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} | | video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} |
##### `channels` ##### `channels`
@@ -470,6 +488,9 @@ Then scroll down and click on "Share channel", then "Copy channel ID":
##### `limit` ##### `limit`
The maximum number of videos to show. 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` ##### `style`
Used to change the appearance of the widget. Possible values are `horizontal-cards` and `grid-cards`. Used to change the appearance of the widget. Possible values are `horizontal-cards` and `grid-cards`.
@@ -530,6 +551,45 @@ Can be used to specify an additional sort which will be applied on top of the al
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts. The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
### Lobsters
Display a list of posts from [Lobsters](https://lobste.rs).
Example:
```yaml
- type: lobsters
sort-by: hot
tags:
- go
- security
- linux
limit: 15
collapse-after: 5
```
Preview:
![](images/lobsters-widget-preview.png)
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| limit | integer | no | 15 |
| collapse-after | integer | no | 5 |
| sort-by | string | no | hot |
| tags | array | no | |
##### `limit`
The maximum number of posts to show.
##### `collapse-after`
How many posts are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
##### `sort-by`
The sort order in which posts are returned. Possible options are `hot` and `new`.
##### `tags`
Limit to posts containing one of the given tags. **You cannot specify a sort order when filtering by tags, it will default to `hot`.**
### Reddit ### Reddit
Display a list of posts from a specific subreddit. Display a list of posts from a specific subreddit.
@@ -626,7 +686,7 @@ https://your.proxy/?url={REQUEST-URL}
##### `sort-by` ##### `sort-by`
Can be used to specify the order in which the posts should get returned. Possible values are `hot`, `new`, `top` and `rising`. 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`. Available only when `sort-by` is set to `top`. Possible values are `hour`, `day`, `week`, `month`, `year` and `all`.
##### `search` ##### `search`
@@ -639,6 +699,111 @@ Can be used to specify an additional sort which will be applied on top of the al
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts. The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
### Search Widget
Display a search bar that can be used to search for specific terms on various search engines.
Example:
```yaml
- type: search
search-engine: duckduckgo
bangs:
- title: YouTube
shortcut: "!yt"
url: https://www.youtube.com/results?search_query={QUERY}
```
Preview:
![](images/search-widget-preview.png)
#### Keyboard shortcuts
| Keys | Action | Condition |
| ---- | ------ | --------- |
| <kbd>S</kbd> | Focus the search bar | Not already focused on another input field |
| <kbd>Enter</kbd> | Perform search in the same tab | Search input is focused and not empty |
| <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Perform search in a new tab | Search input is focused and not empty |
| <kbd>Escape</kbd> | Leave focus | Search input is focused |
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| search-engine | string | no | duckduckgo |
| bangs | array | no | |
##### `search-engine`
Either a value from the table below or a URL to a custom search engine. Use `{QUERY}` to indicate where the query value gets placed.
| Name | URL |
| ---- | --- |
| duckduckgo | `https://duckduckgo.com/?q={QUERY}` |
| google | `https://www.google.com/search?q={QUERY}` |
##### `bangs`
What now? [Bangs](https://duckduckgo.com/bangs). They're shortcuts that allow you to use the same search box for many different sites. Assuming you have it configured, if for example you start your search input with `!yt` you'd be able to perform a search on YouTube:
![](images/search-widget-bangs-preview.png)
##### Properties for each bang
| Name | Type | Required |
| ---- | ---- | -------- |
| title | string | no |
| shortcut | string | yes |
| url | string | yes |
###### `title`
Optional title that will appear on the right side of the search bar when the query starts with the associated shortcut.
###### `shortcut`
Any value you wish to use as the shortcut for the search engine. It does not have to start with `!`.
> [!IMPORTANT]
>
> In YAML some characters have special meaning when placed in the beginning of a value. If your shortcut starts with `!` (and potentially some other special characters) you'll have to wrap the value in quotes:
> ```yaml
> shortcut: "!yt"
>```
###### `url`
The URL of the search engine. Use `{QUERY}` to indicate where the query value gets placed. Examples:
```yaml
url: https://www.reddit.com/search?q={QUERY}
url: https://store.steampowered.com/search/?term={QUERY}
url: https://www.amazon.com/s?k={QUERY}
```
### 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 ### Weather
Display weather information for a specific location. The data is provided by https://open-meteo.com/. Display weather information for a specific location. The data is provided by https://open-meteo.com/.
@@ -647,6 +812,7 @@ Example:
```yaml ```yaml
- type: weather - type: weather
units: metric units: metric
hour-format: 12h
location: London, United Kingdom location: London, United Kingdom
``` ```
@@ -671,6 +837,7 @@ Each bar represents a 2 hour interval. The yellow background represents sunrise
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| location | string | yes | | | location | string | yes | |
| units | string | no | metric | | units | string | no | metric |
| hour-format | string | no | 12h |
| hide-location | boolean | no | false | | hide-location | boolean | no | false |
| show-area-name | boolean | no | false | | show-area-name | boolean | no | false |
@@ -680,6 +847,9 @@ The name of the city and country to fetch weather information for. Attempting to
##### `units` ##### `units`
Whether to show the temperature in celsius or fahrenheit, possible values are `metric` or `imperial`. Whether to show the temperature in celsius or fahrenheit, possible values are `metric` or `imperial`.
#### `hour-format`
Whether to show the hours of the day in 12-hour format or 24-hour format. Possible values are `12h` and `24h`.
##### `hide-location` ##### `hide-location`
Optionally don't display the location name on the widget. Optionally don't display the location name on the widget.
@@ -697,7 +867,7 @@ Greenville, United States
``` ```
### Monitor ### 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: Example:
@@ -749,6 +919,7 @@ Properties for each site:
| title | string | yes | | | title | string | yes | |
| url | string | yes | | | url | string | yes | |
| icon | string | no | | | icon | string | no | |
| allow-insecure | boolean | no | false |
| same-tab | boolean | no | false | | same-tab | boolean | no | false |
`title` `title`
@@ -761,7 +932,21 @@ The URL which will be requested and its response will determine the status of th
`icon` `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` `same-tab`
@@ -958,6 +1143,98 @@ Whether to open the link in the same tab or a new one.
Whether to hide the colored arrow on each link. 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 ### Calendar
Display a calendar. Display a calendar.
@@ -975,14 +1252,14 @@ Preview:
> >
> There is currently no customizability available for the calendar. Extra features will be added in the future. > There is currently no customizability available for the calendar. Extra features will be added in the future.
### Stocks ### Markets
Display a list of stocks, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance. Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance.
Example: Example:
```yaml ```yaml
- type: stocks - type: markets
stocks: markets:
- symbol: SPY - symbol: SPY
name: S&P 500 name: S&P 500
- symbol: BTC-USD - symbol: BTC-USD
@@ -997,21 +1274,21 @@ Example:
Preview: Preview:
![](images/stocks-widget-preview.png) ![](images/markets-widget-preview.png)
#### Properties #### Properties
| Name | Type | Required | | Name | Type | Required |
| ---- | ---- | -------- | | ---- | ---- | -------- |
| stocks | array | yes | | markets | array | yes |
| sort-by | string | no | | sort-by | string | no |
| style | string | no | | style | string | no |
##### `stocks` ##### `markets`
An array of stocks for which to display information about. An array of markets for which to display information about.
##### `sort-by` ##### `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` ##### `style`
To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option. To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option.
@@ -1063,6 +1340,7 @@ Preview:
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| channels | array | yes | | | channels | array | yes | |
| collapse-after | integer | no | 5 | | collapse-after | integer | no | 5 |
| sort-by | string | no | viewers |
##### `channels` ##### `channels`
A list of channels to display. A list of channels to display.
@@ -1070,6 +1348,9 @@ A list of channels to display.
##### `collapse-after` ##### `collapse-after`
How many channels are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse. How many channels are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
##### `sort-by`
Can be used to specify the order in which the channels are displayed. Possible values are `viewers` and `live`.
### Twitch top games ### Twitch top games
Display a list of games with the most viewers on Twitch. Display a list of games with the most viewers on Twitch.
@@ -1132,3 +1413,16 @@ The source of the iframe.
##### `height` ##### `height`
The height of the iframe. The minimum allowed height is 50. 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
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: 39 KiB

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 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

+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/glanceapp/glance module github.com/glanceapp/glance
go 1.22.0 go 1.22.3
require ( require (
github.com/mmcdole/gofeed v1.3.0 github.com/mmcdole/gofeed v1.3.0
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

+262 -51
View File
@@ -37,6 +37,7 @@
--ths: var(--bgh), calc(var(--bgs) * var(--tsm)); --ths: var(--bgh), calc(var(--bgs) * var(--tsm));
--color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%)); --color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
--color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%));
--color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%)); --color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
--color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%)); --color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
@@ -57,6 +58,14 @@
font-size: var(--font-size-h4); font-size: var(--font-size-h4);
} }
.page-content, .page.content-ready .page-loading-container {
display: none;
}
.page.content-ready > .page-content {
display: block;
}
.page-column-full .size-title-dynamic { .page-column-full .size-title-dynamic {
font-size: var(--font-size-h3); font-size: var(--font-size-h3);
} }
@@ -71,14 +80,16 @@
white-space: nowrap; white-space: nowrap;
} }
.text-truncate-3-lines { .text-truncate-2-lines, .text-truncate-3-lines {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
-webkit-line-clamp: 3;
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
.text-truncate-3-lines { -webkit-line-clamp: 3; }
.text-truncate-2-lines { -webkit-line-clamp: 2; }
.visited-indicator:not(.text-truncate)::after, .visited-indicator:not(.text-truncate)::after,
.visited-indicator.text-truncate::before, .visited-indicator.text-truncate::before,
.bookmarks-link:not(.bookmarks-link-no-arrow)::after { .bookmarks-link:not(.bookmarks-link-no-arrow)::after {
@@ -106,6 +117,7 @@
.list-gap-14 { --list-half-gap: 0.7rem; } .list-gap-14 { --list-half-gap: 0.7rem; }
.list-gap-20 { --list-half-gap: 1rem; } .list-gap-20 { --list-half-gap: 1rem; }
.list-gap-24 { --list-half-gap: 1.2rem; } .list-gap-24 { --list-half-gap: 1.2rem; }
.list-gap-34 { --list-half-gap: 1.7rem; }
.list > *:not(:first-child) { .list > *:not(:first-child) {
margin-top: calc(var(--list-half-gap) * 2); margin-top: calc(var(--list-half-gap) * 2);
@@ -117,70 +129,84 @@
padding-top: var(--list-half-gap); padding-top: var(--list-half-gap);
} }
@keyframes listItemReveal { .collapsible-container:not(.container-expanded) > .collapsible-item {
display: none;
}
.collapsible-item {
animation: collapsibleItemReveal .25s backwards;
}
@keyframes collapsibleItemReveal {
from { from {
opacity: 0; opacity: 0;
transform: translateY(10px); transform: translateY(10px);
} }
} }
.list-collapsible-item { .expand-toggle-button {
display: none; font: inherit;
animation: listItemReveal 0.3s backwards; border: 0;
animation-delay: var(--animation-delay); cursor: pointer;
} display: block;
width: 100%;
.list-collapsible-label { text-align: left;
display: flex; color: var(--color-text-base);
align-items: center; text-transform: uppercase;
gap: 1rem; font-size: var(--font-size-h4);
padding: var(--widget-content-vertical-padding) 0; padding: var(--widget-content-vertical-padding) 0;
background: var(--color-widget-background); background: var(--color-widget-background);
} }
.list-collapsible-label:has(.list-collapsible-input:checked) { .expand-toggle-button.container-expanded {
position: sticky; position: sticky;
bottom: 0; /* -1px to hide 1px gap on chrome */
bottom: -1px;
} }
.list-collapsible:has(+ .list-collapsible-label > .list-collapsible-input:checked) .list-collapsible-item { .expand-toggle-button-icon {
display: block; display: inline-block;
margin-left: 1rem;
position: relative;
top: -.2rem;
} }
.list-collapsible-input { .expand-toggle-button-icon::before {
display: none;
}
.list-collapsible-label::before, .list-collapsible-label::after {
cursor: pointer;
display: block;
}
.list-collapsible-label::before {
content: 'SHOW MORE';
font-size: var(--font-size-h4);
}
.list-collapsible-label:has(.list-collapsible-input:checked)::before {
content: 'SHOW LESS';
}
.list-collapsible-label::after {
content: ''; content: '';
font-size: 0.8rem; font-size: 0.8rem;
transform: rotate(90deg); transform: rotate(90deg);
line-height: 1; line-height: 1;
display: inline-block;
transition: transform 0.3s; transition: transform 0.3s;
} }
.list-collapsible-label:has(.list-collapsible-input:checked)::after { .expand-toggle-button.container-expanded .expand-toggle-button-icon::before {
transform: rotate(-90deg); transform: rotate(-90deg);
} }
.widget-content:has(.list-collapsible-label:last-child) { .widget-content:has(.expand-toggle-button:last-child) {
padding-bottom: 0; padding-bottom: 0;
} }
.cards-grid.collapsible-container + .expand-toggle-button {
text-align: center;
margin-top: 0.5rem;
background-color: var(--color-background);
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.attachments > * {
border-radius: var(--border-radius);
padding: 0.1rem 0.5rem;
font-size: var(--font-size-h6);
background-color: var(--color-separator);
}
::selection { ::selection {
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%))); background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
color: var(--color-text-highlight); color: var(--color-text-highlight);
@@ -327,6 +353,23 @@ body {
border: 1px solid var(--color-negative); border: 1px solid var(--color-negative);
} }
kbd {
font: inherit;
padding: 0.1rem 0.8rem;
border-radius: var(--border-radius);
border: 2px solid var(--color-widget-background-highlight);
box-shadow: 0 2px 0 var(--color-widget-background-highlight);
user-select: none;
transition: transform .1s, box-shadow .1s;
font-size: var(--font-size-h5);
cursor: pointer;
}
kbd:active {
transform: translateY(2px);
box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
}
.content-bounds { .content-bounds {
max-width: 1600px; max-width: 1600px;
margin-inline: auto; margin-inline: auto;
@@ -578,16 +621,16 @@ body {
color: var(--color-text-highlight); color: var(--color-text-highlight);
} }
.stock-chart { .market-chart {
margin-left: auto; margin-left: auto;
width: 6.5rem; width: 6.5rem;
} }
.stock-chart svg { .market-chart svg {
width: 100%; width: 100%;
} }
.stock-values { .market-values {
min-width: 8rem; min-width: 8rem;
} }
@@ -638,6 +681,85 @@ body {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
.search-icon {
width: 2.3rem;
}
.search-icon-container {
position: relative;
flex-shrink: 0;
}
/* gives a wider hit area for the 3 people that will notice the animation : ) */
.search-icon-container::before {
content: '';
position: absolute;
inset: -1rem;
}
.search-icon-container:hover > .search-icon {
animation: searchIconHover 2.9s forwards;
}
@keyframes searchIconHover {
0%, 39% { translate: 0 0; }
20% { scale: 1.3; }
40% { scale: 1; }
50% { translate: -30% 30%; }
70% { translate: 30% -30%; }
90% { translate: -30% -30%; }
100% { translate: 0 0; }
}
.search {
transition: border-color .2s;
position: relative;
}
.search:hover {
border-color: var(--color-text-subdue);
}
.search:focus-within {
border-color: var(--color-primary);
}
.search-input {
border: 0;
background: none;
width: 100%;
height: 6rem;
font: inherit;
outline: none;
}
.search-input::placeholder {
color: var(--color-text-base-muted);
opacity: 1;
}
.search-bangs { display: none; }
.search-bang {
border-radius: calc(var(--border-radius) * 2);
background: var(--color-widget-background-highlight);
padding: 0.3rem 1rem;
flex-shrink: 0;
font-size: var(--font-size-h5);
animation: searchBangsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
@keyframes searchBangsEntrance {
0% {
opacity: 0;
transform: translateX(-10px);
}
}
.search-bang:empty {
display: none;
}
.forum-post-list-item { .forum-post-list-item {
display: flex; display: flex;
gap: 1.2rem; gap: 1.2rem;
@@ -653,6 +775,10 @@ body {
margin-top: 0.1rem; margin-top: 0.1rem;
} }
.forum-post-tags-container {
transform: translateY(-0.15rem);
}
.bookmarks-group { .bookmarks-group {
--bookmarks-group-color: var(--color-primary); --bookmarks-group-color: var(--color-primary);
} }
@@ -706,7 +832,7 @@ body {
flex-direction: column; flex-direction: column;
width: calc(100% / 12); width: calc(100% / 12);
padding-top: 3px; padding-top: 3px;
max-width: 3.5rem; max-width: 30px;
} }
.weather-column-value, .weather-columns:hover .weather-column-value { .weather-column-value, .weather-columns:hover .weather-column-value {
@@ -840,6 +966,10 @@ body {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
.clock-time span {
color: var(--color-text-highlight);
}
.monitor-site-icon { .monitor-site-icon {
display: block; display: block;
opacity: 0.8; opacity: 0.8;
@@ -866,11 +996,22 @@ body {
.thumbnail { .thumbnail {
filter: grayscale(0.2) contrast(0.9); filter: grayscale(0.2) contrast(0.9);
transition: all 0.2s;
opacity: 0.8; opacity: 0.8;
transition: filter 0.2s, opacity .2s;
} }
.thumbnail-container:hover .thumbnail { .thumbnail-container {
flex-shrink: 0;
border: 1px solid var(--color-separator);
border-radius: var(--border-radius);
}
.thumbnail-container > * {
border-radius: var(--border-radius);
object-fit: cover;
}
.thumbnail-parent:hover .thumbnail {
opacity: 1; opacity: 1;
filter: none; filter: none;
} }
@@ -918,8 +1059,23 @@ body {
z-index: 3; z-index: 3;
} }
.rss-detailed-description {
max-width: 55rem;
color: var(--color-text-base-muted);
}
.rss-detailed-thumbnail {
margin-top: 0.3rem;
}
.rss-detailed-thumbnail > * {
aspect-ratio: 3 / 2;
height: 8.7rem;
}
.twitch-category-thumbnail { .twitch-category-thumbnail {
width: 5rem; width: 5rem;
aspect-ratio: 3 / 4;
border-radius: var(--border-radius); border-radius: var(--border-radius);
} }
@@ -996,10 +1152,10 @@ body {
.page-column { .page-column {
display: none; display: none;
animation: columnEntrance 0s cubic-bezier(0.25, 1, 0.5, 1) backwards; animation: columnEntrance .0s cubic-bezier(0.25, 1, 0.5, 1) backwards;
} }
.animate-element-transition .page-column { .page-columns-transitioned .page-column {
animation-duration: .3s; animation-duration: .3s;
} }
@@ -1107,9 +1263,48 @@ body {
box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor; box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor;
} }
.list-collapsible-label:has(.list-collapsible-input:checked) { .expand-toggle-button.container-expanded {
bottom: var(--mobile-navigation-height); bottom: var(--mobile-navigation-height);
} }
.cards-grid + .expand-toggle-button.container-expanded {
/* hides content that peeks through the rounded borders of the mobile navigation */
box-shadow: 0 var(--border-radius) 0 0 var(--color-background);
}
.weather-column-rain::before {
background-size: 7px 7px;
}
}
@media (max-width: 1190px) and (display-mode: standalone) {
:root {
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0);
}
.list-collapsible-label:has(.list-collapsible-input:checked) {
bottom: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
}
.mobile-navigation {
transform: translateY(calc(100% - var(--mobile-navigation-height) - var(--safe-area-inset-bottom)));
padding-bottom: var(--safe-area-inset-bottom);
}
.mobile-navigation-icons {
padding-bottom: var(--safe-area-inset-bottom);
transition: padding-bottom .3s;
}
.mobile-navigation-icons:has(.mobile-navigation-page-links-input:checked) {
padding-bottom: 0;
}
}
@media (display-mode: standalone) {
body {
padding-top: env(safe-area-inset-top, 0);
}
} }
@media (max-width: 550px) { @media (max-width: 550px) {
@@ -1123,22 +1318,30 @@ body {
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; } .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
.forum-post-list-item { .row-reverse-on-mobile {
flex-flow: row-reverse; flex-direction: row-reverse;
} }
.hide-on-mobile { .hide-on-mobile, .thumbnail-container:has(> .hide-on-mobile) {
display: none display: none
} }
.mobile-reachability-header { .mobile-reachability-header {
display: block; display: block;
font-size: 3rem; font-size: 3rem;
padding: 10dvh 1rem; padding: 10vh 1rem;
text-align: center; text-align: center;
color: var(--color-text-highlight); color: var(--color-text-highlight);
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards; animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
} }
.rss-detailed-thumbnail > * {
height: 6rem;
}
.rss-detailed-description {
-webkit-line-clamp: 3;
}
} }
.size-h1 { font-size: var(--font-size-h1); } .size-h1 { font-size: var(--font-size-h1); }
@@ -1166,7 +1369,9 @@ body {
.shrink { flex-shrink: 1; } .shrink { flex-shrink: 1; }
.shrink-0 { flex-shrink: 0; } .shrink-0 { flex-shrink: 0; }
.min-width-0 { min-width: 0; } .min-width-0 { min-width: 0; }
.max-width-100 { max-width: 100%; }
.block { display: block; } .block { display: block; }
.inline-block { display: inline-block; }
.overflow-hidden { overflow: hidden; } .overflow-hidden { overflow: hidden; }
.relative { position: relative; } .relative { position: relative; }
.flex { display: flex; } .flex { display: flex; }
@@ -1174,6 +1379,7 @@ body {
.flex-nowrap { flex-wrap: nowrap; } .flex-nowrap { flex-wrap: nowrap; }
.justify-between { justify-content: space-between; } .justify-between { justify-content: space-between; }
.justify-stretch { justify-content: stretch; } .justify-stretch { justify-content: stretch; }
.justify-evenly { justify-content: space-evenly; }
.justify-center { justify-content: center; } .justify-center { justify-content: center; }
.justify-end { justify-content: end; } .justify-end { justify-content: end; }
.uppercase { text-transform: uppercase; } .uppercase { text-transform: uppercase; }
@@ -1185,6 +1391,10 @@ body {
.gap-7 { gap: 0.7rem; } .gap-7 { gap: 0.7rem; }
.gap-10 { gap: 1rem; } .gap-10 { gap: 1rem; }
.gap-15 { gap: 1.5rem; } .gap-15 { gap: 1.5rem; }
.gap-25 { gap: 2.5rem; }
.gap-35 { gap: 3.5rem; }
.gap-45 { gap: 4.5rem; }
.gap-55 { gap: 5.5rem; }
.margin-top-3 { margin-top: 0.3rem; } .margin-top-3 { margin-top: 0.3rem; }
.margin-top-5 { margin-top: 0.5rem; } .margin-top-5 { margin-top: 0.5rem; }
.margin-top-7 { margin-top: 0.7rem; } .margin-top-7 { margin-top: 0.7rem; }
@@ -1201,3 +1411,4 @@ body {
.margin-bottom-10 { margin-bottom: 1rem; } .margin-bottom-10 { margin-bottom: 1rem; }
.margin-bottom-15 { margin-bottom: 1.5rem; } .margin-bottom-15 { margin-bottom: 1.5rem; }
.margin-bottom-auto { margin-bottom: auto; } .margin-bottom-auto { margin-bottom: auto; }
.scale-half { transform: scale(0.5); }
+408 -23
View File
@@ -21,7 +21,7 @@ function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
}; };
async function fetchPageContents (pageSlug) { async function fetchPageContent(pageSlug) {
// TODO: handle non 200 status codes/time outs // TODO: handle non 200 status codes/time outs
// TODO: add retries // TODO: add retries
const response = await fetch(`/api/pages/${pageSlug}/content/`); const response = await fetch(`/api/pages/${pageSlug}/content/`);
@@ -33,8 +33,13 @@ async function fetchPageContents (pageSlug) {
function setupCarousels() { function setupCarousels() {
const carouselElements = document.getElementsByClassName("carousel-container"); const carouselElements = document.getElementsByClassName("carousel-container");
if (carouselElements.length == 0) {
return;
}
for (let i = 0; i < carouselElements.length; i++) { for (let i = 0; i < carouselElements.length; i++) {
const carousel = carouselElements[i]; const carousel = carouselElements[i];
carousel.classList.add("show-right-cutoff");
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0]; const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
const determineSideCutoffs = () => { const determineSideCutoffs = () => {
@@ -54,9 +59,9 @@ function setupCarousels() {
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100); const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited); itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
document.addEventListener("resize", determineSideCutoffsRateLimited); window.addEventListener("resize", determineSideCutoffsRateLimited);
determineSideCutoffs(); afterContentReady(determineSideCutoffs);
} }
} }
@@ -98,7 +103,104 @@ function updateRelativeTimeForElements(elements)
if (timestamp === undefined) if (timestamp === undefined)
continue continue
element.innerText = relativeTimeSince(timestamp); element.textContent = relativeTimeSince(timestamp);
}
}
function setupSearchboxes() {
const searchWidgets = document.getElementsByClassName("search");
if (searchWidgets.length == 0) {
return;
}
for (let i = 0; i < searchWidgets.length; i++) {
const widget = searchWidgets[i];
const defaultSearchUrl = widget.dataset.defaultSearchUrl;
const inputElement = widget.getElementsByClassName("search-input")[0];
const bangElement = widget.getElementsByClassName("search-bang")[0];
const bangs = widget.querySelectorAll(".search-bangs > input");
const bangsMap = {};
const kbdElement = widget.getElementsByTagName("kbd")[0];
let currentBang = null;
for (let j = 0; j < bangs.length; j++) {
const bang = bangs[j];
bangsMap[bang.dataset.shortcut] = bang;
}
const handleKeyDown = (event) => {
if (event.key == "Escape") {
inputElement.blur();
return;
}
if (event.key == "Enter") {
const input = inputElement.value.trim();
let query;
let searchUrlTemplate;
if (currentBang != null) {
query = input.slice(currentBang.dataset.shortcut.length + 1);
searchUrlTemplate = currentBang.dataset.url;
} else {
query = input;
searchUrlTemplate = defaultSearchUrl;
}
if (query.length == 0) {
return;
}
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
if (event.ctrlKey) {
window.open(url, '_blank').focus();
} else {
window.location.href = url;
}
return;
}
};
const changeCurrentBang = (bang) => {
currentBang = bang;
bangElement.textContent = bang != null ? bang.dataset.title : "";
}
const handleInput = (event) => {
const value = event.target.value.trimStart();
const words = value.split(" ");
if (words.length >= 2 && words[0] in bangsMap) {
changeCurrentBang(bangsMap[words[0]]);
return;
}
changeCurrentBang(null);
};
inputElement.addEventListener("focus", () => {
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("input", handleInput);
});
inputElement.addEventListener("blur", () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("input", handleInput);
});
document.addEventListener("keydown", (event) => {
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
if (event.key != "s") return;
inputElement.focus();
event.preventDefault();
});
kbdElement.addEventListener("mousedown", () => {
requestAnimationFrame(() => inputElement.focus());
});
} }
} }
@@ -107,6 +209,8 @@ function setupDynamicRelativeTime() {
const updateInterval = 60 * 1000; const updateInterval = 60 * 1000;
let lastUpdateTime = Date.now(); let lastUpdateTime = Date.now();
updateRelativeTimeForElements(elements);
const updateElementsAndTimestamp = () => { const updateElementsAndTimestamp = () => {
updateRelativeTimeForElements(elements); updateRelativeTimeForElements(elements);
lastUpdateTime = Date.now(); lastUpdateTime = Date.now();
@@ -153,35 +257,316 @@ function setupLazyImages() {
image.classList.add("finished-transition"); image.classList.add("finished-transition");
} }
for (let i = 0; i < images.length; i++) { afterContentReady(() => {
const image = images[i]; setTimeout(() => {
for (let i = 0; i < images.length; i++) {
const image = images[i];
if (image.complete) { if (image.complete) {
image.classList.add("cached"); image.classList.add("cached");
setTimeout(() => imageFinishedTransition(image), 5); setTimeout(() => imageFinishedTransition(image), 1);
} else { } else {
// TODO: also handle error event // TODO: also handle error event
image.addEventListener("load", () => { image.addEventListener("load", () => {
image.classList.add("loaded"); image.classList.add("loaded");
setTimeout(() => imageFinishedTransition(image), 500); setTimeout(() => imageFinishedTransition(image), 400);
}); });
}
}
}, 1);
});
}
function attachExpandToggleButton(collapsibleContainer) {
const showMoreText = "Show more";
const showLessText = "Show less";
let expanded = false;
const button = document.createElement("button");
const icon = document.createElement("span");
icon.classList.add("expand-toggle-button-icon");
const textNode = document.createTextNode(showMoreText);
button.classList.add("expand-toggle-button");
button.append(textNode, icon);
button.addEventListener("click", () => {
expanded = !expanded;
if (expanded) {
collapsibleContainer.classList.add("container-expanded");
button.classList.add("container-expanded");
textNode.nodeValue = showLessText;
return;
}
const topBefore = button.getClientRects()[0].top;
collapsibleContainer.classList.remove("container-expanded");
button.classList.remove("container-expanded");
textNode.nodeValue = showMoreText;
const topAfter = button.getClientRects()[0].top;
if (topAfter > 0)
return;
window.scrollBy({
top: topAfter - topBefore,
behavior: "instant"
});
});
collapsibleContainer.after(button);
return button;
};
function setupCollapsibleLists() {
const collapsibleLists = document.querySelectorAll(".list.collapsible-container");
if (collapsibleLists.length == 0) {
return;
}
for (let i = 0; i < collapsibleLists.length; i++) {
const list = collapsibleLists[i];
if (list.dataset.collapseAfter === undefined) {
continue;
}
const collapseAfter = parseInt(list.dataset.collapseAfter);
if (collapseAfter == -1) {
continue;
}
if (list.children.length <= collapseAfter) {
continue;
}
attachExpandToggleButton(list);
for (let c = collapseAfter; c < list.children.length; c++) {
const child = list.children[c];
child.classList.add("collapsible-item");
child.style.animationDelay = ((c - collapseAfter) * 20).toString() + "ms";
} }
} }
} }
function setupCollapsibleGrids() {
const collapsibleGridElements = document.querySelectorAll(".cards-grid.collapsible-container");
if (collapsibleGridElements.length == 0) {
return;
}
for (let i = 0; i < collapsibleGridElements.length; i++) {
const gridElement = collapsibleGridElements[i];
if (gridElement.dataset.collapseAfterRows === undefined) {
continue;
}
const collapseAfterRows = parseInt(gridElement.dataset.collapseAfterRows);
if (collapseAfterRows == -1) {
continue;
}
const getCardsPerRow = () => {
return parseInt(getComputedStyle(gridElement).getPropertyValue('--cards-per-row'));
};
const button = attachExpandToggleButton(gridElement);
let cardsPerRow = 2;
const resolveCollapsibleItems = () => {
const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
if (hideItemsAfterIndex >= gridElement.children.length) {
button.style.display = "none";
} else {
button.style.removeProperty("display");
}
let row = 0;
for (let i = 0; i < gridElement.children.length; i++) {
const child = gridElement.children[i];
if (i >= hideItemsAfterIndex) {
child.classList.add("collapsible-item");
child.style.animationDelay = (row * 40).toString() + "ms";
if (i % cardsPerRow + 1 == cardsPerRow) {
row++;
}
} else {
child.classList.remove("collapsible-item");
child.style.removeProperty("animation-delay");
}
}
};
afterContentReady(() => {
cardsPerRow = getCardsPerRow();
resolveCollapsibleItems();
});
window.addEventListener("resize", () => {
const newCardsPerRow = getCardsPerRow();
if (cardsPerRow == newCardsPerRow) {
return;
}
cardsPerRow = newCardsPerRow;
resolveCollapsibleItems();
});
}
}
const contentReadyCallbacks = [];
function afterContentReady(callback) {
contentReadyCallbacks.push(callback);
}
const weekDayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
function makeSettableTimeElement(element, hourFormat) {
const fragment = document.createDocumentFragment();
const hour = document.createElement('span');
const minute = document.createElement('span');
const amPm = document.createElement('span');
fragment.append(hour, document.createTextNode(':'), minute);
if (hourFormat == '12h') {
fragment.append(document.createTextNode(' '), amPm);
}
element.append(fragment);
return (date) => {
const hours = date.getHours();
if (hourFormat == '12h') {
amPm.textContent = hours < 12 ? 'AM' : 'PM';
hour.textContent = hours % 12 || 12;
} else {
hour.textContent = hours < 10 ? '0' + hours : hours;
}
const minutes = date.getMinutes();
minute.textContent = minutes < 10 ? '0' + minutes : minutes;
};
};
function timeInZone(now, zone) {
let timeInZone;
try {
timeInZone = new Date(now.toLocaleString('en-US', { timeZone: zone }));
} catch (e) {
// TODO: indicate to the user that this is an invalid timezone
console.error(e);
timeInZone = now
}
const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
return { time: timeInZone, diffInHours: diffInHours };
}
function setupClocks() {
const clocks = document.getElementsByClassName('clock');
if (clocks.length == 0) {
return;
}
const updateCallbacks = [];
for (var i = 0; i < clocks.length; i++) {
const clock = clocks[i];
const hourFormat = clock.dataset.hourFormat;
const localTimeContainer = clock.querySelector('[data-local-time]');
const localDateElement = localTimeContainer.querySelector('[data-date]');
const localWeekdayElement = localTimeContainer.querySelector('[data-weekday]');
const localYearElement = localTimeContainer.querySelector('[data-year]');
const timeZoneContainers = clock.querySelectorAll('[data-time-in-zone]');
const setLocalTime = makeSettableTimeElement(
localTimeContainer.querySelector('[data-time]'),
hourFormat
);
updateCallbacks.push((now) => {
setLocalTime(now);
localDateElement.textContent = now.getDate() + ' ' + monthNames[now.getMonth()];
localWeekdayElement.textContent = weekDayNames[now.getDay()];
localYearElement.textContent = now.getFullYear();
});
for (var z = 0; z < timeZoneContainers.length; z++) {
const timeZoneContainer = timeZoneContainers[z];
const diffElement = timeZoneContainer.querySelector('[data-time-diff]');
const setZoneTime = makeSettableTimeElement(
timeZoneContainer.querySelector('[data-time]'),
hourFormat
);
updateCallbacks.push((now) => {
const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
setZoneTime(time);
diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
});
}
}
const updateClocks = () => {
const now = new Date();
for (var i = 0; i < updateCallbacks.length; i++)
updateCallbacks[i](now);
setTimeout(updateClocks, (60 - now.getSeconds()) * 1000);
};
updateClocks();
}
async function setupPage() { async function setupPage() {
const pageElement = document.getElementById("page"); const pageElement = document.getElementById("page");
const pageContents = await fetchPageContents(pageData.slug); const pageContentElement = document.getElementById("page-content");
const pageContent = await fetchPageContent(pageData.slug);
pageElement.innerHTML = pageContents; pageContentElement.innerHTML = pageContent;
setTimeout(() => { try {
document.body.classList.add("animate-element-transition"); setupClocks()
}, 150); setupCarousels();
setupSearchboxes();
setupCollapsibleLists();
setupCollapsibleGrids();
setupDynamicRelativeTime();
setupLazyImages();
} finally {
pageElement.classList.add("content-ready");
setTimeout(setupLazyImages, 5); for (let i = 0; i < contentReadyCallbacks.length; i++) {
setupCarousels(); contentReadyCallbacks[i]();
setupDynamicRelativeTime(); }
setTimeout(() => {
document.body.classList.add("page-columns-transitioned");
}, 300);
}
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {
+14
View File
@@ -0,0 +1,14 @@
{
"name": "Glance",
"display": "standalone",
"background_color": "#151519",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "/static/app-icon.png",
"type": "image/png",
"sizes": "512x512"
}
]
}
+6 -1
View File
@@ -15,6 +15,7 @@ var (
PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl") PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
PageContentTemplate = compileTemplate("content.html") PageContentTemplate = compileTemplate("content.html")
CalendarTemplate = compileTemplate("calendar.html", "widget-base.html") CalendarTemplate = compileTemplate("calendar.html", "widget-base.html")
ClockTemplate = compileTemplate("clock.html", "widget-base.html")
BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html") BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html")
IFrameTemplate = compileTemplate("iframe.html", "widget-base.html") IFrameTemplate = compileTemplate("iframe.html", "widget-base.html")
WeatherTemplate = compileTemplate("weather.html", "widget-base.html") WeatherTemplate = compileTemplate("weather.html", "widget-base.html")
@@ -22,16 +23,20 @@ var (
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html") RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html") RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html") ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html")
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html") VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html") VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
StocksTemplate = compileTemplate("stocks.html", "widget-base.html") MarketsTemplate = compileTemplate("markets.html", "widget-base.html")
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html") RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html")
RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html") RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html") RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html") MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html") TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html") TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html") RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
SearchTemplate = compileTemplate("search.html", "widget-base.html")
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
) )
var globalTemplateFunctions = template.FuncMap{ var globalTemplateFunctions = template.FuncMap{
@@ -0,0 +1,17 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .ChangeDetections }}
<li>
<a class="size-h4 block text-truncate color-highlight" href="{{ .URL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text">
<li {{ dynamicRelativeTimeAttrs .LastChanged }}></li>
<li class="shrink min-width-0"><a class="visited-indicator" href="{{ .DiffURL }}" target="_blank" rel="noreferrer">diff:{{ .PreviousHash }}</a></li>
</ul>
</li>
{{ else }}
<li>No watches configured</li>
{{ end}}
</ul>
{{ end }}
+30
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 }}
+9 -1
View File
@@ -5,7 +5,15 @@
<title>{{ block "document-title" . }}{{ end }}</title> <title>{{ block "document-title" . }}{{ end }}</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Glance">
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
<link rel="apple-touch-icon" sizes="512x512" href="/static/app-icon.png">
<link rel="icon" type="image/png" sizes="50x50" href="/static/favicon.png">
<link rel="manifest" href="/static/manifest.json">
<link rel="icon" type="image/png" href="/static/favicon.png" /> <link rel="icon" type="image/png" href="/static/favicon.png" />
<link rel="stylesheet" href="/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}"> <link rel="stylesheet" href="/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}">
<script async src="/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script> <script async src="/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script>
+5
View File
@@ -0,0 +1,5 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
{{ .Extension.Content }}
{{ end }}
+23 -17
View File
@@ -1,14 +1,14 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<ul class="list list-gap-14 list-collapsible"> <ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range $i, $post := .Posts }} {{ range .Posts }}
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}> <li>
<div class="forum-post-list-item thumbnail-container"> <div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
{{ if $.ShowThumbnails }} {{ if $.ShowThumbnails }}
{{ if ne $post.ThumbnailUrl "" }} {{ if ne .ThumbnailUrl "" }}
<img class="forum-post-list-thumbnail thumbnail" src="{{ $post.ThumbnailUrl }}" alt="" loading="lazy"> <img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
{{ else if $post.HasTargetUrl }} {{ else if .HasTargetUrl }}
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)"> <svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" /> <path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg> </svg>
@@ -18,14 +18,23 @@
</svg> </svg>
{{ end }} {{ end }}
{{ end }} {{ end }}
<div class="grow"> <div class="grow min-width-0">
<a href="{{ $post.DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a> <a href="{{ .DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
{{ 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"> <ul class="list-horizontal-text">
<li title="{{ $post.TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs $post.TimePosted }}>{{ $post.TimePosted | relativeTime }}</li> <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ $post.Score | formatNumber }} points</li> <li>{{ .Score | formatNumber }} points</li>
<li>{{ $post.CommentCount | formatNumber }} comments</li> <li>{{ .CommentCount | formatNumber }} comments</li>
{{ if $post.HasTargetUrl }} {{ if .HasTargetUrl }}
<li class="shrink min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ $post.TargetUrlDomain }}</a></li> <li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
{{ end }} {{ end }}
</ul> </ul>
</div> </div>
@@ -33,7 +42,4 @@
</li> </li>
{{ end }} {{ end }}
</ul> </ul>
{{ if gt (len .Posts) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }} {{ end }}
@@ -3,36 +3,36 @@
{{ define "widget-content" }} {{ define "widget-content" }}
{{ if ne .Style "dynamic-columns-experimental" }} {{ if ne .Style "dynamic-columns-experimental" }}
<ul class="list list-gap-20 list-with-separator"> <ul class="list list-gap-20 list-with-separator">
{{ range .Stocks }} {{ range .Markets }}
<li class="flex items-center gap-15"> <li class="flex items-center gap-15">
{{ template "stock" . }} {{ template "market" . }}
</li> </li>
{{ end }} {{ end }}
</ul> </ul>
{{ else }} {{ else }}
<div class="dynamic-columns"> <div class="dynamic-columns">
{{ range .Stocks }} {{ range .Markets }}
<div class="flex items-center gap-15"> <div class="flex items-center gap-15">
{{ template "stock" . }} {{ template "market" . }}
</div> </div>
{{ end }} {{ end }}
</div> </div>
{{ end }} {{ end }}
{{ end }} {{ end }}
{{ define "stock" }} {{ define "market" }}
<div class="shrink min-width-0"> <div class="min-width-0">
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a> <a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
<div class="text-truncate">{{ .Name }}</div> <div class="text-truncate">{{ .Name }}</div>
</div> </div>
<a class="stock-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}> <a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
<svg class="stock-chart shrink-0" viewBox="0 0 100 50"> <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> <polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
</svg> </svg>
</a> </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="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 class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
</div> </div>
+4 -4
View File
@@ -22,13 +22,13 @@
{{ define "site" }} {{ define "site" }}
{{ if .IconUrl }} {{ 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 }} {{ end }}
<div> <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"> <ul class="list-horizontal-text">
{{ if not .Status.Error }} {{ if not .Status.Error }}
<li>{{ .StatusText }}</li> <li title="{{ .Status.Code }}">{{ .StatusText }}</li>
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li> <li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
{{ else if .Status.TimedOut }} {{ else if .Status.TimedOut }}
<li class="color-negative">Timed Out</li> <li class="color-negative">Timed Out</li>
@@ -37,7 +37,7 @@
{{ end }} {{ end }}
</ul> </ul>
</div> </div>
{{ if eq .StatusStyle "good" }} {{ if eq .StatusStyle "ok" }}
<div class="monitor-site-status-icon"> <div class="monitor-site-status-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)"> <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" /> <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" />
+3 -5
View File
@@ -50,6 +50,7 @@
<div class="content-bounds"> <div class="content-bounds">
<div class="page" id="page"> <div class="page" id="page">
<div class="page-content" id="page-content"></div>
<div class="page-loading-container"> <div class="page-loading-container">
<!-- TODO: add a bigger/better loading indicator --> <!-- TODO: add a bigger/better loading indicator -->
<div class="loading-icon"></div> <div class="loading-icon"></div>
@@ -59,11 +60,8 @@
<div class="footer flex items-center flex-column"> <div class="footer flex items-center flex-column">
<div> <div>
<span class="size-h3">Glance</span> ({{ .App.Version }}) <a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
</div> </div>
<ul class="list-horizontal-text margin-top-5 size-h5 color-primary"> <a class="color-primary block margin-top-5 size-h5" href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a>
<li><a href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a></li>
<li><a href="https://github.com/glanceapp/glance/discussions" target="_blank" rel="noreferrer">Submit feedback</a></li>
</ul>
</div> </div>
{{ end }} {{ end }}
@@ -20,7 +20,7 @@
{{ end }} {{ end }}
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a> <a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text margin-top-7"> <ul class="list-horizontal-text margin-top-7">
<li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li> <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ .Score | formatNumber }} points</li> <li>{{ .Score | formatNumber }} points</li>
</ul> </ul>
</div> </div>
@@ -19,7 +19,7 @@
{{ end }} {{ end }}
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a> <a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text margin-top-7"> <ul class="list-horizontal-text margin-top-7">
<li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li> <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ .Score | formatNumber }} points</li> <li>{{ .Score | formatNumber }} points</li>
</ul> </ul>
</div> </div>
+3 -6
View File
@@ -1,12 +1,12 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<ul class="list list-gap-10 list-collapsible"> <ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range $i, $release := .Releases }} {{ range $i, $release := .Releases }}
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}> <li>
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a> <a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
<ul class="list-horizontal-text"> <ul class="list-horizontal-text">
<li title="{{ $release.TimeReleased | formatTime }}" {{ dynamicRelativeTimeAttrs $release.TimeReleased }}>{{ $release.TimeReleased | relativeTime }}</li> <li {{ dynamicRelativeTimeAttrs $release.TimeReleased }}></li>
<li>{{ $release.Version }}</li> <li>{{ $release.Version }}</li>
{{ if gt $release.Downvotes 3 }} {{ if gt $release.Downvotes 3 }}
<li>{{ $release.Downvotes | formatNumber }} ⚠</li> <li>{{ $release.Downvotes | formatNumber }} ⚠</li>
@@ -15,7 +15,4 @@
</li> </li>
{{ end }} {{ end }}
</ul> </ul>
{{ if gt (len .Releases) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }} {{ end }}
+2 -2
View File
@@ -13,7 +13,7 @@
<div class="flex gap-7 size-h5 margin-top-3"> <div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2"> <ul class="list list-gap-2">
{{ range .RepositoryDetails.PullRequests }} {{ range .RepositoryDetails.PullRequests }}
<li title="{{ .CreatedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .CreatedAt }}>{{ .CreatedAt | relativeTime }}</li> <li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }} {{ end }}
</ul> </ul>
<ul class="list list-gap-2 min-width-0"> <ul class="list list-gap-2 min-width-0">
@@ -30,7 +30,7 @@
<div class="flex gap-7 size-h5 margin-top-3"> <div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2"> <ul class="list list-gap-2">
{{ range .RepositoryDetails.Issues }} {{ range .RepositoryDetails.Issues }}
<li title="{{ .CreatedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .CreatedAt }}>{{ .CreatedAt | relativeTime }}</li> <li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }} {{ end }}
</ul> </ul>
<ul class="list list-gap-2 min-width-0"> <ul class="list list-gap-2 min-width-0">
@@ -0,0 +1,38 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-24 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Items }}
<li class="flex gap-15 items-start row-reverse-on-mobile thumbnail-parent">
<div class="thumbnail-container rss-detailed-thumbnail">
{{ if ne "" .ImageURL }}
<img class="thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
{{ else }}
<svg class="scale-half hide-on-mobile" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
{{ end }}
</div>
<div class="grow min-width-0">
<a class="size-h3 color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap">
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="min-width-0">
<a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
</li>
</ul>
{{ if ne "" .Description }}
<p class="rss-detailed-description text-truncate-2-lines margin-top-10">{{ .Description }}</p>
{{ end }}
{{ if gt (len .Categories) 0 }}
<ul class="attachments margin-top-10">
{{ range .Categories }}
<li>{{ . }}</li>
{{ end }}
</ul>
{{ end }}
</div>
</li>
{{ end }}
</ul>
{{ end }}
@@ -6,7 +6,7 @@
<div class="carousel-container"> <div class="carousel-container">
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}> <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
{{ range .Items }} {{ range .Items }}
<div class="card rss-card-2 widget-content-frame thumbnail-container"> <div class="card rss-card-2 widget-content-frame thumbnail-parent">
{{ if ne "" .ImageURL }} {{ if ne "" .ImageURL }}
<img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt=""> <img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
{{ else }} {{ else }}
@@ -17,8 +17,8 @@
<div class="rss-card-2-content padding-inline-widget"> <div class="rss-card-2-content padding-inline-widget">
<a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a> <a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-5"> <ul class="list-horizontal-text flex-nowrap margin-top-5">
<li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li> <li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li> <li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -6,7 +6,7 @@
<div class="carousel-container"> <div class="carousel-container">
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}> <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
{{ range .Items }} {{ range .Items }}
<div class="card widget-content-frame thumbnail-container"> <div class="card widget-content-frame thumbnail-parent">
{{ if ne "" .ImageURL }} {{ if ne "" .ImageURL }}
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt=""> <img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
{{ else }} {{ else }}
@@ -17,8 +17,8 @@
<div class="margin-bottom-widget padding-inline-widget flex flex-column grow"> <div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a> <a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-7"> <ul class="list-horizontal-text flex-nowrap margin-top-7">
<li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li> <li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li> <li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
</ul> </ul>
</div> </div>
</div> </div>
+8 -11
View File
@@ -1,20 +1,17 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<ul class="list list-gap-14 list-collapsible"> <ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range $i, $item := .Items }} {{ range .Items }}
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}> <li>
<a class="size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a> <a class="size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text"> <ul class="list-horizontal-text flex-nowrap">
<li title="{{ $item.PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs $item.PublishedAt }}>{{ .PublishedAt | relativeTime }}</li> <li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
{{ if gt (len $.FeedRequests) 1 }} <li class="min-width-0">
<li><a href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a></li> <a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
{{ end }} </li>
</ul> </ul>
</li> </li>
{{ end }} {{ end }}
</ul> </ul>
{{ if gt (len .Items) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }} {{ end }}
+24
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 }}">
<div class="search-bangs">
{{ range .Bangs }}
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
{{ end }}
</div>
<div class="search-icon-container">
<svg class="search-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</div>
<input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off">
<div class="search-bang"></div>
<kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
</div>
{{ end }}
+13 -16
View File
@@ -1,27 +1,27 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<ul class="list list-gap-14 list-collapsible"> <ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range $i, $channel := .Channels }} {{ range .Channels }}
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}> <li>
<div class="{{ if $channel.IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-container"> <div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
<div class="twitch-channel-avatar-container"> <div class="twitch-channel-avatar-container">
{{ if $channel.Exists }} {{ if .Exists }}
<img class="twitch-channel-avatar thumbnail" src="{{ $channel.AvatarUrl }}" alt="" loading="lazy"> <img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
{{ else }} {{ else }}
<svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg> </svg>
{{ end }} {{ end }}
</div> </div>
<div class="shrink min-width-0"> <div class="min-width-0">
<a href="https://twitch.tv/{{ $channel.Login }}" class="size-h3{{ if $channel.IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ $channel.Name }}</a> <a href="https://twitch.tv/{{ .Login }}" class="size-h3{{ if .IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ .Name }}</a>
{{ if $channel.Exists }} {{ if .Exists }}
{{ if $channel.IsLive }} {{ if .IsLive }}
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ $channel.CategorySlug }}" target="_blank" rel="noreferrer">{{ $channel.Category }}</a> <a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a>
<ul class="list-horizontal-text"> <ul class="list-horizontal-text">
<li title="{{ $channel.LiveSince | formatTime }}" {{ dynamicRelativeTimeAttrs $channel.LiveSince }}>{{ $channel.LiveSince | relativeTime }}</li> <li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
<li>{{ $channel.ViewersCount | formatViewerCount }} viewers</li> <li>{{ .ViewersCount | formatViewerCount }} viewers</li>
</ul> </ul>
{{ else }} {{ else }}
<div>Offline</div> <div>Offline</div>
@@ -34,7 +34,4 @@
</li> </li>
{{ end }} {{ end }}
</ul> </ul>
{{ if gt (len .Channels) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }} {{ end }}
@@ -1,26 +1,25 @@
{{ template "widget-base.html" . }} {{ template "widget-base.html" . }}
{{ define "widget-content" }} {{ define "widget-content" }}
<ul class="list list-gap-14 list-collapsible"> <ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range $i, $category := .Categories }} {{ range .Categories }}
{{ $shouldCollapseItem := shouldCollapse $i $.CollapseAfter }} <li class="twitch-category thumbnail-parent">
<li class="twitch-category thumbnail-container{{ if $shouldCollapseItem }} list-collapsible-item{{ end }}" {{ if $shouldCollapseItem }}style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}> <div class="flex gap-10 items-start">
<div class="flex gap-10 items-center"> <img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ .AvatarUrl }}" alt="">
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ $category.AvatarUrl }}" alt=""> <div class="min-width-0">
<div class="shrink 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>
<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-horizontal-text"> <ul class="list-horizontal-text">
<li>{{ $category.ViewersCount | formatViewerCount }} viewers</li> <li>{{ .ViewersCount | formatViewerCount }} viewers</li>
{{ if $category.IsNew }} {{ if .IsNew }}
<li class="color-primary">NEW</li> <li class="color-primary">NEW</li>
{{ end }} {{ end }}
</ul> </ul>
<ul class="list-horizontal-text flex-nowrap"> <ul class="list-horizontal-text flex-nowrap">
{{ range $i, $tag := $category.Tags }} {{ range $i, $tag := .Tags }}
{{ if eq $i 0 }} {{ if eq $i 0 }}
<li class="shrink-0">{{ $tag.Name }}</li> <li class="shrink-0">{{ $tag.Name }}</li>
{{ else }} {{ else }}
<li class="text-truncate shrink min-width-0">{{ $tag.Name }}</li> <li class="text-truncate min-width-0">{{ $tag.Name }}</li>
{{ end }} {{ end }}
{{ end }} {{ end }}
</ul> </ul>
@@ -29,7 +28,4 @@
</li> </li>
{{ end }} {{ end }}
</ul> </ul>
{{ if gt (len .Categories) $.CollapseAfter }}
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
{{ end }}
{{ end }} {{ end }}
@@ -3,8 +3,8 @@
<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget"> <div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a> <a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-7"> <ul class="list-horizontal-text flex-nowrap margin-top-7">
<li class="shrink-0" title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li> <li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li class="shrink min-width-0"> <li class="min-width-0">
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a> <a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
</li> </li>
</ul> </ul>
+2 -2
View File
@@ -3,9 +3,9 @@
{{ define "widget-content-classes" }}widget-content-frameless{{ end }} {{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }} {{ define "widget-content" }}
<div class="cards-grid"> <div class="cards-grid collapsible-container" data-collapse-after-rows="{{ .CollapseAfterRows }}">
{{ range .Videos }} {{ range .Videos }}
<div class="card widget-content-frame thumbnail-container"> <div class="card widget-content-frame thumbnail-parent">
{{ template "video-card-contents" . }} {{ template "video-card-contents" . }}
</div> </div>
{{ end }} {{ end }}
+1 -1
View File
@@ -6,7 +6,7 @@
<div class="carousel-container"> <div class="carousel-container">
<div class="cards-horizontal carousel-items-container"> <div class="cards-horizontal carousel-items-container">
{{ range .Videos }} {{ range .Videos }}
<div class="card widget-content-frame thumbnail-container"> <div class="card widget-content-frame thumbnail-parent">
{{ template "video-card-contents" . }} {{ template "video-card-contents" . }}
</div> </div>
{{ end }} {{ end }}
+139
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
}
+97
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
}
+81
View File
@@ -0,0 +1,81 @@
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(sortBy string, tags []string) (ForumPosts, error) {
var feedUrl string
if sortBy == "hot" {
sortBy = "hottest"
} else if sortBy == "new" {
sortBy = "newest"
}
if len(tags) == 0 {
feedUrl = "https://lobste.rs/" + sortBy + ".json"
} else {
tags := strings.Join(tags, ",")
feedUrl = "https://lobste.rs/t/" + tags + ".json"
}
posts, err := getLobstersPostsFromFeed(feedUrl)
if err != nil {
return nil, err
}
return posts, nil
}
+26 -7
View File
@@ -7,6 +7,11 @@ import (
"time" "time"
) )
type SiteStatusRequest struct {
URL string `yaml:"url"`
AllowInsecure bool `yaml:"allow-insecure"`
}
type SiteStatus struct { type SiteStatus struct {
Code int Code int
TimedOut bool TimedOut bool
@@ -14,14 +19,28 @@ type SiteStatus struct {
Error error Error error
} }
func getSiteStatusTask(request *http.Request) (SiteStatus, error) { func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
request, err := http.NewRequest(http.MethodGet, statusRequest.URL, nil)
if err != nil {
return SiteStatus{
Error: err,
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel() defer cancel()
request = request.WithContext(ctx) request = request.WithContext(ctx)
start := time.Now() requestSentAt := time.Now()
response, err := http.DefaultClient.Do(request) var response *http.Response
took := time.Since(start)
status := SiteStatus{ResponseTime: took} if !statusRequest.AllowInsecure {
response, err = defaultClient.Do(request)
} else {
response, err = defaultInsecureClient.Do(request)
}
status := SiteStatus{ResponseTime: time.Since(requestSentAt)}
if err != nil { if err != nil {
if errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.DeadlineExceeded) {
@@ -29,7 +48,7 @@ func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
} }
status.Error = err status.Error = err
return status, err return status, nil
} }
defer response.Body.Close() defer response.Body.Close()
@@ -39,7 +58,7 @@ func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
return status, nil return status, nil
} }
func FetchStatusesForRequests(requests []*http.Request) ([]SiteStatus, error) { func FetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) {
job := newJob(getSiteStatusTask, requests).withWorkers(20) job := newJob(getSiteStatusTask, requests).withWorkers(20)
results, _, err := workerPoolDo(job) results, _, err := workerPoolDo(job)
+12 -7
View File
@@ -16,6 +16,7 @@ type ForumPost struct {
Score int Score int
Engagement float64 Engagement float64
TimePosted time.Time TimePosted time.Time
Tags []string
} }
type ForumPosts []ForumPost type ForumPosts []ForumPost
@@ -84,20 +85,24 @@ var currencyToSymbol = map[string]string{
"PHP": "₱", "PHP": "₱",
} }
type Stock struct { type MarketRequest struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Symbol string `yaml:"symbol"` Symbol string `yaml:"symbol"`
ChartLink string `yaml:"chart-link"` ChartLink string `yaml:"chart-link"`
SymbolLink string `yaml:"symbol-link"` SymbolLink string `yaml:"symbol-link"`
}
type Market struct {
MarketRequest
Currency string `yaml:"-"` Currency string `yaml:"-"`
Price float64 `yaml:"-"` Price float64 `yaml:"-"`
PercentChange float64 `yaml:"-"` PercentChange float64 `yaml:"-"`
SvgChartPoints string `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 { sort.Slice(t, func(i, j int) bool {
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange) return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
}) })
+13 -1
View File
@@ -2,6 +2,7 @@ package feed
import ( import (
"context" "context"
"crypto/tls"
"encoding/json" "encoding/json"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
@@ -11,8 +12,19 @@ import (
"time" "time"
) )
const defaultClientTimeout = 5 * time.Second
var defaultClient = &http.Client{ 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 { type RequestDoer interface {
+83 -3
View File
@@ -3,8 +3,12 @@ package feed
import ( import (
"context" "context"
"fmt" "fmt"
"html"
"log/slog" "log/slog"
"net/url"
"regexp"
"sort" "sort"
"strings"
"time" "time"
"github.com/mmcdole/gofeed" "github.com/mmcdole/gofeed"
@@ -16,12 +20,35 @@ type RSSFeedItem struct {
Title string Title string
Link string Link string
ImageURL string ImageURL string
Categories []string
Description string
PublishedAt time.Time PublishedAt time.Time
} }
// doesn't cover all cases but works the vast majority of the time
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
func sanitizeFeedDescription(description string) string {
if description == "" {
return ""
}
description = strings.ReplaceAll(description, "\n", " ")
description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
description = strings.TrimSpace(description)
description = html.UnescapeString(description)
return description
}
type RSSFeedRequest struct { type RSSFeedRequest struct {
Url string `yaml:"url"` Url string `yaml:"url"`
Title string `yaml:"title"` Title string `yaml:"title"`
HideCategories bool `yaml:"hide-categories"`
HideDescription bool `yaml:"hide-description"`
ItemLinkPrefix string `yaml:"item-link-prefix"`
} }
type RSSFeedItems []RSSFeedItem type RSSFeedItems []RSSFeedItem
@@ -54,7 +81,60 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
rssItem := RSSFeedItem{ rssItem := RSSFeedItem{
ChannelURL: feed.Link, ChannelURL: feed.Link,
Title: item.Title, 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 !request.HideDescription && item.Description != "" {
description, _ := limitStringLength(item.Description, 1000)
description = sanitizeFeedDescription(description)
description, limited := limitStringLength(description, 200)
if limited {
description += "…"
}
rssItem.Description = description
}
if !request.HideCategories {
var categories = make([]string, 0, 6)
for _, category := range item.Categories {
if len(categories) == 6 {
break
}
if len(category) == 0 || len(category) > 30 {
continue
}
categories = append(categories, category)
}
rssItem.Categories = categories
} }
if request.Title != "" { if request.Title != "" {
+6
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 { type twitchOperationResponse struct {
Data json.RawMessage Data json.RawMessage
Extensions struct { Extensions struct {
+18
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
"regexp"
"slices" "slices"
"strings" "strings"
) )
@@ -77,3 +78,20 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
return values return values
} }
var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
func stripURLScheme(url string) string {
return urlSchemePattern.ReplaceAllString(url, "")
}
func limitStringLength(s string, max int) (string, bool) {
asRunes := []rune(s)
if len(asRunes) > max {
return string(asRunes[:max]), true
}
return s, false
}
+19 -22
View File
@@ -6,7 +6,7 @@ import (
"net/http" "net/http"
) )
type stockResponseJson struct { type marketResponseJson struct {
Chart struct { Chart struct {
Result []struct { Result []struct {
Meta struct { Meta struct {
@@ -25,30 +25,30 @@ type stockResponseJson struct {
} }
// TODO: allow changing chart time frame // TODO: allow changing chart time frame
const stockChartDays = 21 const marketChartDays = 21
func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) { func FetchMarketsDataFromYahoo(marketRequests []MarketRequest) (Markets, error) {
requests := make([]*http.Request, 0, len(stockRequests)) requests := make([]*http.Request, 0, len(marketRequests))
for i := range stockRequests { for i := range marketRequests {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", stockRequests[i].Symbol), nil) 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) requests = append(requests, request)
} }
job := newJob(decodeJsonFromRequestTask[stockResponseJson](defaultClient), requests) job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests)
responses, errs, err := workerPoolDo(job) responses, errs, err := workerPoolDo(job)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: %v", ErrNoContent, err) return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
} }
stocks := make(Stocks, 0, len(responses)) markets := make(Markets, 0, len(responses))
var failed int var failed int
for i := range responses { for i := range responses {
if errs[i] != nil { if errs[i] != nil {
failed++ 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 continue
} }
@@ -56,14 +56,14 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
if len(response.Chart.Result) == 0 { if len(response.Chart.Result) == 0 {
failed++ failed++
slog.Error("Stock response contains no data", "symbol", stockRequests[i].Symbol) slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
continue continue
} }
prices := response.Chart.Result[0].Indicators.Quote[0].Close prices := response.Chart.Result[0].Indicators.Quote[0].Close
if len(prices) > stockChartDays { if len(prices) > marketChartDays {
prices = prices[len(prices)-stockChartDays:] prices = prices[len(prices)-marketChartDays:]
} }
previous := response.Chart.Result[0].Meta.RegularMarketPrice previous := response.Chart.Result[0].Meta.RegularMarketPrice
@@ -80,13 +80,10 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
currency = response.Chart.Result[0].Meta.Currency currency = response.Chart.Result[0].Meta.Currency
} }
stocks = append(stocks, Stock{ markets = append(markets, Market{
Name: stockRequests[i].Name, MarketRequest: marketRequests[i],
Symbol: response.Chart.Result[0].Meta.Symbol, Price: response.Chart.Result[0].Meta.RegularMarketPrice,
SymbolLink: stockRequests[i].SymbolLink, Currency: currency,
ChartLink: stockRequests[i].ChartLink,
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
Currency: currency,
PercentChange: percentChange( PercentChange: percentChange(
response.Chart.Result[0].Meta.RegularMarketPrice, response.Chart.Result[0].Meta.RegularMarketPrice,
previous, previous,
@@ -95,13 +92,13 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
}) })
} }
if len(stocks) == 0 { if len(markets) == 0 {
return nil, ErrNoContent return nil, ErrNoContent
} }
if failed > 0 { 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
} }
+1 -1
View File
@@ -36,7 +36,7 @@ func Main() int {
return 1 return 1
} }
if app.Serve() != nil { if err := app.Serve(); err != nil {
fmt.Printf("http server error: %v\n", err) fmt.Printf("http server error: %v\n", err)
return 1 return 1
} }
+2 -6
View File
@@ -2,7 +2,6 @@ package widget
import ( import (
"html/template" "html/template"
"strings"
"github.com/glanceapp/glance/internal/assets" "github.com/glanceapp/glance/internal/assets"
) )
@@ -34,11 +33,8 @@ func (widget *Bookmarks) Initialize() error {
continue continue
} }
if strings.HasPrefix(widget.Groups[g].Links[l].Icon, "si:") { link := &widget.Groups[g].Links[l]
icon := strings.TrimPrefix(widget.Groups[g].Links[l].Icon, "si:") link.Icon, link.IsSimpleIcon = toSimpleIconIfPrefixed(link.Icon)
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"
}
} }
} }
+66
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
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
}
+59
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
}
+12
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"regexp" "regexp"
"strconv" "strconv"
"strings"
"time" "time"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -150,3 +151,14 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
return nil return nil
} }
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
}
+20
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
}
+56
View File
@@ -0,0 +1,56 @@
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:"-"`
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(30 * time.Minute)
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.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)
}
+17 -22
View File
@@ -2,9 +2,7 @@ package widget
import ( import (
"context" "context"
"fmt"
"html/template" "html/template"
"net/http"
"strconv" "strconv"
"time" "time"
@@ -37,22 +35,23 @@ func statusCodeToText(status int) string {
func statusCodeToStyle(status int) string { func statusCodeToStyle(status int) string {
if status == 200 { if status == 200 {
return "good" return "ok"
} }
return "bad" return "error"
} }
type Monitor struct { type Monitor struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
Sites []struct { Sites []struct {
Title string `yaml:"title"` *feed.SiteStatusRequest `yaml:",inline"`
Url OptionalEnvString `yaml:"url"` Status *feed.SiteStatus `yaml:"-"`
IconUrl string `yaml:"icon"` Title string `yaml:"title"`
SameTab bool `yaml:"same-tab"` IconUrl string `yaml:"icon"`
Status *feed.SiteStatus `yaml:"-"` IsSimpleIcon bool `yaml:"-"`
StatusText string `yaml:"-"` SameTab bool `yaml:"same-tab"`
StatusStyle string `yaml:"-"` StatusText string `yaml:"-"`
StatusStyle string `yaml:"-"`
} `yaml:"sites"` } `yaml:"sites"`
Style string `yaml:"style"` Style string `yaml:"style"`
} }
@@ -60,25 +59,21 @@ type Monitor struct {
func (widget *Monitor) Initialize() error { func (widget *Monitor) Initialize() error {
widget.withTitle("Monitor").withCacheDuration(5 * time.Minute) 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 return nil
} }
func (widget *Monitor) Update(ctx context.Context) { 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 { for i := range widget.Sites {
request, err := http.NewRequest("GET", string(widget.Sites[i].Url), nil) requests[i] = widget.Sites[i].SiteStatusRequest
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
} }
statuses, err := feed.FetchStatusesForRequests(requests) statuses, err := feed.FetchStatusForSites(requests)
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
return return
+11
View File
@@ -39,6 +39,13 @@ func (widget *RSS) Initialize() error {
widget.CardHeight = 0 widget.CardHeight = 0
} }
if widget.Style != "detailed-list" {
for i := range widget.FeedRequests {
widget.FeedRequests[i].HideCategories = true
widget.FeedRequests[i].HideDescription = true
}
}
return nil return nil
} }
@@ -65,5 +72,9 @@ func (widget *RSS) Render() template.HTML {
return widget.render(widget, assets.RSSHorizontalCards2Template) return widget.render(widget, assets.RSSHorizontalCards2Template)
} }
if widget.Style == "detailed-list" {
return widget.render(widget, assets.RSSDetailedListTemplate)
}
return widget.render(widget, assets.RSSListTemplate) return widget.render(widget, assets.RSSListTemplate)
} }
+66
View File
@@ -0,0 +1,66 @@
package widget
import (
"fmt"
"html/template"
"strings"
"github.com/glanceapp/glance/internal/assets"
)
type SearchBang struct {
Title string
Shortcut string
URL string
}
type Search struct {
widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"`
SearchEngine string `yaml:"search-engine"`
Bangs []SearchBang `yaml:"bangs"`
}
func convertSearchUrl(url string) string {
// Go's template is being stubborn and continues to escape the curlies in the
// URL regardless of what the type of the variable is so this is my way around it
return strings.ReplaceAll(url, "{QUERY}", "!QUERY!")
}
var searchEngines = map[string]string{
"duckduckgo": "https://duckduckgo.com/?q={QUERY}",
"google": "https://www.google.com/search?q={QUERY}",
}
func (widget *Search) Initialize() error {
widget.withTitle("Search").withError(nil)
if widget.SearchEngine == "" {
widget.SearchEngine = "duckduckgo"
}
if url, ok := searchEngines[widget.SearchEngine]; ok {
widget.SearchEngine = url
}
widget.SearchEngine = convertSearchUrl(widget.SearchEngine)
for i := range widget.Bangs {
if widget.Bangs[i].Shortcut == "" {
return fmt.Errorf("Search bang %d has no shortcut", i+1)
}
if widget.Bangs[i].URL == "" {
return fmt.Errorf("Search bang %d has no URL", i+1)
}
widget.Bangs[i].URL = convertSearchUrl(widget.Bangs[i].URL)
}
widget.cachedHTML = widget.render(widget, assets.SearchTemplate)
return nil
}
func (widget *Search) Render() template.HTML {
return widget.cachedHTML
}
+19 -14
View File
@@ -9,34 +9,39 @@ import (
"github.com/glanceapp/glance/internal/feed" "github.com/glanceapp/glance/internal/feed"
) )
// TODO: rename to Markets at some point type Markets struct {
type Stocks struct { widgetBase `yaml:",inline"`
widgetBase `yaml:",inline"` StocksRequests []feed.MarketRequest `yaml:"stocks"`
Stocks feed.Stocks `yaml:"stocks"` MarketRequests []feed.MarketRequest `yaml:"markets"`
Sort string `yaml:"sort-by"` Sort string `yaml:"sort-by"`
Style string `yaml:"style"` Style string `yaml:"style"`
Markets feed.Markets `yaml:"-"`
} }
func (widget *Stocks) Initialize() error { func (widget *Markets) Initialize() error {
widget.withTitle("Stocks").withCacheDuration(time.Hour) widget.withTitle("Markets").withCacheDuration(time.Hour)
if len(widget.MarketRequests) == 0 {
widget.MarketRequests = widget.StocksRequests
}
return nil return nil
} }
func (widget *Stocks) Update(ctx context.Context) { func (widget *Markets) Update(ctx context.Context) {
stocks, err := feed.FetchStocksDataFromYahoo(widget.Stocks) markets, err := feed.FetchMarketsDataFromYahoo(widget.MarketRequests)
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
return return
} }
if widget.Sort == "absolute-change" { if widget.Sort == "absolute-change" {
stocks.SortByAbsChange() markets.SortByAbsChange()
} }
widget.Stocks = stocks widget.Markets = markets
} }
func (widget *Stocks) Render() template.HTML { func (widget *Markets) Render() template.HTML {
return widget.render(widget, assets.StocksTemplate) return widget.render(widget, assets.MarketsTemplate)
} }
+11 -1
View File
@@ -14,6 +14,7 @@ type TwitchChannels struct {
ChannelsRequest []string `yaml:"channels"` ChannelsRequest []string `yaml:"channels"`
Channels []feed.TwitchChannel `yaml:"-"` Channels []feed.TwitchChannel `yaml:"-"`
CollapseAfter int `yaml:"collapse-after"` CollapseAfter int `yaml:"collapse-after"`
SortBy string `yaml:"sort-by"`
} }
func (widget *TwitchChannels) Initialize() error { func (widget *TwitchChannels) Initialize() error {
@@ -23,6 +24,10 @@ func (widget *TwitchChannels) Initialize() error {
widget.CollapseAfter = 5 widget.CollapseAfter = 5
} }
if widget.SortBy != "viewers" && widget.SortBy != "live" {
widget.SortBy = "viewers"
}
return nil return nil
} }
@@ -33,7 +38,12 @@ func (widget *TwitchChannels) Update(ctx context.Context) {
return return
} }
channels.SortByViewers() if widget.SortBy == "viewers" {
channels.SortByViewers()
} else if widget.SortBy == "live" {
channels.SortByLive()
}
widget.Channels = channels widget.Channels = channels
} }
+11 -6
View File
@@ -10,12 +10,13 @@ import (
) )
type Videos struct { type Videos struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
Videos feed.Videos `yaml:"-"` Videos feed.Videos `yaml:"-"`
VideoUrlTemplate string `yaml:"video-url-template"` VideoUrlTemplate string `yaml:"video-url-template"`
Style string `yaml:"style"` Style string `yaml:"style"`
Channels []string `yaml:"channels"` CollapseAfterRows int `yaml:"collapse-after-rows"`
Limit int `yaml:"limit"` Channels []string `yaml:"channels"`
Limit int `yaml:"limit"`
} }
func (widget *Videos) Initialize() error { func (widget *Videos) Initialize() error {
@@ -25,6 +26,10 @@ func (widget *Videos) Initialize() error {
widget.Limit = 25 widget.Limit = 25
} }
if widget.CollapseAfterRows == 0 || widget.CollapseAfterRows < -1 {
widget.CollapseAfterRows = 4
}
return nil return nil
} }
+26 -10
View File
@@ -14,17 +14,30 @@ type Weather struct {
Location string `yaml:"location"` Location string `yaml:"location"`
ShowAreaName bool `yaml:"show-area-name"` ShowAreaName bool `yaml:"show-area-name"`
HideLocation bool `yaml:"hide-location"` HideLocation bool `yaml:"hide-location"`
HourFormat string `yaml:"hour-format"`
Units string `yaml:"units"` Units string `yaml:"units"`
Place *feed.PlaceJson `yaml:"-"` Place *feed.PlaceJson `yaml:"-"`
Weather *feed.Weather `yaml:"-"` Weather *feed.Weather `yaml:"-"`
TimeLabels [12]string `yaml:"-"` TimeLabels [12]string `yaml:"-"`
} }
var timeLabels = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"} var timeLabels12h = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"}
var timeLabels24h = [12]string{"02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00", "22:00", "00:00"}
func (widget *Weather) Initialize() error { func (widget *Weather) Initialize() error {
widget.withTitle("Weather").withCacheOnTheHour() widget.withTitle("Weather").withCacheOnTheHour()
widget.TimeLabels = timeLabels
if widget.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 == "" { if widget.Units == "" {
widget.Units = "metric" 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) 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 return nil
} }
func (widget *Weather) Update(ctx context.Context) { 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) weather, err := feed.FetchWeatherForPlace(widget.Place, widget.Units)
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
+14 -2
View File
@@ -19,20 +19,24 @@ func New(widgetType string) (Widget, error) {
switch widgetType { switch widgetType {
case "calendar": case "calendar":
return &Calendar{}, nil return &Calendar{}, nil
case "clock":
return &Clock{}, nil
case "weather": case "weather":
return &Weather{}, nil return &Weather{}, nil
case "bookmarks": case "bookmarks":
return &Bookmarks{}, nil return &Bookmarks{}, nil
case "iframe": case "iframe":
return &IFrame{}, nil return &IFrame{}, nil
case "html":
return &HTML{}, nil
case "hacker-news": case "hacker-news":
return &HackerNews{}, nil return &HackerNews{}, nil
case "releases": case "releases":
return &Releases{}, nil return &Releases{}, nil
case "videos": case "videos":
return &Videos{}, nil return &Videos{}, nil
case "stocks": case "markets", "stocks":
return &Stocks{}, nil return &Markets{}, nil
case "reddit": case "reddit":
return &Reddit{}, nil return &Reddit{}, nil
case "rss": case "rss":
@@ -43,8 +47,16 @@ func New(widgetType string) (Widget, error) {
return &TwitchGames{}, nil return &TwitchGames{}, nil
case "twitch-channels": case "twitch-channels":
return &TwitchChannels{}, nil return &TwitchChannels{}, nil
case "lobsters":
return &Lobsters{}, nil
case "change-detection":
return &ChangeDetection{}, nil
case "repository": case "repository":
return &Repository{}, nil return &Repository{}, nil
case "search":
return &Search{}, nil
case "extension":
return &Extension{}, nil
default: default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType) return nil, fmt.Errorf("unknown widget type: %s", widgetType)
} }
+65 -7
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"flag"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -77,7 +78,40 @@ var buildTargets = []buildTarget{
}, },
} }
func hasUncommitedChanges() (bool, error) {
output, err := exec.Command("git", "status", "--porcelain").CombinedOutput()
if err != nil {
return false, err
}
return len(output) > 0, nil
}
func main() { func main() {
flags := flag.NewFlagSet("", flag.ExitOnError)
specificTag := flags.String("tag", "", "Which tagged version to build")
err := flags.Parse(os.Args[1:])
if err != nil {
fmt.Println(err)
os.Exit(1)
}
uncommitedChanges, err := hasUncommitedChanges()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
if uncommitedChanges {
fmt.Println("There are uncommited changes - commit, stash or discard them first")
os.Exit(1)
}
cwd, err := os.Getwd() cwd, err := os.Getwd()
if err != nil { if err != nil {
@@ -95,10 +129,24 @@ func main() {
os.Mkdir(buildPath, 0755) os.Mkdir(buildPath, 0755)
os.Mkdir(archivesPath, 0755) os.Mkdir(archivesPath, 0755)
version, err := getVersionFromGit() var version string
if *specificTag == "" {
version, err := getVersionFromGit()
if err != nil {
fmt.Println(version, err)
os.Exit(1)
}
} else {
version = *specificTag
}
output, err := exec.Command("git", "checkout", "tags/"+version).CombinedOutput()
if err != nil { if err != nil {
fmt.Println(version, err) fmt.Println(string(output))
fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
@@ -119,13 +167,19 @@ func main() {
fmt.Println("Building docker image") fmt.Println("Building docker image")
output, err := exec.Command( var dockerBuildOptions = []string{
"sudo", "docker", "build", "docker", "build",
"--platform=linux/amd64,linux/arm64,linux/arm/v7", "--platform=linux/amd64,linux/arm64,linux/arm/v7",
"-t", versionTag, "-t", versionTag,
"-t", latestTag, }
".",
).CombinedOutput() if !strings.Contains(version, "beta") {
dockerBuildOptions = append(dockerBuildOptions, "-t", latestTag)
}
dockerBuildOptions = append(dockerBuildOptions, ".")
output, err = exec.Command("sudo", dockerBuildOptions...).CombinedOutput()
if err != nil { if err != nil {
fmt.Println(string(output)) fmt.Println(string(output))
@@ -152,6 +206,10 @@ func main() {
os.Exit(1) os.Exit(1)
} }
if strings.Contains(version, "beta") {
return
}
output, err = exec.Command( output, err = exec.Command(
"sudo", "docker", "push", latestTag, "sudo", "docker", "push", latestTag,
).CombinedOutput() ).CombinedOutput()