Browse Source

Merge branch 'release/v0.6.0' into features

Svilen Markov 11 tháng trước cách đây
mục cha
commit
37164070d2

+ 80 - 4
docs/configuration.md

@@ -3,6 +3,7 @@
 - [Intro](#intro)
 - [Preconfigured page](#preconfigured-page)
 - [Server](#server)
+- [Branding](#branding)
 - [Theme](#theme)
   - [Themes](#themes)
 - [Pages & Columns](#pages--columns)
@@ -174,6 +175,42 @@ To be able to point to an asset from your assets path, use the `/assets/` path l
 icon: /assets/gitea-icon.png
 ```
 
+## Branding
+You can adjust the various parts of the branding through a top level `branding` property. Example:
+
+```yaml
+branding:
+  custom-footer: |
+    <p>Powered by <a href="https://github.com/glanceapp/glance">Glance</a></p>
+  logo-url: /assets/logo.png
+  favicon-url: /assets/logo.png
+```
+
+### Properties
+
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| hide-footer | bool | no | false |
+| custom-footer | string | no |  |
+| logo-text | string | no | G |
+| logo-url | string | no | |
+| favicon-url | string | no | |
+
+#### `hide-footer`
+Hides the footer when set to `true`.
+
+#### `custom-footer`
+Specify custom HTML to use for the footer.
+
+#### `logo-text`
+Specify custom text to use instead of the "G" found in the navigation.
+
+#### `logo-url`
+Specify a URL to a custom image to use instead of the "G" found in the navigation. If both `logo-text` and `logo-url` are set, only `logo-url` will be used.
+
+#### `favicon-url`
+Specify a URL to a custom image to use for the favicon.
+
 ## Theme
 Theming is done through a top level `theme` property. Values for the colors are in [HSL](https://giggster.com/guide/basics/hue-saturation-lightness/) (hue, saturation, lightness) format. You can use a color picker [like this one](https://hslpicker.com/) to convert colors from other formats to HSL. The values are separated by a space and `%` is not required for any of the numbers.
 
@@ -1063,17 +1100,19 @@ Whether to ignore invalid/self-signed certificates.
 Whether to open the link in the same or a new tab.
 
 ### Releases
-Display a list of releases for specific repositories on Github. Draft releases and prereleases will not be shown.
+Display a list of latest releases for specific repositories on Github, GitLab or Docker Hub.
 
 Example:
 
 ```yaml
 - type: releases
+  show-source-icon: true
   repositories:
-    - immich-app/immich
     - go-gitea/gitea
-    - dani-garcia/vaultwarden
     - jellyfin/jellyfin
+    - glanceapp/glance
+    - gitlab:fdroid/fdroidclient
+    - dockerhub:gotify/server
 ```
 
 Preview:
@@ -1085,12 +1124,41 @@ Preview:
 | Name | Type | Required | Default |
 | ---- | ---- | -------- | ------- |
 | repositories | array | yes |  |
+| show-source-icon | boolean | no | false |  |
 | token | string | no | |
+| gitlab-token | string | no | |
 | limit | integer | no | 10 |
 | collapse-after | integer | no | 5 |
 
 ##### `repositories`
-A list of repositores for which to fetch the latest release for. Only the name/repo is required, not the full URL.
+A list of repositores to fetch the latest release for. Only the name/repo is required, not the full URL. A prefix can be specified for repositories hosted elsewhere such as GitLab and Docker Hub. Example:
+
+```yaml
+repositories:
+  - gitlab:inkscape/inkscape
+  - dockerhub:glanceapp/glance
+```
+
+Official images on Docker Hub can be specified by ommiting the owner:
+
+```yaml
+repositories:
+  - dockerhub:nginx
+  - dockerhub:node
+  - dockerhub:alpine
+```
+
+You can also specify specific tags for Docker Hub images:
+
+```yaml
+repositories:
+  - dockerhub:nginx:latest
+  - dockerhub:nginx:stable-alpine
+```
+
+
+##### `show-source-icon`
+Shows an icon of the source (GitHub/GitLab/Docker Hub) next to the repository name when set to `true`.
 
 ##### `token`
 Without authentication Github allows for up to 60 requests per hour. You can easily exceed this limit and start seeing errors if you're tracking lots of repositories or your cache time is low. To circumvent this you can [create a read only token from your Github account](https://github.com/settings/personal-access-tokens/new) and provide it here.
@@ -1115,6 +1183,9 @@ and then use it in your `glance.yml` like this:
 
 This way you can safely check your `glance.yml` in version control without exposing the token.
 
+##### `gitlab-token`
+Same as the above but used when fetching GitLab releases.
+
 ##### `limit`
 The maximum number of releases to show.
 
@@ -1181,6 +1252,7 @@ Example:
   repository: glanceapp/glance
   pull-requests-limit: 5
   issues-limit: 3
+  commits-limit: 3
 ```
 
 Preview:
@@ -1195,6 +1267,7 @@ Preview:
 | token | string | no | |
 | pull-requests-limit | integer | no | 3 |
 | issues-limit | integer | no | 3 |
+| commits-limit | integer | no | -1 |
 
 ##### `repository`
 The owner and repository name that will have their information displayed.
@@ -1208,6 +1281,9 @@ The maximum number of latest open pull requests to show. Set to `-1` to not show
 ##### `issues-limit`
 The maximum number of latest open issues to show. Set to `-1` to not show any.
 
+##### `commits-limit`
+The maximum number of lastest commits to show from the default branch. Set to `-1` to not show any.
+
 ### Bookmarks
 Display a list of links which can be grouped.
 

BIN
docs/images/releases-widget-preview.png


+ 1 - 0
internal/assets/static/icons/dockerhub.svg

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

+ 1 - 0
internal/assets/static/icons/github.svg

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

+ 1 - 0
internal/assets/static/icons/gitlab.svg

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

+ 21 - 4
internal/assets/static/main.css

@@ -782,6 +782,15 @@ details[open] .summary::after {
     padding-right: var(--widget-content-horizontal-padding);
 }
 
+.logo:has(img) {
+    display: flex;
+    align-items: center;
+}
+
+.logo img {
+    max-height: 2.7rem;
+}
+
 .nav {
     height: 100%;
     gap: var(--header-items-gap);
@@ -820,6 +829,13 @@ details[open] .summary::after {
     color: var(--color-text-highlight);
 }
 
+.release-source-icon {
+    width: 16px;
+    height: 16px;
+    flex-shrink: 0;
+    opacity: 0.4;
+}
+
 .market-chart {
     margin-left: auto;
     width: 6.5rem;
@@ -997,6 +1013,7 @@ details[open] .summary::after {
     background-color: var(--color-widget-background-highlight);
     border-radius: var(--border-radius);
     padding: 0.5rem;
+    opacity: 0.7;
 }
 
 .bookmarks-icon {
@@ -1005,10 +1022,6 @@ details[open] .summary::after {
     opacity: 0.8;
 }
 
-.simple-icon {
-    opacity: 0.7;
-}
-
 :root:not(.light-scheme) .simple-icon {
     filter: invert(1);
 }
@@ -1307,6 +1320,10 @@ details[open] .summary::after {
     transition: filter 0.3s, opacity 0.3s;
 }
 
+.monitor-site-icon.simple-icon {
+    opacity: 0.7;
+}
+
 .monitor-site:hover .monitor-site-icon {
     filter: grayscale(0);
     opacity: 1;

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

@@ -12,9 +12,8 @@
     <meta name="apple-mobile-web-app-title" content="Glance">
     <meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
     <link rel="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}">
-    <link rel="icon" type="image/png" sizes="50x50" href="{{ .App.AssetPath "favicon.png" }}">
     <link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
-    <link rel="icon" type="image/png" href="{{ .App.AssetPath "favicon.png" }}" />
+    <link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
     <link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
     <script type="module" src="{{ .App.AssetPath "js/main.js" }}"></script>
     {{ block "document-head-after" . }}{{ end }}

+ 7 - 1
internal/assets/templates/page.html

@@ -32,7 +32,7 @@
     <div class="header-container content-bounds">
         <div class="header flex padding-inline-widget widget-content-frame">
             <!-- TODO: Replace G with actual logo, first need an actual logo -->
-            <div class="logo">G</div>
+            <div class="logo">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
             <div class="nav flex grow">
                 {{ template "navigation-links" . }}
             </div>
@@ -63,11 +63,17 @@
         </div>
     </div>
 
+    {{ if not .App.Config.Branding.HideFooter }}
     <div class="footer flex items-center flex-column">
+    {{ if eq "" .App.Config.Branding.CustomFooter }}
         <div>
             <a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
         </div>
+    {{ else }}
+        {{ .App.Config.Branding.CustomFooter }}
+    {{ end }}
     </div>
+    {{ end }}
 
     <div class="mobile-navigation-offset"></div>
 </div>

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

@@ -2,14 +2,19 @@
 
 {{ define "widget-content" }}
 <ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
-    {{ range $i, $release := .Releases }}
+    {{ range .Releases }}
     <li>
-        <a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
+        <div class="flex items-center gap-10">
+            <a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ .NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
+            {{ if $.ShowSourceIcon }}
+            <img class="simple-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
+            {{ end }}
+        </div>
         <ul class="list-horizontal-text">
-            <li {{ dynamicRelativeTimeAttrs $release.TimeReleased }}></li>
-            <li>{{ $release.Version }}</li>
-            {{ if gt $release.Downvotes 3 }}
-            <li>{{ $release.Downvotes | formatNumber }} ⚠</li>
+            <li {{ dynamicRelativeTimeAttrs .TimeReleased }}></li>
+            <li>{{ .Version }}</li>
+            {{ if gt .Downvotes 3 }}
+            <li>{{ .Downvotes | formatNumber }} ⚠</li>
             {{ end }}
         </ul>
     </li>

+ 17 - 0
internal/assets/templates/repository.html

@@ -7,6 +7,23 @@
     <li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
 </ul>
 
+{{ if gt (len .RepositoryDetails.Commits) 0 }}
+<hr class="margin-block-10">
+<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
+<div class="flex gap-7 size-h5 margin-top-3">
+    <ul class="list list-gap-2">
+        {{ range .RepositoryDetails.Commits }}
+        <li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
+        {{ end }}
+    </ul>
+    <ul class="list list-gap-2 min-width-0">
+        {{ range .RepositoryDetails.Commits }}
+        <li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Author }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/commit/{{ .Sha }}">{{ .Message }}</a></li>
+        {{ end }}
+    </ul>
+</div>
+{{ end }}
+
 {{ if gt (len .RepositoryDetails.PullRequests) 0 }}
 <hr class="margin-block-10">
 <a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)</a>

+ 102 - 0
internal/feed/dockerhub.go

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

+ 73 - 70
internal/feed/github.go

@@ -2,8 +2,8 @@ package feed
 
 import (
 	"fmt"
-	"log/slog"
 	"net/http"
+	"strings"
 	"sync"
 	"time"
 )
@@ -17,85 +17,41 @@ type githubReleaseLatestResponseJson struct {
 	} `json:"reactions"`
 }
 
-func parseGithubTime(t string) time.Time {
-	parsedTime, err := time.Parse("2006-01-02T15:04:05Z", t)
+func fetchLatestGithubRelease(request *ReleaseRequest) (*AppRelease, error) {
+	httpRequest, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository),
+		nil,
+	)
 
 	if err != nil {
-		return time.Now()
+		return nil, err
 	}
 
-	return parsedTime
-}
-
-func FetchLatestReleasesFromGithub(repositories []string, token string) (AppReleases, error) {
-	appReleases := make(AppReleases, 0, len(repositories))
-
-	if len(repositories) == 0 {
-		return appReleases, nil
+	if request.Token != nil {
+		httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
 	}
 
-	requests := make([]*http.Request, len(repositories))
-
-	for i, repository := range repositories {
-		request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repository), nil)
-
-		if token != "" {
-			request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
-		}
-
-		requests[i] = request
-	}
-
-	task := decodeJsonFromRequestTask[githubReleaseLatestResponseJson](defaultClient)
-	job := newJob(task, requests).withWorkers(15)
-	responses, errs, err := workerPoolDo(job)
+	response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest)
 
 	if err != nil {
 		return nil, err
 	}
 
-	var failed int
+	version := response.TagName
 
-	for i := range responses {
-		if errs[i] != nil {
-			failed++
-			slog.Error("Failed to fetch or parse github release", "error", errs[i], "url", requests[i].URL)
-			continue
-		}
-
-		liveRelease := &responses[i]
-
-		if liveRelease == nil {
-			slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
-			continue
-		}
-
-		version := liveRelease.TagName
-
-		if version[0] != 'v' {
-			version = "v" + version
-		}
-
-		appReleases = append(appReleases, AppRelease{
-			Name:         repositories[i],
-			Version:      version,
-			NotesUrl:     liveRelease.HtmlUrl,
-			TimeReleased: parseGithubTime(liveRelease.PublishedAt),
-			Downvotes:    liveRelease.Reactions.Downvotes,
-		})
+	if len(version) > 0 && version[0] != 'v' {
+		version = "v" + version
 	}
 
-	if len(appReleases) == 0 {
-		return nil, ErrNoContent
-	}
-
-	appReleases.SortByNewest()
-
-	if failed > 0 {
-		return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
-	}
-
-	return appReleases, nil
+	return &AppRelease{
+		Source:       ReleaseSourceGithub,
+		Name:         request.Repository,
+		Version:      version,
+		NotesUrl:     response.HtmlUrl,
+		TimeReleased: parseRFC3339Time(response.PublishedAt),
+		Downvotes:    response.Reactions.Downvotes,
+	}, nil
 }
 
 type GithubTicket struct {
@@ -112,6 +68,8 @@ type RepositoryDetails struct {
 	PullRequests     []GithubTicket
 	OpenIssues       int
 	Issues           []GithubTicket
+	LastCommits      int
+	Commits          []CommitDetails
 }
 
 type githubRepositoryDetailsResponseJson struct {
@@ -129,21 +87,40 @@ type githubTicketResponseJson struct {
 	} `json:"items"`
 }
 
-func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int) (RepositoryDetails, error) {
-	repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
+type CommitDetails struct {
+	Sha       string
+	Author    string
+	CreatedAt time.Time
+	Message   string
+}
 
+type gitHubCommitResponseJson struct {
+	Sha    string `json:"sha"`
+	Commit struct {
+		Author struct {
+			Name string `json:"name"`
+			Date string `json:"date"`
+		} `json:"author"`
+		Message string `json:"message"`
+	} `json:"commit"`
+}
+
+func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int, maxCommits int) (RepositoryDetails, error) {
+	repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
 	if err != nil {
 		return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err)
 	}
 
 	PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil)
 	issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil)
+	CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repository, maxCommits), nil)
 
 	if token != "" {
 		token = fmt.Sprintf("Bearer %s", token)
 		repositoryRequest.Header.Add("Authorization", token)
 		PRsRequest.Header.Add("Authorization", token)
 		issuesRequest.Header.Add("Authorization", token)
+		CommitsRequest.Header.Add("Authorization", token)
 	}
 
 	var detailsResponse githubRepositoryDetailsResponseJson
@@ -152,6 +129,8 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
 	var PRsErr error
 	var issuesResponse githubTicketResponseJson
 	var issuesErr error
+	var commitsResponse []gitHubCommitResponseJson
+	var CommitsErr error
 	var wg sync.WaitGroup
 
 	wg.Add(1)
@@ -176,6 +155,14 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
 		})()
 	}
 
