瀏覽代碼

Merge branch 'release/v0.5.0' into search

Svilen Markov 1 年之前
父節點
當前提交
3f862f67ab
共有 40 個文件被更改,包括 1064 次插入239 次删除
  1. 128 0
      .github/CODE_OF_CONDUCT.md
  2. 7 0
      .github/PULL_REQUEST_TEMPLATE.md
  3. 9 0
      .github/SECURITY.md
  4. 1 0
      README.md
  5. 57 1
      docs/configuration.md
  6. 二進制
      docs/images/clock-widget-preview.png
  7. 二進制
      internal/assets/static/app-icon.png
  8. 157 110
      internal/assets/static/main.css
  9. 310 23
      internal/assets/static/main.js
  10. 14 0
      internal/assets/static/manifest.json
  11. 2 0
      internal/assets/templates.go
  12. 30 0
      internal/assets/templates/clock.html
  13. 9 1
      internal/assets/templates/document.html
  14. 14 17
      internal/assets/templates/forum-posts.html
  15. 3 5
      internal/assets/templates/page.html
  16. 1 1
      internal/assets/templates/reddit-horizontal-cards.html
  17. 1 1
      internal/assets/templates/reddit-vertical-cards.html
  18. 3 6
      internal/assets/templates/releases.html
  19. 2 2
      internal/assets/templates/repository.html
  20. 38 0
      internal/assets/templates/rss-detailed-list.html
  21. 3 3
      internal/assets/templates/rss-horizontal-cards-2.html
  22. 3 3
      internal/assets/templates/rss-horizontal-cards.html
  23. 8 11
      internal/assets/templates/rss-list.html
  24. 1 1
      internal/assets/templates/stocks.html
  25. 13 16
      internal/assets/templates/twitch-channels.html
  26. 10 14
      internal/assets/templates/twitch-games-list.html
  27. 2 2
      internal/assets/templates/video-card-contents.html
  28. 2 2
      internal/assets/templates/videos-grid.html
  29. 1 1
      internal/assets/templates/videos.html
  30. 57 2
      internal/feed/rss.go
  31. 6 0
      internal/feed/twitch.go
  32. 10 0
      internal/feed/utils.go
  33. 1 1
      internal/glance/main.go
  34. 50 0
      internal/widget/clock.go
  35. 11 0
      internal/widget/rss.go
  36. 11 1
      internal/widget/twitch-channels.go
  37. 11 6
      internal/widget/videos.go
  38. 11 2
      internal/widget/weather.go
  39. 2 0
      internal/widget/widget.go
  40. 65 7
      scripts/build-and-ship/main.go

+ 128 - 0
.github/CODE_OF_CONDUCT.md

@@ -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 - 0
.github/PULL_REQUEST_TEMPLATE.md

@@ -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 - 0
.github/SECURITY.md

@@ -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 - 0
README.md

@@ -11,6 +11,7 @@
 * Weather
 * Bookmarks
 * Latest YouTube videos from specific channels
+* Clock
 * Calendar
 * Stocks
 * iframe

+ 57 - 1
docs/configuration.md

@@ -17,6 +17,7 @@
   - [Repository](#repository)
   - [Bookmarks](#bookmarks)
   - [Calendar](#calendar)
+  - [Clock](#clock)
   - [Stocks](#stocks)
   - [Twitch Channels](#twitch-channels)
   - [Twitch Top Games](#twitch-top-games)
@@ -35,6 +36,7 @@ pages:
     columns:
       - size: small
         widgets:
+          - type: clock
           - type: calendar
 
           - type: rss
@@ -648,6 +650,7 @@ Example:
 ```yaml
 - type: weather
   units: metric
+  hour-format: 12h
   location: London, United Kingdom
 ```
 
@@ -672,6 +675,7 @@ Each bar represents a 2 hour interval. The yellow background represents sunrise
 | ---- | ---- | -------- | ------- |
 | location | string | yes |  |
 | units | string | no | metric |
+| hour-format | string | no | 12h |
 | hide-location | boolean | no | false |
 | show-area-name | boolean | no | false |
 
@@ -681,6 +685,9 @@ The name of the city and country to fetch weather information for. Attempting to
 ##### `units`
 Whether to show the temperature in celsius or fahrenheit, possible values are `metric` or `imperial`.
 
+#### `hour-format`
+Whether to show the hours of the day in 12-hour format or 24-hour format. Possible values are `12h` and `24h`.
+
 ##### `hide-location`
 Optionally don't display the location name on the widget.
 
@@ -698,7 +705,7 @@ Greenville, United States
 ```
 
 ### Monitor
-Display a list of sites and whether they are reachable (online) or not. This is determined by sending a HEAD request to the specified URL, if the response is 200 then the site is OK. The time it took to receive a response is also shown in milliseconds.
+Display a list of sites and whether they are reachable (online) or not. This is determined by sending a GET request to the specified URL, if the response is 200 then the site is OK. The time it took to receive a response is also shown in milliseconds.
 
 Example:
 
@@ -959,6 +966,51 @@ Whether to open the link in the same tab or a new one.
 
 Whether to hide the colored arrow on each link.
 
+### Clock
+Display a clock showing the current time and date. Optionally, also display the the time in other timezones.
+
+Example:
+
+```yaml
+- type: clock
+  hour-format: 24h
+  timezones:
+    - timezone: Europe/Paris
+      label: Paris
+    - timezone: America/New_York
+      label: New York
+    - timezone: Asia/Tokyo
+      label: Tokyo
+```
+
+Preview:
+
+![](images/clock-widget-preview.png)
+
+#### Properties
+
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| hour-format | string | no | 24h |
+| timezones | array | no |  |
+
+##### `hour-format`
+Whether to show the time in 12 or 24 hour format. Possible values are `12h` and `24h`.
+
+#### Properties for each timezone
+
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| timezone | string | yes | |
+| label | string | no | |
+
+##### `timezone`
+A timezone identifier such as `Europe/London`, `America/New_York`, etc. The full list of available identifiers can be found [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
+
+##### `label`
+Optionally, override the display value for the timezone to something more meaningful such as "Home", "Work" or anything else.
+
+
 ### Calendar
 Display a calendar.
 
@@ -1064,6 +1116,7 @@ Preview:
 | ---- | ---- | -------- | ------- |
 | channels | array | yes | |
 | collapse-after | integer | no | 5 |
+| sort-by | string | no | viewers |
 
 ##### `channels`
 A list of channels to display.
@@ -1071,6 +1124,9 @@ A list of channels to display.
 ##### `collapse-after`
 How many channels are visible before the "SHOW MORE" button appears. Set to `-1` to never collapse.
 
+##### `sort-by`
+Can be used to specify the order in which the channels are displayed. Possible values are `viewers` and `live`.
+
 ### Twitch top games
 Display a list of games with the most viewers on Twitch.
 

二進制
docs/images/clock-widget-preview.png


二進制
internal/assets/static/app-icon.png


+ 157 - 110
internal/assets/static/main.css

@@ -37,6 +37,7 @@
 
     --ths: var(--bgh), calc(var(--bgs) * var(--tsm));
     --color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
+    --color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%));
     --color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
     --color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
 
@@ -57,6 +58,14 @@
     font-size: var(--font-size-h4);
 }
 
+.page-content, .page.content-ready .page-loading-container {
+    display: none;
+}
+
+.page.content-ready > .page-content {
+    display: block;
+}
+
 .page-column-full .size-title-dynamic {
     font-size: var(--font-size-h3);
 }
@@ -71,14 +80,16 @@
     white-space: nowrap;
 }
 
-.text-truncate-3-lines {
+.text-truncate-2-lines, .text-truncate-3-lines {
     overflow: hidden;
     text-overflow: ellipsis;
-    -webkit-line-clamp: 3;
     display: -webkit-box;
     -webkit-box-orient: vertical;
 }
 
+.text-truncate-3-lines { -webkit-line-clamp: 3; }
+.text-truncate-2-lines { -webkit-line-clamp: 2; }
+
 .visited-indicator:not(.text-truncate)::after,
 .visited-indicator.text-truncate::before,
 .bookmarks-link:not(.bookmarks-link-no-arrow)::after {
@@ -106,6 +117,7 @@
 .list-gap-14 { --list-half-gap: 0.7rem; }
 .list-gap-20 { --list-half-gap: 1rem; }
 .list-gap-24 { --list-half-gap: 1.2rem; }
+.list-gap-34 { --list-half-gap: 1.7rem; }
 
 .list > *:not(:first-child) {
     margin-top: calc(var(--list-half-gap) * 2);
@@ -117,70 +129,85 @@
     padding-top: var(--list-half-gap);
 }
 
-@keyframes listItemReveal {
-    from {
-        opacity: 0;
-        transform: translateY(10px);
-    }
-}
-
-.list-collapsible-item {
+.collapsible-container:not(.container-expanded) > .collapsible-item {
     display: none;
-    animation: listItemReveal 0.3s backwards;
-    animation-delay: var(--animation-delay);
 }
 
-.list-collapsible-label {
-    display: flex;
-    align-items: center;
-    gap: 1rem;
-    padding: var(--widget-content-vertical-padding) 0;
-    background: var(--color-widget-background);
+.collapsible-item {
+    animation: collapsibleItemReveal .25s backwards;
 }
 
-.list-collapsible-label:has(.list-collapsible-input:checked) {
-    position: sticky;
-    bottom: 0;
-}
-
-.list-collapsible:has(+ .list-collapsible-label > .list-collapsible-input:checked) .list-collapsible-item {
-    display: block;
-}
-
-.list-collapsible-input {
-    display: none;
+@keyframes collapsibleItemReveal {
+    from {
+        opacity: 0;
+        transform: translateY(10px);
+    }
 }
 
-.list-collapsible-label::before, .list-collapsible-label::after {
+.expand-toggle-button {
+    font: inherit;
+    border: 0;
     cursor: pointer;
     display: block;
+    width: 100%;
+    text-align: left;
+    color: var(--color-text-base);
+    text-transform: uppercase;
+    font-size: var(--font-size-h4);
+    padding: var(--widget-content-vertical-padding) 0;
+    background: var(--color-widget-background);
 }
 
-.list-collapsible-label::before {
-    content: 'SHOW MORE';
-    font-size: var(--font-size-h4);
+.expand-toggle-button.container-expanded {
+    position: sticky;
+    /* -1px to hide 1px gap on chrome */
+    bottom: -1px;
 }
 
-.list-collapsible-label:has(.list-collapsible-input:checked)::before {
-    content: 'SHOW LESS';
+.expand-toggle-button-icon {
+    display: inline-block;
+    margin-left: 1rem;
+    position: relative;
+    top: -.2rem;
 }
 
-.list-collapsible-label::after {
+.expand-toggle-button-icon::before {
     content: '';
     font-size: 0.8rem;
     transform: rotate(90deg);
     line-height: 1;
+    display: inline-block;
     transition: transform 0.3s;
 }
 
-.list-collapsible-label:has(.list-collapsible-input:checked)::after {
+.expand-toggle-button.container-expanded .expand-toggle-button-icon::before {
     transform: rotate(-90deg);
 }
 
-.widget-content:has(.list-collapsible-label:last-child) {
+.widget-content:has(.expand-toggle-button:last-child) {
     padding-bottom: 0;
 }
 
+.cards-grid.collapsible-container + .expand-toggle-button {
+    text-align: center;
+    margin-top: 0.5rem;
+    background-color: var(--color-background);
+}
+
+.attachments {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+    margin-left: -0.5rem;
+}
+
+.attachments > * {
+    border-radius: var(--border-radius);
+    padding: 0.1rem 0.5rem;
+    font-size: var(--font-size-h6);
+    background-color: var(--color-separator);
+}
+
 ::selection {
     background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
     color: var(--color-text-highlight);
@@ -706,7 +733,7 @@ body {
     flex-direction: column;
     width: calc(100% / 12);
     padding-top: 3px;
-    max-width: 3.5rem;
+    max-width: 30px;
 }
 
 .weather-column-value, .weather-columns:hover .weather-column-value {
@@ -840,6 +867,10 @@ body {
     transform: translate(-50%, -50%);
 }
 
+.clock-time span {
+    color: var(--color-text-highlight);
+}
+
 .monitor-site-icon {
     display: block;
     opacity: 0.8;
@@ -866,11 +897,22 @@ body {
 
 .thumbnail {
     filter: grayscale(0.2) contrast(0.9);
-    transition: all 0.2s;
     opacity: 0.8;
+    transition: filter 0.2s, opacity .2s;
 }
 
-.thumbnail-container:hover .thumbnail {
+.thumbnail-container {
+    flex-shrink: 0;
+    border: 1px solid var(--color-separator);
+    border-radius: var(--border-radius);
+}
+
+.thumbnail-container > * {
+    border-radius: var(--border-radius);
+    object-fit: cover;
+}
+
+.thumbnail-parent:hover .thumbnail {
     opacity: 1;
     filter: none;
 }
@@ -918,6 +960,20 @@ body {
     z-index: 3;
 }
 
+.rss-detailed-description {
+    max-width: 55rem;
+    color: var(--color-text-base-muted);
+}
+
+.rss-detailed-thumbnail {
+    margin-top: 0.3rem;
+}
+
+.rss-detailed-thumbnail > * {
+    aspect-ratio: 3 / 2;
+    height: 8.7rem;
+}
+
 .twitch-category-thumbnail {
     width: 5rem;
     border-radius: var(--border-radius);
@@ -996,10 +1052,10 @@ body {
 
     .page-column {
         display: none;
-        animation: columnEntrance 0s cubic-bezier(0.25, 1, 0.5, 1) backwards;
+        animation: columnEntrance .0s cubic-bezier(0.25, 1, 0.5, 1) backwards;
     }
 
-    .animate-element-transition .page-column {
+    .page-columns-transitioned .page-column {
         animation-duration: .3s;
     }
 
@@ -1107,9 +1163,48 @@ body {
         box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor;
     }
 
-    .list-collapsible-label:has(.list-collapsible-input:checked) {
+    .expand-toggle-button.container-expanded {
         bottom: var(--mobile-navigation-height);
     }
+
+    .cards-grid + .expand-toggle-button.container-expanded {
+        /* hides content that peeks through the rounded borders of the mobile navigation */
+        box-shadow: 0 var(--border-radius) 0 0 var(--color-background);
+    }
+
+    .weather-column-rain::before {
+        background-size: 7px 7px;
+    }
+}
+
+@media (max-width: 1190px) and (display-mode: standalone) {
+    :root {
+        --safe-area-inset-bottom: env(safe-area-inset-bottom, 0);
+    }
+
+    .list-collapsible-label:has(.list-collapsible-input:checked) {
+        bottom: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
+    }
+
+    .mobile-navigation {
+        transform: translateY(calc(100% - var(--mobile-navigation-height) - var(--safe-area-inset-bottom)));
+        padding-bottom: var(--safe-area-inset-bottom);
+    }
+
+    .mobile-navigation-icons {
+        padding-bottom: var(--safe-area-inset-bottom);
+        transition: padding-bottom .3s;
+    }
+
+    .mobile-navigation-icons:has(.mobile-navigation-page-links-input:checked) {
+        padding-bottom: 0;
+    }
+}
+
+@media (display-mode: standalone) {
+    body {
+        padding-top: env(safe-area-inset-top, 0);
+    }
 }
 
 @media (max-width: 550px) {
@@ -1123,22 +1218,30 @@ body {
 
     .dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
 
-    .forum-post-list-item {
-        flex-flow: row-reverse;
+    .row-reverse-on-mobile {
+        flex-direction: row-reverse;
     }
 
-    .hide-on-mobile {
+    .hide-on-mobile, .thumbnail-container:has(> .hide-on-mobile) {
         display: none
     }
 
     .mobile-reachability-header {
         display: block;
         font-size: 3rem;
-        padding: 10dvh 1rem;
+        padding: 10vh 1rem;
         text-align: center;
         color: var(--color-text-highlight);
         animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
     }
+
+    .rss-detailed-thumbnail > * {
+        height: 6rem;
+    }
+
+    .rss-detailed-description {
+        -webkit-line-clamp: 3;
+    }
 }
 
 .size-h1   { font-size: var(--font-size-h1); }
@@ -1166,6 +1269,7 @@ body {
 .shrink             { flex-shrink: 1; }
 .shrink-0           { flex-shrink: 0; }
 .min-width-0        { min-width: 0; }
+.max-width-100      { max-width: 100%; }
 .block              { display: block; }
 .overflow-hidden    { overflow: hidden; }
 .relative           { position: relative; }
@@ -1185,6 +1289,10 @@ body {
 .gap-7              { gap: 0.7rem; }
 .gap-10             { gap: 1rem; }
 .gap-15             { gap: 1.5rem; }
+.gap-25             { gap: 2.5rem; }
+.gap-35             { gap: 3.5rem; }
+.gap-45             { gap: 4.5rem; }
+.gap-55             { gap: 5.5rem; }
 .margin-top-3       { margin-top: 0.3rem; }
 .margin-top-5       { margin-top: 0.5rem; }
 .margin-top-7       { margin-top: 0.7rem; }
@@ -1201,65 +1309,4 @@ body {
 .margin-bottom-10   { margin-bottom: 1rem; }
 .margin-bottom-15   { margin-bottom: 1.5rem; }
 .margin-bottom-auto { margin-bottom: auto; }
-
-.search-form {
-    margin: 0;
-    padding: var(--widget-content-padding);
-    background-color: var(--color-background);
-    border-radius: 20px;
-    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
-    width: 100%;
-    display: flex;
-    align-items: center;
-}
-
-.search-input-container {
-    position: relative;
-    width: 100%;
-}
-
-.search-input {
-    width: 100%;
-    padding: 10px 40px 10px 10px;
-    font-size: var(--font-size-h2);
-    border: 1px solid var(--color-widget-content-border);
-    border-radius: 20px;
-    color: white;
-    background: var(--color-background);
-    transition: border-color 0.15s ease;
-    font-family: 'JetBrains Mono', monospace;
-}
-
-.search-input:focus {
-    outline: none;
-    border-color: var(--color-primary);
-}
-
-.search-input:placeholder-shown {
-    color: var(--color-text-subdue);
-    font-family: 'JetBrains Mono', monospace;
-}
-
-.search-button {
-    position: absolute;
-    right: 10px;
-    top: 50%;
-    transform: translateY(-50%);
-    background: none;
-    border: none;
-    cursor: pointer;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    padding: 0;
-}
-
-.search-button svg {
-    width: 24px;
-    height: 24px;
-    color: white;
-}
-
-.search-button:hover svg {
-    color: var(--color-text-highlight);
-}
+.scale-half         { transform: scale(0.5); }

+ 310 - 23
internal/assets/static/main.js

@@ -21,7 +21,7 @@ function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
 };
 
 
-async function fetchPageContents (pageSlug) {
+async function fetchPageContent(pageSlug) {
     // TODO: handle non 200 status codes/time outs
     // TODO: add retries
     const response = await fetch(`/api/pages/${pageSlug}/content/`);
@@ -33,8 +33,13 @@ async function fetchPageContents (pageSlug) {
 function setupCarousels() {
     const carouselElements = document.getElementsByClassName("carousel-container");
 
+    if (carouselElements.length == 0) {
+        return;
+    }
+
     for (let i = 0; i < carouselElements.length; i++) {
         const carousel = carouselElements[i];
+        carousel.classList.add("show-right-cutoff");
         const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
 
         const determineSideCutoffs = () => {
@@ -54,9 +59,9 @@ function setupCarousels() {
         const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
 
         itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
-        document.addEventListener("resize", determineSideCutoffsRateLimited);
+        window.addEventListener("resize", determineSideCutoffsRateLimited);
 
-        determineSideCutoffs();
+        afterContentReady(determineSideCutoffs);
     }
 }
 
@@ -98,7 +103,7 @@ function updateRelativeTimeForElements(elements)
         if (timestamp === undefined)
             continue
 
-        element.innerText = relativeTimeSince(timestamp);
+        element.textContent = relativeTimeSince(timestamp);
     }
 }
 
@@ -107,6 +112,8 @@ function setupDynamicRelativeTime() {
     const updateInterval = 60 * 1000;
     let lastUpdateTime = Date.now();
 
+    updateRelativeTimeForElements(elements);
+
     const updateElementsAndTimestamp = () => {
         updateRelativeTimeForElements(elements);
         lastUpdateTime = Date.now();
@@ -153,35 +160,315 @@ function setupLazyImages() {
         image.classList.add("finished-transition");
     }
 
-    for (let i = 0; i < images.length; i++) {
-        const image = images[i];
+    afterContentReady(() => {
+        setTimeout(() => {
+            for (let i = 0; i < images.length; i++) {
+                const image = images[i];
+
+                if (image.complete) {
+                    image.classList.add("cached");
+                    setTimeout(() => imageFinishedTransition(image), 1);
+                } else {
+                    // TODO: also handle error event
+                    image.addEventListener("load", () => {
+                        image.classList.add("loaded");
+                        setTimeout(() => imageFinishedTransition(image), 400);
+                    });
+                }
+            }
+        }, 1);
+    });
+}
+
+function attachExpandToggleButton(collapsibleContainer) {
+    const showMoreText = "Show more";
+    const showLessText = "Show less";
+
+    let expanded = false;
+    const button = document.createElement("button");
+    const icon = document.createElement("span");
+    icon.classList.add("expand-toggle-button-icon");
+    const textNode = document.createTextNode(showMoreText);
+    button.classList.add("expand-toggle-button");
+    button.append(textNode, icon);
+    button.addEventListener("click", () => {
+        expanded = !expanded;
+
+        if (expanded) {
+            collapsibleContainer.classList.add("container-expanded");
+            button.classList.add("container-expanded");
+            textNode.nodeValue = showLessText;
+            return;
+        }
+
+        const topBefore = button.getClientRects()[0].top;
+
+        collapsibleContainer.classList.remove("container-expanded");
+        button.classList.remove("container-expanded");
+        textNode.nodeValue = showMoreText;
+
+        const topAfter = button.getClientRects()[0].top;
+
+        if (topAfter > 0)
+            return;
+
+        window.scrollBy({
+            top: topAfter - topBefore,
+            behavior: "instant"
+        });
+    });
+
+    collapsibleContainer.after(button);
+
+    return button;
+};
+
+
+function setupCollapsibleLists() {
+    const collapsibleLists = document.querySelectorAll(".list.collapsible-container");
+
+    if (collapsibleLists.length == 0) {
+        return;
+    }
+
+    for (let i = 0; i < collapsibleLists.length; i++) {
+        const list = collapsibleLists[i];
+
+        if (list.dataset.collapseAfter === undefined) {
+            continue;
+        }
+
+        const collapseAfter = parseInt(list.dataset.collapseAfter);
+
+        if (collapseAfter == -1) {
+            continue;
+        }
+
+        if (list.children.length <= collapseAfter) {
+            continue;
+        }
+
+        attachExpandToggleButton(list);
+
+        for (let c = collapseAfter; c < list.children.length; c++) {
+            const child = list.children[c];
+            child.classList.add("collapsible-item");
+            child.style.animationDelay = ((c - collapseAfter) * 20).toString() + "ms";
+        }
+    }
+}
+
+function setupCollapsibleGrids() {
+    const collapsibleGridElements = document.querySelectorAll(".cards-grid.collapsible-container");
+
+    if (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);
 
-        if (image.complete) {
-            image.classList.add("cached");
-            setTimeout(() => imageFinishedTransition(image), 5);
+    return (date) => {
+        const hours = date.getHours();
+
+        if (hourFormat == '12h') {
+            amPm.textContent = hours < 12 ? 'AM' : 'PM';
+            hour.textContent = hours % 12 || 12;
         } else {
-            // TODO: also handle error event
-            image.addEventListener("load", () => {
-                image.classList.add("loaded");
-                setTimeout(() => imageFinishedTransition(image), 500);
+            hour.textContent = hours < 10 ? '0' + hours : hours;
+        }
+
+        const minutes = date.getMinutes();
+        minute.textContent = minutes < 10 ? '0' + minutes : minutes;
+    };
+};
+
+function timeInZone(now, zone) {
+    let timeInZone;
+
+    try {
+        timeInZone = new Date(now.toLocaleString('en-US', { timeZone: zone }));
+    } catch (e) {
+        // TODO: indicate to the user that this is an invalid timezone
+        console.error(e);
+        timeInZone = now
+    }
+
+    const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
+
+    return { time: timeInZone, diffInHours: diffInHours };
+}
+
+function setupClocks() {
+    const clocks = document.getElementsByClassName('clock');
+
+    if (clocks.length == 0) {
+        return;
+    }
+
+    const updateCallbacks = [];
+
+    for (var i = 0; i < clocks.length; i++) {
+        const clock = clocks[i];
+        const hourFormat = clock.dataset.hourFormat;
+        const localTimeContainer = clock.querySelector('[data-local-time]');
+        const localDateElement = localTimeContainer.querySelector('[data-date]');
+        const localWeekdayElement = localTimeContainer.querySelector('[data-weekday]');
+        const localYearElement = localTimeContainer.querySelector('[data-year]');
+        const timeZoneContainers = clock.querySelectorAll('[data-time-in-zone]');
+
+        const setLocalTime = makeSettableTimeElement(
+            localTimeContainer.querySelector('[data-time]'),
+            hourFormat
+        );
+
+        updateCallbacks.push((now) => {
+            setLocalTime(now);
+            localDateElement.textContent = now.getDate() + ' ' + monthNames[now.getMonth()];
+            localWeekdayElement.textContent = weekDayNames[now.getDay()];
+            localYearElement.textContent = now.getFullYear();
+        });
+
+        for (var z = 0; z < timeZoneContainers.length; z++) {
+            const timeZoneContainer = timeZoneContainers[z];
+            const diffElement = timeZoneContainer.querySelector('[data-time-diff]');
+
+            const setZoneTime = makeSettableTimeElement(
+                timeZoneContainer.querySelector('[data-time]'),
+                hourFormat
+            );
+
+            updateCallbacks.push((now) => {
+                const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
+                setZoneTime(time);
+                diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
             });
         }
     }
+
+    const updateClocks = () => {
+        const now = new Date();
+
+        for (var i = 0; i < updateCallbacks.length; i++)
+            updateCallbacks[i](now);
+
+        setTimeout(updateClocks, (60 - now.getSeconds()) * 1000);
+    };
+
+    updateClocks();
 }
 
 async function setupPage() {
     const pageElement = document.getElementById("page");
-    const pageContents = await fetchPageContents(pageData.slug);
-
-    pageElement.innerHTML = pageContents;
-
-    setTimeout(() => {
-        document.body.classList.add("animate-element-transition");
-    }, 150);
+    const pageContentElement = document.getElementById("page-content");
+    const pageContent = await fetchPageContent(pageData.slug);
+
+    pageContentElement.innerHTML = pageContent;
+
+    try {
+        setupClocks()
+        setupCarousels();
+        setupCollapsibleLists();
+        setupCollapsibleGrids();
+        setupDynamicRelativeTime();
+        setupLazyImages();
+    } finally {
+        pageElement.classList.add("content-ready");
+
+        for (let i = 0; i < contentReadyCallbacks.length; i++) {
+            contentReadyCallbacks[i]();
+        }
 
-    setTimeout(setupLazyImages, 5);
-    setupCarousels();
-    setupDynamicRelativeTime();
+        setTimeout(() => {
+            document.body.classList.add("page-columns-transitioned");
+        }, 300);
+    }
 }
 
 if (document.readyState === "loading") {

+ 14 - 0
internal/assets/static/manifest.json

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

+ 2 - 0
internal/assets/templates.go

@@ -15,6 +15,7 @@ var (
 	PageTemplate                  = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
 	PageContentTemplate           = compileTemplate("content.html")
 	CalendarTemplate              = compileTemplate("calendar.html", "widget-base.html")
+	ClockTemplate                 = compileTemplate("clock.html", "widget-base.html")
 	BookmarksTemplate             = compileTemplate("bookmarks.html", "widget-base.html")
 	IFrameTemplate                = compileTemplate("iframe.html", "widget-base.html")
 	WeatherTemplate               = compileTemplate("weather.html", "widget-base.html")
@@ -26,6 +27,7 @@ var (
 	VideosGridTemplate            = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
 	StocksTemplate                = compileTemplate("stocks.html", "widget-base.html")
 	RSSListTemplate               = compileTemplate("rss-list.html", "widget-base.html")
+	RSSDetailedListTemplate       = compileTemplate("rss-detailed-list.html", "widget-base.html")
 	RSSHorizontalCardsTemplate    = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
 	RSSHorizontalCards2Template   = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
 	MonitorTemplate               = compileTemplate("monitor.html", "widget-base.html")

+ 30 - 0
internal/assets/templates/clock.html

@@ -0,0 +1,30 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+<div class="clock" data-hour-format="{{ .HourFormat }}">
+    <div class="flex justify-between items-center" data-local-time>
+        <div>
+            <div class="color-highlight size-h1" data-date></div>
+            <div data-year></div>
+        </div>
+        <div class="text-right">
+            <div class="clock-time size-h1" data-time></div>
+            <div data-weekday></div>
+        </div>
+    </div>
+    {{ if gt (len .Timezones) 0 }}
+    <hr class="margin-block-10">
+    <ul class="list list-gap-10">
+        {{ range .Timezones }}
+        <li class="flex items-center gap-15" data-time-in-zone="{{ .Timezone }}">
+            <div class="grow min-width-0">
+                <div class="text-truncate">{{ if ne .Label "" }}{{ .Label }}{{ else }}{{ .Timezone }}{{ end }}</div>
+            </div>
+            <div class="color-subdue" data-time-diff></div>
+            <div class="size-h4 clock-time shrink-0 text-right" data-time></div>
+        </li>
+        {{ end }}
+    </ul>
+    {{ end }}
+</div>
+{{ end }}

+ 9 - 1
internal/assets/templates/document.html

@@ -5,7 +5,15 @@
     <title>{{ block "document-title" . }}{{ end }}</title>
     <meta charset="UTF-8">
     <meta name="color-scheme" content="dark">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <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="stylesheet" href="/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}">
     <script async src="/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script>

+ 14 - 17
internal/assets/templates/forum-posts.html

@@ -1,14 +1,14 @@
 {{ template "widget-base.html" . }}
 
 {{ define "widget-content" }}
-<ul class="list list-gap-14 list-collapsible">
-    {{ range $i, $post := .Posts }}
-    <li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
-        <div class="forum-post-list-item thumbnail-container">
+<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
+    {{ range .Posts }}
+    <li>
+        <div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
             {{ if $.ShowThumbnails }}
-                {{ if ne $post.ThumbnailUrl "" }}
-                <img class="forum-post-list-thumbnail thumbnail" src="{{ $post.ThumbnailUrl }}" alt="" loading="lazy">
-                {{ else if $post.HasTargetUrl }}
+                {{ if ne .ThumbnailUrl "" }}
+                <img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
+                {{ else if .HasTargetUrl }}
                 <svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
                     <path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
                 </svg>
@@ -18,14 +18,14 @@
                 </svg>
                 {{ end }}
             {{ end }}
-            <div class="grow">
-                <a href="{{ $post.DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
+            <div class="grow min-width-0">
+                <a href="{{ .DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
                 <ul class="list-horizontal-text">
-                    <li title="{{ $post.TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs $post.TimePosted }}>{{ $post.TimePosted | relativeTime }}</li>
-                    <li>{{ $post.Score | formatNumber }} points</li>
-                    <li>{{ $post.CommentCount | formatNumber }} comments</li>
-                    {{ if $post.HasTargetUrl }}
-                    <li class="shrink min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ $post.TargetUrlDomain }}</a></li>
+                    <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
+                    <li>{{ .Score | formatNumber }} points</li>
+                    <li>{{ .CommentCount | formatNumber }} comments</li>
+                    {{ if .HasTargetUrl }}
+                    <li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
                     {{ end }}
                 </ul>
             </div>
@@ -33,7 +33,4 @@
     </li>
     {{ end }}
 </ul>
-{{ if gt (len .Posts) $.CollapseAfter }}
-<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
-{{ end }}
 {{ end }}

+ 3 - 5
internal/assets/templates/page.html

@@ -50,6 +50,7 @@
 
 <div class="content-bounds">
     <div class="page" id="page">
+        <div class="page-content" id="page-content"></div>
         <div class="page-loading-container">
             <!-- TODO: add a bigger/better loading indicator -->
             <div class="loading-icon"></div>
@@ -59,11 +60,8 @@
 
 <div class="footer flex items-center flex-column">
     <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>
-    <ul class="list-horizontal-text margin-top-5 size-h5 color-primary">
-        <li><a href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a></li>
-        <li><a href="https://github.com/glanceapp/glance/discussions" target="_blank" rel="noreferrer">Submit feedback</a></li>
-    </ul>
+    <a class="color-primary block margin-top-5 size-h5" href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a>
 </div>
 {{ end }}

+ 1 - 1
internal/assets/templates/reddit-horizontal-cards.html

@@ -20,7 +20,7 @@
                 {{ end }}
                 <a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
                 <ul class="list-horizontal-text margin-top-7">
-                    <li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
+                    <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
                     <li>{{ .Score | formatNumber }} points</li>
                 </ul>
             </div>

+ 1 - 1
internal/assets/templates/reddit-vertical-cards.html

@@ -19,7 +19,7 @@
             {{ end }}
             <a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
             <ul class="list-horizontal-text margin-top-7">
-                <li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
+                <li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
                 <li>{{ .Score | formatNumber }} points</li>
             </ul>
         </div>

+ 3 - 6
internal/assets/templates/releases.html

@@ -1,12 +1,12 @@
 {{ template "widget-base.html" . }}
 
 {{ 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 }}
-    <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>
         <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>
             {{ if gt $release.Downvotes 3 }}
             <li>{{ $release.Downvotes | formatNumber }} ⚠</li>
@@ -15,7 +15,4 @@
     </li>
     {{ end }}
 </ul>
-{{ if gt (len .Releases) $.CollapseAfter }}
-<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
-{{ end }}
 {{ end }}

+ 2 - 2
internal/assets/templates/repository.html

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

+ 38 - 0
internal/assets/templates/rss-detailed-list.html

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

+ 3 - 3
internal/assets/templates/rss-horizontal-cards-2.html

@@ -6,7 +6,7 @@
 <div class="carousel-container">
     <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
         {{ range .Items }}
-        <div class="card rss-card-2 widget-content-frame thumbnail-container">
+        <div class="card rss-card-2 widget-content-frame thumbnail-parent">
             {{ if ne "" .ImageURL }}
             <img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
             {{ else }}
@@ -17,8 +17,8 @@
             <div class="rss-card-2-content padding-inline-widget">
                 <a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
                 <ul class="list-horizontal-text flex-nowrap margin-top-5">
-                    <li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
-                    <li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
+                    <li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
+                    <li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
                 </ul>
             </div>
         </div>

+ 3 - 3
internal/assets/templates/rss-horizontal-cards.html

@@ -6,7 +6,7 @@
 <div class="carousel-container">
     <div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
         {{ range .Items }}
-        <div class="card widget-content-frame thumbnail-container">
+        <div class="card widget-content-frame thumbnail-parent">
             {{ if ne "" .ImageURL }}
             <img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
             {{ else }}
@@ -17,8 +17,8 @@
             <div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
                 <a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
                 <ul class="list-horizontal-text flex-nowrap margin-top-7">
-                    <li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
-                    <li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
+                    <li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
+                    <li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
                 </ul>
             </div>
         </div>

+ 8 - 11
internal/assets/templates/rss-list.html

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

+ 1 - 1
internal/assets/templates/stocks.html

@@ -21,7 +21,7 @@
 {{ end }}
 
 {{ define "stock" }}
-<div class="shrink min-width-0">
+<div class="min-width-0">
     <a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
     <div class="text-truncate">{{ .Name }}</div>
 </div>

+ 13 - 16
internal/assets/templates/twitch-channels.html

@@ -1,27 +1,27 @@
 {{ template "widget-base.html" . }}
 
 {{ define "widget-content" }}
-<ul class="list list-gap-14 list-collapsible">
-    {{ range $i, $channel := .Channels }}
-    <li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
-        <div class="{{ if $channel.IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-container">
+<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
+    {{ range .Channels }}
+    <li>
+        <div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
             <div class="twitch-channel-avatar-container">
-                {{ if $channel.Exists }}
-                <img class="twitch-channel-avatar thumbnail" src="{{ $channel.AvatarUrl }}" alt="" loading="lazy">
+                {{ if .Exists }}
+                <img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
                 {{ else }}
                 <svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
                     <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
                 </svg>
                 {{ end }}
             </div>
-            <div class="shrink min-width-0">
-                <a href="https://twitch.tv/{{ $channel.Login }}" class="size-h3{{ if $channel.IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ $channel.Name }}</a>
-                {{ if $channel.Exists }}
-                    {{ if $channel.IsLive }}
-                    <a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ $channel.CategorySlug }}" target="_blank" rel="noreferrer">{{ $channel.Category }}</a>
+            <div class="min-width-0">
+                <a href="https://twitch.tv/{{ .Login }}" class="size-h3{{ if .IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ .Name }}</a>
+                {{ if .Exists }}
+                    {{ if .IsLive }}
+                    <a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a>
                     <ul class="list-horizontal-text">
-                        <li title="{{ $channel.LiveSince | formatTime }}" {{ dynamicRelativeTimeAttrs $channel.LiveSince }}>{{ $channel.LiveSince | relativeTime }}</li>
-                        <li>{{ $channel.ViewersCount | formatViewerCount }} viewers</li>
+                        <li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
+                        <li>{{ .ViewersCount | formatViewerCount }} viewers</li>
                     </ul>
                     {{ else }}
                     <div>Offline</div>
@@ -34,7 +34,4 @@
     </li>
     {{ end }}
 </ul>
-{{ if gt (len .Channels) $.CollapseAfter }}
-<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
-{{ end }}
 {{ end }}

+ 10 - 14
internal/assets/templates/twitch-games-list.html

@@ -1,26 +1,25 @@
 {{ template "widget-base.html" . }}
 
 {{ define "widget-content" }}
-<ul class="list list-gap-14 list-collapsible">
-    {{ range $i, $category := .Categories }}
-    {{ $shouldCollapseItem := shouldCollapse $i $.CollapseAfter }}
-    <li class="twitch-category thumbnail-container{{ if $shouldCollapseItem }} list-collapsible-item{{ end }}" {{ if $shouldCollapseItem }}style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
+<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
+    {{ range .Categories }}
+    <li class="twitch-category thumbnail-parent">
         <div class="flex gap-10 items-center">
-            <img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ $category.AvatarUrl }}" alt="">
-            <div class="shrink min-width-0">
-                <a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ $category.Slug }}" target="_blank" rel="noreferrer">{{ $category.Name }}</a>
+            <img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ .AvatarUrl }}" alt="">
+            <div class="min-width-0">
+                <a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
                 <ul class="list-horizontal-text">
-                    <li>{{ $category.ViewersCount | formatViewerCount }} viewers</li>
-                    {{ if $category.IsNew }}
+                    <li>{{ .ViewersCount | formatViewerCount }} viewers</li>
+                    {{ if .IsNew }}
                     <li class="color-primary">NEW</li>
                     {{ end }}
                 </ul>
                 <ul class="list-horizontal-text flex-nowrap">
-                    {{ range $i, $tag := $category.Tags }}
+                    {{ range $i, $tag := .Tags }}
                         {{ if eq $i 0 }}
                         <li class="shrink-0">{{ $tag.Name }}</li>
                         {{ else }}
-                        <li class="text-truncate shrink min-width-0">{{ $tag.Name }}</li>
+                        <li class="text-truncate min-width-0">{{ $tag.Name }}</li>
                         {{ end }}
                     {{ end }}
                 </ul>
@@ -29,7 +28,4 @@
     </li>
     {{ end }}
 </ul>
-{{ if gt (len .Categories) $.CollapseAfter }}
-<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
-{{ end }}
 {{ end }}

+ 2 - 2
internal/assets/templates/video-card-contents.html

@@ -3,8 +3,8 @@
 <div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
     <a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
     <ul class="list-horizontal-text flex-nowrap margin-top-7">
-        <li class="shrink-0" title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
-        <li class="shrink min-width-0">
+        <li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
+        <li class="min-width-0">
             <a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
         </li>
     </ul>

+ 2 - 2
internal/assets/templates/videos-grid.html

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

+ 1 - 1
internal/assets/templates/videos.html

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

+ 57 - 2
internal/feed/rss.go

@@ -3,8 +3,11 @@ package feed
 import (
 	"context"
 	"fmt"
+	"html"
 	"log/slog"
+	"regexp"
 	"sort"
+	"strings"
 	"time"
 
 	"github.com/mmcdole/gofeed"
@@ -16,12 +19,34 @@ type RSSFeedItem struct {
 	Title       string
 	Link        string
 	ImageURL    string
+	Categories  []string
+	Description string
 	PublishedAt time.Time
 }
 
+// doesn't cover all cases but works the vast majority of the time
+var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
+var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
+
+func sanitizeFeedDescription(description string) string {
+	if description == "" {
+		return ""
+	}
+
+	description = strings.ReplaceAll(description, "\n", " ")
+	description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
+	description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
+	description = strings.TrimSpace(description)
+	description = html.UnescapeString(description)
+
+	return description
+}
+
 type RSSFeedRequest struct {
-	Url   string `yaml:"url"`
-	Title string `yaml:"title"`
+	Url             string `yaml:"url"`
+	Title           string `yaml:"title"`
+	HideCategories  bool   `yaml:"hide-categories"`
+	HideDescription bool   `yaml:"hide-description"`
 }
 
 type RSSFeedItems []RSSFeedItem
@@ -57,6 +82,36 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
 			Link:       item.Link,
 		}
 
+		if !request.HideDescription && item.Description != "" {
+			description, _ := limitStringLength(item.Description, 1000)
+			description = sanitizeFeedDescription(description)
+			description, limited := limitStringLength(description, 200)
+
+			if limited {
+				description += "…"
+			}
+
+			rssItem.Description = description
+		}
+
+		if !request.HideCategories {
+			var categories = make([]string, 0, 6)
+
+			for _, category := range item.Categories {
+				if len(categories) == 6 {
+					break
+				}
+
+				if len(category) == 0 || len(category) > 30 {
+					continue
+				}
+
+				categories = append(categories, category)
+			}
+
+			rssItem.Categories = categories
+		}
+
 		if request.Title != "" {
 			rssItem.ChannelName = request.Title
 		} else {

+ 6 - 0
internal/feed/twitch.go

@@ -44,6 +44,12 @@ func (channels TwitchChannels) SortByViewers() {
 	})
 }
 
+func (channels TwitchChannels) SortByLive() {
+	sort.SliceStable(channels, func(i, j int) bool {
+		return channels[i].IsLive && !channels[j].IsLive
+	})
+}
+
 type twitchOperationResponse struct {
 	Data       json.RawMessage
 	Extensions struct {

+ 10 - 0
internal/feed/utils.go

@@ -77,3 +77,13 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
 
 	return values
 }
+
+func limitStringLength(s string, max int) (string, bool) {
+	asRunes := []rune(s)
+
+	if len(asRunes) > max {
+		return string(asRunes[:max]), true
+	}
+
+	return s, false
+}

+ 1 - 1
internal/glance/main.go

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

+ 50 - 0
internal/widget/clock.go

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

+ 11 - 0
internal/widget/rss.go

@@ -39,6 +39,13 @@ func (widget *RSS) Initialize() error {
 		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
 }
 
@@ -65,5 +72,9 @@ func (widget *RSS) Render() template.HTML {
 		return widget.render(widget, assets.RSSHorizontalCards2Template)
 	}
 
+	if widget.Style == "detailed-list" {
+		return widget.render(widget, assets.RSSDetailedListTemplate)
+	}
+
 	return widget.render(widget, assets.RSSListTemplate)
 }

+ 11 - 1
internal/widget/twitch-channels.go

@@ -14,6 +14,7 @@ type TwitchChannels struct {
 	ChannelsRequest []string             `yaml:"channels"`
 	Channels        []feed.TwitchChannel `yaml:"-"`
 	CollapseAfter   int                  `yaml:"collapse-after"`
+	SortBy          string               `yaml:"sort-by"`
 }
 
 func (widget *TwitchChannels) Initialize() error {
@@ -23,6 +24,10 @@ func (widget *TwitchChannels) Initialize() error {
 		widget.CollapseAfter = 5
 	}
 
+	if widget.SortBy != "viewers" && widget.SortBy != "live" {
+		widget.SortBy = "viewers"
+	}
+
 	return nil
 }
 
@@ -33,7 +38,12 @@ func (widget *TwitchChannels) Update(ctx context.Context) {
 		return
 	}
 
-	channels.SortByViewers()
+	if widget.SortBy == "viewers" {
+		channels.SortByViewers()
+	} else if widget.SortBy == "live" {
+		channels.SortByLive()
+	}
+
 	widget.Channels = channels
 }
 

+ 11 - 6
internal/widget/videos.go

@@ -10,12 +10,13 @@ import (
 )
 
 type Videos struct {
-	widgetBase       `yaml:",inline"`
-	Videos           feed.Videos `yaml:"-"`
-	VideoUrlTemplate string      `yaml:"video-url-template"`
-	Style            string      `yaml:"style"`
-	Channels         []string    `yaml:"channels"`
-	Limit            int         `yaml:"limit"`
+	widgetBase        `yaml:",inline"`
+	Videos            feed.Videos `yaml:"-"`
+	VideoUrlTemplate  string      `yaml:"video-url-template"`
+	Style             string      `yaml:"style"`
+	CollapseAfterRows int         `yaml:"collapse-after-rows"`
+	Channels          []string    `yaml:"channels"`
+	Limit             int         `yaml:"limit"`
 }
 
 func (widget *Videos) Initialize() error {
@@ -25,6 +26,10 @@ func (widget *Videos) Initialize() error {
 		widget.Limit = 25
 	}
 
+	if widget.CollapseAfterRows == 0 || widget.CollapseAfterRows < -1 {
+		widget.CollapseAfterRows = 4
+	}
+
 	return nil
 }
 

+ 11 - 2
internal/widget/weather.go

@@ -14,17 +14,26 @@ type Weather struct {
 	Location     string          `yaml:"location"`
 	ShowAreaName bool            `yaml:"show-area-name"`
 	HideLocation bool            `yaml:"hide-location"`
+	HourFormat   string          `yaml:"hour-format"`
 	Units        string          `yaml:"units"`
 	Place        *feed.PlaceJson `yaml:"-"`
 	Weather      *feed.Weather   `yaml:"-"`
 	TimeLabels   [12]string      `yaml:"-"`
 }
 
-var timeLabels = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"}
+var timeLabels12h = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"}
+var timeLabels24h = [12]string{"02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00", "22:00", "00:00"}
 
 func (widget *Weather) Initialize() error {
 	widget.withTitle("Weather").withCacheOnTheHour()
-	widget.TimeLabels = timeLabels
+
+	if widget.HourFormat == "" || widget.HourFormat == "12h" {
+		widget.TimeLabels = timeLabels12h
+	} else if widget.HourFormat == "24h" {
+		widget.TimeLabels = timeLabels24h
+	} else {
+		return fmt.Errorf("invalid hour format '%s' for weather widget, must be either 12h or 24h", widget.HourFormat)
+	}
 
 	if widget.Units == "" {
 		widget.Units = "metric"

+ 2 - 0
internal/widget/widget.go

@@ -19,6 +19,8 @@ func New(widgetType string) (Widget, error) {
 	switch widgetType {
 	case "calendar":
 		return &Calendar{}, nil
+	case "clock":
+		return &Clock{}, nil
 	case "weather":
 		return &Weather{}, nil
 	case "bookmarks":

+ 65 - 7
scripts/build-and-ship/main.go

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"flag"
 	"fmt"
 	"os"
 	"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() {
+	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()
 
 	if err != nil {
@@ -95,10 +129,24 @@ func main() {
 	os.Mkdir(buildPath, 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 {
-		fmt.Println(version, err)
+		fmt.Println(string(output))
+		fmt.Println(err)
 		os.Exit(1)
 	}
 
@@ -119,13 +167,19 @@ func main() {
 
 	fmt.Println("Building docker image")
 
-	output, err := exec.Command(
-		"sudo", "docker", "build",
+	var dockerBuildOptions = []string{
+		"docker", "build",
 		"--platform=linux/amd64,linux/arm64,linux/arm/v7",
 		"-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 {
 		fmt.Println(string(output))
@@ -152,6 +206,10 @@ func main() {
 		os.Exit(1)
 	}
 
+	if strings.Contains(version, "beta") {
+		return
+	}
+
 	output, err = exec.Command(
 		"sudo", "docker", "push", latestTag,
 	).CombinedOutput()