+	if maxCommits > 0 {
+		wg.Add(1)
+		go (func() {
+			defer wg.Done()
+			commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultClient, CommitsRequest)
+		})()
+	}
+
 	wg.Wait()
 
 	if detailsErr != nil {
@@ -188,6 +175,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
 		Forks:        detailsResponse.Forks,
 		PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
 		Issues:       make([]GithubTicket, 0, len(issuesResponse.Tickets)),
+		Commits:      make([]CommitDetails, 0, len(commitsResponse)),
 	}
 
 	err = nil
@@ -201,7 +189,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
 			for i := range PRsResponse.Tickets {
 				details.PullRequests = append(details.PullRequests, GithubTicket{
 					Number:    PRsResponse.Tickets[i].Number,
-					CreatedAt: parseGithubTime(PRsResponse.Tickets[i].CreatedAt),
+					CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
 					Title:     PRsResponse.Tickets[i].Title,
 				})
 			}
@@ -218,12 +206,27 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
 			for i := range issuesResponse.Tickets {
 				details.Issues = append(details.Issues, GithubTicket{
 					Number:    issuesResponse.Tickets[i].Number,
-					CreatedAt: parseGithubTime(issuesResponse.Tickets[i].CreatedAt),
+					CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
 					Title:     issuesResponse.Tickets[i].Title,
 				})
 			}
 		}
 	}
 
+	if maxCommits > 0 {
+		if CommitsErr != nil {
+			err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, CommitsErr)
+		} else {
+			for i := range commitsResponse {
+				details.Commits = append(details.Commits, CommitDetails{
+					Sha:       commitsResponse[i].Sha,
+					Author:    commitsResponse[i].Commit.Author.Name,
+					CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date),
+					Message:   strings.SplitN(commitsResponse[i].Commit.Message, "\n\n", 2)[0],
+				})
+			}
+		}
+	}
+
 	return details, err
 }

+ 54 - 0
internal/feed/gitlab.go

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

+ 7 - 5
internal/feed/primitives.go

@@ -41,11 +41,13 @@ type Weather struct {
 }
 
 type AppRelease struct {
-	Name         string
-	Version      string
-	NotesUrl     string
-	TimeReleased time.Time
-	Downvotes    int
+	Source        ReleaseSource
+	SourceIconURL string
+	Name          string
+	Version       string
+	NotesUrl      string
+	TimeReleased  time.Time
+	Downvotes     int
 }
 
 type AppReleases []AppRelease

+ 69 - 0
internal/feed/releases.go

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

+ 5 - 1
internal/feed/rss.go

@@ -161,7 +161,11 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
 		} else if url := findThumbnailInItemExtensions(item); url != "" {
 			rssItem.ImageURL = url
 		} else if feed.Image != nil {
-			rssItem.ImageURL = feed.Image.URL
+			if len(feed.Image.URL) > 0 && feed.Image.URL[0] == '/' {
+				rssItem.ImageURL = strings.TrimRight(feed.Link, "/") + feed.Image.URL
+			} else {
+				rssItem.ImageURL = feed.Image.URL
+			}
 		}
 
 		if item.PublishedParsed != nil {

+ 11 - 1
internal/feed/utils.go

@@ -7,6 +7,7 @@ import (
 	"regexp"
 	"slices"
 	"strings"
+	"time"
 )
 
 var (
@@ -79,7 +80,6 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
 	return values
 }
 
-
 var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
 
 func stripURLScheme(url string) string {
@@ -95,3 +95,13 @@ func limitStringLength(s string, max int) (string, bool) {
 
 	return s, false
 }
+
+func parseRFC3339Time(t string) time.Time {
+	parsed, err := time.Parse(time.RFC3339, t)
+
+	if err != nil {
+		return time.Now()
+	}
+
+	return parsed
+}

+ 4 - 3
internal/glance/config.go

@@ -8,9 +8,10 @@ import (
 )
 
 type Config struct {
-	Server Server `yaml:"server"`
-	Theme  Theme  `yaml:"theme"`
-	Pages  []Page `yaml:"pages"`
+	Server   Server   `yaml:"server"`
+	Theme    Theme    `yaml:"theme"`
+	Branding Branding `yaml:"branding"`
+	Pages    []Page   `yaml:"pages"`
 }
 
 func NewConfigFromYml(contents io.Reader) (*Config, error) {

+ 32 - 7
internal/glance/glance.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"context"
 	"fmt"
+	"html/template"
 	"log/slog"
 	"net/http"
 	"path/filepath"
@@ -48,6 +49,14 @@ type Server struct {
 	StartedAt  time.Time `yaml:"-"` // used in custom css file
 }
 
+type Branding struct {
+	HideFooter   bool          `yaml:"hide-footer"`
+	CustomFooter template.HTML `yaml:"custom-footer"`
+	LogoText     string        `yaml:"logo-text"`
+	LogoURL      string        `yaml:"logo-url"`
+	FaviconURL   string        `yaml:"favicon-url"`
+}
+
 type Column struct {
 	Size    string         `yaml:"size"`
 	Widgets widget.Widgets `yaml:"widgets"`
@@ -102,6 +111,14 @@ func titleToSlug(s string) string {
 	return s
 }
 
+func (a *Application) TransformUserDefinedAssetPath(path string) string {
+	if strings.HasPrefix(path, "/assets/") {
+		return a.Config.Server.BaseURL + path
+	}
+
+	return path
+}
+
 func NewApplication(config *Config) (*Application, error) {
 	if len(config.Pages) == 0 {
 		return nil, fmt.Errorf("no pages configured")
@@ -114,8 +131,13 @@ func NewApplication(config *Config) (*Application, error) {
 		widgetByID: make(map[uint64]widget.Widget),
 	}
 
+	app.Config.Server.AssetsHash = assets.PublicFSHash
 	app.slugToPage[""] = &config.Pages[0]
 
+	providers := &widget.Providers{
+		AssetResolver: app.AssetPath,
+	}
+
 	for p := range config.Pages {
 		if config.Pages[p].Slug == "" {
 			config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
@@ -127,6 +149,8 @@ func NewApplication(config *Config) (*Application, error) {
 			for w := range config.Pages[p].Columns[c].Widgets {
 				widget := config.Pages[p].Columns[c].Widgets[w]
 				app.widgetByID[widget.GetID()] = widget
+
+				widget.SetProviders(providers)
 			}
 		}
 	}
@@ -134,13 +158,16 @@ func NewApplication(config *Config) (*Application, error) {
 	config = &app.Config
 
 	config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
+	config.Theme.CustomCSSFile = app.TransformUserDefinedAssetPath(config.Theme.CustomCSSFile)
 
-	if config.Server.BaseURL != "" &&
-		config.Theme.CustomCSSFile != "" &&
-		strings.HasPrefix(config.Theme.CustomCSSFile, "/assets/") {
-		config.Theme.CustomCSSFile = config.Server.BaseURL + config.Theme.CustomCSSFile
+	if config.Branding.FaviconURL == "" {
+		config.Branding.FaviconURL = app.AssetPath("favicon.png")
+	} else {
+		config.Branding.FaviconURL = app.TransformUserDefinedAssetPath(config.Branding.FaviconURL)
 	}
 
+	config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL)
+
 	return app, nil
 }
 
@@ -238,8 +265,6 @@ func (a *Application) AssetPath(asset string) string {
 }
 
 func (a *Application) Serve() error {
-	a.Config.Server.AssetsHash = assets.PublicFSHash
-
 	// TODO: add gzip support, static files must have their gzipped contents cached
 	// TODO: add HTTPS support
 	mux := http.NewServeMux()
@@ -252,7 +277,7 @@ func (a *Application) Serve() error {
 
 	mux.Handle(
 		fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
-		http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 8*time.Hour)),
+		http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)),
 	)
 
 	if a.Config.Server.AssetsPath != "" {

+ 4 - 0
internal/widget/fields.go

@@ -152,6 +152,10 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
 	return nil
 }
 
+func (f *OptionalEnvString) String() string {
+	return string(*f)
+}
+
 func toSimpleIconIfPrefixed(icon string) (string, bool) {
 	if !strings.HasPrefix(icon, "si:") {
 		return icon, false

+ 6 - 0
internal/widget/group.go

@@ -55,6 +55,12 @@ func (widget *Group) Update(ctx context.Context) {
 	wg.Wait()
 }
 
+func (widget *Group) SetProviders(providers *Providers) {
+	for i := range widget.Widgets {
+		widget.Widgets[i].SetProviders(providers)
+	}
+}
+
 func (widget *Group) RequiresUpdate(now *time.Time) bool {
 	for i := range widget.Widgets {
 		if widget.Widgets[i].RequiresUpdate(now) {

+ 55 - 7
internal/widget/releases.go

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

+ 6 - 0
internal/widget/repository-overview.go

@@ -15,6 +15,7 @@ type Repository struct {
 	Token               OptionalEnvString `yaml:"token"`
 	PullRequestsLimit   int               `yaml:"pull-requests-limit"`
 	IssuesLimit         int               `yaml:"issues-limit"`
+	CommitsLimit        int               `yaml:"commits-limit"`
 	RepositoryDetails   feed.RepositoryDetails
 }
 
@@ -29,6 +30,10 @@ func (widget *Repository) Initialize() error {
 		widget.IssuesLimit = 3
 	}
 
+	if widget.CommitsLimit == 0 || widget.CommitsLimit < -1 {
+		widget.CommitsLimit = -1
+	}
+
 	return nil
 }
 
@@ -38,6 +43,7 @@ func (widget *Repository) Update(ctx context.Context) {
 		string(widget.Token),
 		widget.PullRequestsLimit,
 		widget.IssuesLimit,
+		widget.CommitsLimit,
 	)
 
 	if !widget.canContinueUpdateAfterHandlingErr(err) {

+ 10 - 0
internal/widget/widget.go

@@ -113,6 +113,7 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
 type Widget interface {
 	Initialize() error
 	RequiresUpdate(*time.Time) bool
+	SetProviders(*Providers)
 	Update(context.Context)
 	Render() template.HTML
 	GetType() string
@@ -132,6 +133,7 @@ const (
 
 type widgetBase struct {
 	ID                  uint64        `yaml:"-"`
+	Providers           *Providers    `yaml:"-"`
 	Type                string        `yaml:"type"`
 	Title               string        `yaml:"title"`
 	TitleURL            string        `yaml:"title-url"`
@@ -148,6 +150,10 @@ type widgetBase struct {
 	HideHeader          bool          `yaml:"-"`
 }
 
+type Providers struct {
+	AssetResolver func(string) string
+}
+
 func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
 	if w.cacheType == cacheTypeInfinite {
 		return false
@@ -184,6 +190,10 @@ func (w *widgetBase) GetType() string {
 	return w.Type
 }
 
+func (w *widgetBase) SetProviders(providers *Providers) {
+	w.Providers = providers
+}
+
 func (w *widgetBase) render(data any, t *template.Template) template.HTML {
 	w.templateBuffer.Reset()
 	err := t.Execute(&w.templateBuffer, data)