Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e09ea60616 | ||
|
|
7cab07fa01 | ||
|
|
5a62a8afe9 | ||
|
|
9bda88edbd | ||
|
|
16da25898f | ||
|
|
88ae2b8e83 | ||
|
|
3805f8b156 | ||
|
|
39f40670d0 | ||
|
|
514cf2b81c | ||
|
|
4dbb5975c0 | ||
|
|
b327e59ab1 | ||
|
|
917d51e54b | ||
|
|
122d05346b | ||
|
|
a2b7f59dc9 | ||
|
|
c23dc93c7a | ||
|
|
d3e3667405 | ||
|
|
d4ed5afe93 | ||
|
|
790ecef1c1 | ||
|
|
edb59ffdc6 | ||
|
|
e137038586 | ||
|
|
761eb343f9 | ||
|
|
6c15c907bf | ||
|
|
06b21e3d27 | ||
|
|
263d2e6f30 | ||
|
|
c97fec66f4 | ||
|
|
757f68c872 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.20
|
||||
FROM alpine:3.20.1
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
+34
-1
@@ -40,7 +40,6 @@ pages:
|
||||
columns:
|
||||
- size: small
|
||||
widgets:
|
||||
- type: clock
|
||||
- type: calendar
|
||||
|
||||
- type: rss
|
||||
@@ -235,6 +234,8 @@ theme:
|
||||
> .widget-type-rss a {
|
||||
> font-size: 1.5rem;
|
||||
> }
|
||||
>
|
||||
> In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets.
|
||||
|
||||
|
||||
## Pages & Columns
|
||||
@@ -355,7 +356,9 @@ pages:
|
||||
| ---- | ---- | -------- |
|
||||
| type | string | yes |
|
||||
| title | string | no |
|
||||
| title-url | string | no |
|
||||
| cache | string | no |
|
||||
| css-class | string | no |
|
||||
|
||||
#### `type`
|
||||
Used to specify the widget.
|
||||
@@ -363,6 +366,9 @@ Used to specify the widget.
|
||||
#### `title`
|
||||
The title of the widget. If left blank it will be defined by the widget.
|
||||
|
||||
#### `title-url`
|
||||
The URL to go to when clicking on the widget's title. If left blank it will be defined by the widget (if available).
|
||||
|
||||
#### `cache`
|
||||
How long to keep the fetched data in memory. The value is a string and must be a number followed by one of s, m, h, d. Examples:
|
||||
|
||||
@@ -377,6 +383,9 @@ cache: 1d # 1 day
|
||||
>
|
||||
> Not all widgets can have their cache duration modified. The calendar and weather widgets update on the hour and this cannot be changed.
|
||||
|
||||
#### `css-class`
|
||||
Set custom CSS classes for the specific widget instance.
|
||||
|
||||
### RSS
|
||||
Display a list of articles from multiple RSS feeds.
|
||||
|
||||
@@ -573,11 +582,23 @@ Preview:
|
||||
#### Properties
|
||||
| Name | Type | Required | Default |
|
||||
| ---- | ---- | -------- | ------- |
|
||||
| instance-url | string | no | https://lobste.rs/ |
|
||||
| custom-url | string | no | |
|
||||
| limit | integer | no | 15 |
|
||||
| collapse-after | integer | no | 5 |
|
||||
| sort-by | string | no | hot |
|
||||
| tags | array | no | |
|
||||
|
||||
##### `instance-url`
|
||||
The base URL for a lobsters instance hosted somewhere other than on lobste.rs. Example:
|
||||
|
||||
```yaml
|
||||
instance-url: https://www.journalduhacker.net/
|
||||
```
|
||||
|
||||
##### `custom-url`
|
||||
A custom URL to retrieve lobsters posts from. If this is specified, the `instance-url`, `sort-by` and `tags` properties are ignored.
|
||||
|
||||
##### `limit`
|
||||
The maximum number of posts to show.
|
||||
|
||||
@@ -725,10 +746,16 @@ Preview:
|
||||
| <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Perform search in a new tab | Search input is focused and not empty |
|
||||
| <kbd>Escape</kbd> | Leave focus | Search input is focused |
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> You can use the property `new-tab` with a value of `true` if you want to show search results in a new tab by default. <kbd>Ctrl</kbd> + <kbd>Enter</kbd> will then show results in the same tab.
|
||||
|
||||
#### Properties
|
||||
| Name | Type | Required | Default |
|
||||
| ---- | ---- | -------- | ------- |
|
||||
| search-engine | string | no | duckduckgo |
|
||||
| new-tab | boolean | no | false |
|
||||
| autofocus | boolean | no | false |
|
||||
| bangs | array | no | |
|
||||
|
||||
##### `search-engine`
|
||||
@@ -739,6 +766,12 @@ Either a value from the table below or a URL to a custom search engine. Use `{QU
|
||||
| duckduckgo | `https://duckduckgo.com/?q={QUERY}` |
|
||||
| google | `https://www.google.com/search?q={QUERY}` |
|
||||
|
||||
##### `new-tab`
|
||||
When set to `true`, swaps the shortcuts for showing results in the same or new tab, defaulting to showing results in a new tab.
|
||||
|
||||
##### `new-tab`
|
||||
When set to `true`, automatically focuses the search input on page load.
|
||||
|
||||
##### `bangs`
|
||||
What now? [Bangs](https://duckduckgo.com/bangs). They're shortcuts that allow you to use the same search box for many different sites. Assuming you have it configured, if for example you start your search input with `!yt` you'd be able to perform a search on YouTube:
|
||||
|
||||
|
||||
@@ -4,16 +4,16 @@ go 1.22.3
|
||||
|
||||
require (
|
||||
github.com/mmcdole/gofeed v1.3.0
|
||||
golang.org/x/text v0.14.0
|
||||
golang.org/x/text v0.16.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.9.1 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.9.2 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/mmcdole/goxpp v1.1.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
golang.org/x/net v0.24.0 // indirect
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
|
||||
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
|
||||
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -33,8 +33,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -54,8 +54,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
@@ -13,3 +19,38 @@ var _templateFS embed.FS
|
||||
|
||||
var PublicFS, _ = fs.Sub(_publicFS, "static")
|
||||
var TemplateFS, _ = fs.Sub(_templateFS, "templates")
|
||||
|
||||
func getFSHash(files fs.FS) string {
|
||||
hash := md5.New()
|
||||
|
||||
err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := files.Open(path)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
return hex.EncodeToString(hash.Sum(nil))[:10]
|
||||
}
|
||||
|
||||
slog.Warn("Could not compute assets cache", "err", err)
|
||||
return strconv.FormatInt(time.Now().Unix(), 10)
|
||||
}
|
||||
|
||||
var PublicFSHash = getFSHash(PublicFS)
|
||||
|
||||
@@ -263,6 +263,7 @@ html {
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
ul {
|
||||
@@ -731,6 +732,7 @@ kbd:active {
|
||||
height: 6rem;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
color: var(--color-text-highlight);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
|
||||
@@ -107,7 +107,7 @@ function updateRelativeTimeForElements(elements)
|
||||
}
|
||||
}
|
||||
|
||||
function setupSearchboxes() {
|
||||
function setupSearchBoxes() {
|
||||
const searchWidgets = document.getElementsByClassName("search");
|
||||
|
||||
if (searchWidgets.length == 0) {
|
||||
@@ -117,6 +117,7 @@ function setupSearchboxes() {
|
||||
for (let i = 0; i < searchWidgets.length; i++) {
|
||||
const widget = searchWidgets[i];
|
||||
const defaultSearchUrl = widget.dataset.defaultSearchUrl;
|
||||
const newTab = widget.dataset.newTab === "true";
|
||||
const inputElement = widget.getElementsByClassName("search-input")[0];
|
||||
const bangElement = widget.getElementsByClassName("search-bang")[0];
|
||||
const bangs = widget.querySelectorAll(".search-bangs > input");
|
||||
@@ -147,14 +148,13 @@ function setupSearchboxes() {
|
||||
query = input;
|
||||
searchUrlTemplate = defaultSearchUrl;
|
||||
}
|
||||
|
||||
if (query.length == 0) {
|
||||
if (query.length == 0 && currentBang == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
|
||||
|
||||
if (event.ctrlKey) {
|
||||
if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
|
||||
window.open(url, '_blank').focus();
|
||||
} else {
|
||||
window.location.href = url;
|
||||
@@ -170,9 +170,13 @@ function setupSearchboxes() {
|
||||
}
|
||||
|
||||
const handleInput = (event) => {
|
||||
const value = event.target.value.trimStart();
|
||||
const words = value.split(" ");
|
||||
const value = event.target.value.trim();
|
||||
if (value in bangsMap) {
|
||||
changeCurrentBang(bangsMap[value]);
|
||||
return;
|
||||
}
|
||||
|
||||
const words = value.split(" ");
|
||||
if (words.length >= 2 && words[0] in bangsMap) {
|
||||
changeCurrentBang(bangsMap[words[0]]);
|
||||
return;
|
||||
@@ -551,7 +555,7 @@ async function setupPage() {
|
||||
try {
|
||||
setupClocks()
|
||||
setupCarousels();
|
||||
setupSearchboxes();
|
||||
setupSearchBoxes();
|
||||
setupCollapsibleLists();
|
||||
setupCollapsibleGrids();
|
||||
setupDynamicRelativeTime();
|
||||
@@ -569,8 +573,4 @@ async function setupPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", setupPage);
|
||||
} else {
|
||||
setupPage();
|
||||
}
|
||||
setupPage();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/app-icon.png",
|
||||
"src": "app-icon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
<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="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="/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>
|
||||
<link rel="icon" type="image/png" href="{{ .App.AssetPath "favicon.png" }}" />
|
||||
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
|
||||
<script type="module" src="{{ .App.AssetPath "main.js" }}"></script>
|
||||
{{ block "document-head-after" . }}{{ end }}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}">
|
||||
<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}" data-new-tab="{{ .NewTab }}">
|
||||
<div class="search-bangs">
|
||||
{{ range .Bangs }}
|
||||
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
|
||||
@@ -16,7 +16,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off">
|
||||
<input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off"{{ if .Autofocus }} autofocus{{ end }}>
|
||||
|
||||
<div class="search-bang"></div>
|
||||
<kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<div class="widget widget-type-{{ .GetType }}">
|
||||
<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
|
||||
<div class="widget-header">
|
||||
<div class="uppercase">{{ .Title }}</div>
|
||||
{{ if ne "" .TitleURL}}<a href="{{ .TitleURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>{{ else }}<div class="uppercase">{{ .Title }}</div>{{ end }}
|
||||
{{ if and .Error .ContentAvailable }}
|
||||
<div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
|
||||
{{ else if .Notice }}
|
||||
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="widget-content {{ if .ContentAvailable }}{{ block "widget-content-classes" . }}{{ end }}{{ end }}">
|
||||
<div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}">
|
||||
{{ if .ContentAvailable }}
|
||||
{{ block "widget-content" . }}{{ end }}
|
||||
{{ else }}
|
||||
|
||||
+4
-23
@@ -8,12 +8,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type githubReleaseResponseJson struct {
|
||||
type githubReleaseLatestResponseJson struct {
|
||||
TagName string `json:"tag_name"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
HtmlUrl string `json:"html_url"`
|
||||
Draft bool `json:"draft"`
|
||||
PreRelease bool `json:"prerelease"`
|
||||
Reactions struct {
|
||||
Downvotes int `json:"-1"`
|
||||
} `json:"reactions"`
|
||||
@@ -39,7 +37,7 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele
|
||||
requests := make([]*http.Request, len(repositories))
|
||||
|
||||
for i, repository := range repositories {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=10", repository), nil)
|
||||
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))
|
||||
@@ -48,7 +46,7 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele
|
||||
requests[i] = request
|
||||
}
|
||||
|
||||
task := decodeJsonFromRequestTask[[]githubReleaseResponseJson](defaultClient)
|
||||
task := decodeJsonFromRequestTask[githubReleaseLatestResponseJson](defaultClient)
|
||||
job := newJob(task, requests).withWorkers(15)
|
||||
responses, errs, err := workerPoolDo(job)
|
||||
|
||||
@@ -65,24 +63,7 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele
|
||||
continue
|
||||
}
|
||||
|
||||
releases := responses[i]
|
||||
|
||||
if len(releases) < 1 {
|
||||
failed++
|
||||
slog.Error("No releases found", "repository", repositories[i], "url", requests[i].URL)
|
||||
continue
|
||||
}
|
||||
|
||||
var liveRelease *githubReleaseResponseJson
|
||||
|
||||
for i := range releases {
|
||||
release := &releases[i]
|
||||
|
||||
if !release.Draft && !release.PreRelease {
|
||||
liveRelease = release
|
||||
break
|
||||
}
|
||||
}
|
||||
liveRelease := &responses[i]
|
||||
|
||||
if liveRelease == nil {
|
||||
slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
|
||||
|
||||
+21
-11
@@ -55,20 +55,30 @@ func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) {
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func FetchLobstersPosts(sortBy string, tags []string) (ForumPosts, error) {
|
||||
func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) {
|
||||
var feedUrl string
|
||||
|
||||
if sortBy == "hot" {
|
||||
sortBy = "hottest"
|
||||
} else if sortBy == "new" {
|
||||
sortBy = "newest"
|
||||
}
|
||||
|
||||
if len(tags) == 0 {
|
||||
feedUrl = "https://lobste.rs/" + sortBy + ".json"
|
||||
if customURL != "" {
|
||||
feedUrl = customURL
|
||||
} else {
|
||||
tags := strings.Join(tags, ",")
|
||||
feedUrl = "https://lobste.rs/t/" + tags + ".json"
|
||||
if instanceURL != "" {
|
||||
instanceURL = strings.TrimRight(instanceURL, "/") + "/"
|
||||
} else {
|
||||
instanceURL = "https://lobste.rs/"
|
||||
}
|
||||
|
||||
if sortBy == "hot" {
|
||||
sortBy = "hottest"
|
||||
} else if sortBy == "new" {
|
||||
sortBy = "newest"
|
||||
}
|
||||
|
||||
if len(tags) == 0 {
|
||||
feedUrl = instanceURL + sortBy + ".json"
|
||||
} else {
|
||||
tags := strings.Join(tags, ",")
|
||||
feedUrl = instanceURL + "t/" + tags + ".json"
|
||||
}
|
||||
}
|
||||
|
||||
posts, err := getLobstersPostsFromFeed(feedUrl)
|
||||
|
||||
+70
-25
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mmcdole/gofeed"
|
||||
gofeedext "github.com/mmcdole/gofeed/extensions"
|
||||
)
|
||||
|
||||
type RSSFeedItem struct {
|
||||
@@ -43,12 +44,25 @@ func sanitizeFeedDescription(description string) string {
|
||||
return description
|
||||
}
|
||||
|
||||
func shortenFeedDescriptionLen(description string, maxLen int) string {
|
||||
description, _ = limitStringLength(description, 1000)
|
||||
description = sanitizeFeedDescription(description)
|
||||
description, limited := limitStringLength(description, maxLen)
|
||||
|
||||
if limited {
|
||||
description += "…"
|
||||
}
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
type RSSFeedRequest struct {
|
||||
Url string `yaml:"url"`
|
||||
Title string `yaml:"title"`
|
||||
HideCategories bool `yaml:"hide-categories"`
|
||||
HideDescription bool `yaml:"hide-description"`
|
||||
ItemLinkPrefix string `yaml:"item-link-prefix"`
|
||||
IsDetailed bool `yaml:"-"`
|
||||
}
|
||||
|
||||
type RSSFeedItems []RSSFeedItem
|
||||
@@ -80,7 +94,6 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
||||
|
||||
rssItem := RSSFeedItem{
|
||||
ChannelURL: feed.Link,
|
||||
Title: item.Title,
|
||||
}
|
||||
|
||||
if request.ItemLinkPrefix != "" {
|
||||
@@ -107,34 +120,34 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
||||
}
|
||||
}
|
||||
|
||||
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 item.Title != "" {
|
||||
rssItem.Title = item.Title
|
||||
} else {
|
||||
rssItem.Title = shortenFeedDescriptionLen(item.Description, 100)
|
||||
}
|
||||
|
||||
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)
|
||||
if request.IsDetailed {
|
||||
if !request.HideDescription && item.Description != "" && item.Title != "" {
|
||||
rssItem.Description = shortenFeedDescriptionLen(item.Description, 200)
|
||||
}
|
||||
|
||||
rssItem.Categories = categories
|
||||
if !request.HideCategories {
|
||||
var categories = make([]string, 0, 6)
|
||||
|
||||
for _, category := range item.Categories {
|
||||
if len(categories) == 6 {
|
||||
break
|
||||
}
|
||||
|
||||
if len(category) == 0 || len(category) > 30 {
|
||||
continue
|
||||
}
|
||||
|
||||
categories = append(categories, category)
|
||||
}
|
||||
|
||||
rssItem.Categories = categories
|
||||
}
|
||||
}
|
||||
|
||||
if request.Title != "" {
|
||||
@@ -145,6 +158,8 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
||||
|
||||
if item.Image != nil {
|
||||
rssItem.ImageURL = item.Image.URL
|
||||
} else if url := findThumbnailInItemExtensions(item); url != "" {
|
||||
rssItem.ImageURL = url
|
||||
} else if feed.Image != nil {
|
||||
rssItem.ImageURL = feed.Image.URL
|
||||
}
|
||||
@@ -161,6 +176,36 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extension) string {
|
||||
for _, exts := range extensions {
|
||||
for _, ext := range exts {
|
||||
if ext.Name == "thumbnail" || ext.Name == "image" {
|
||||
if url, ok := ext.Attrs["url"]; ok {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
if ext.Children != nil {
|
||||
if url := recursiveFindThumbnailInExtensions(ext.Children); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func findThumbnailInItemExtensions(item *gofeed.Item) string {
|
||||
media, ok := item.Extensions["media"]
|
||||
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return recursiveFindThumbnailInExtensions(media)
|
||||
}
|
||||
|
||||
func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
|
||||
job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10)
|
||||
feeds, errs, err := workerPoolDo(job)
|
||||
|
||||
@@ -38,10 +38,10 @@ type Theme struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Host string `yaml:"host"`
|
||||
Port uint16 `yaml:"port"`
|
||||
AssetsPath string `yaml:"assets-path"`
|
||||
StartedAt time.Time `yaml:"-"`
|
||||
Host string `yaml:"host"`
|
||||
Port uint16 `yaml:"port"`
|
||||
AssetsPath string `yaml:"assets-path"`
|
||||
AssetsHash string `yaml:"-"`
|
||||
}
|
||||
|
||||
type Column struct {
|
||||
@@ -189,7 +189,13 @@ func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.H
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Application) AssetPath(asset string) string {
|
||||
return "/static/" + a.Config.Server.AssetsHash + "/" + asset
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -197,7 +203,10 @@ func (a *Application) Serve() error {
|
||||
mux.HandleFunc("GET /{$}", a.HandlePageRequest)
|
||||
mux.HandleFunc("GET /{page}", a.HandlePageRequest)
|
||||
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
|
||||
mux.Handle("GET /static/{path...}", http.StripPrefix("/static/", FileServerWithCache(http.FS(assets.PublicFS), 2*time.Hour)))
|
||||
mux.Handle(
|
||||
fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
|
||||
http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 8*time.Hour)),
|
||||
)
|
||||
|
||||
if a.Config.Server.AssetsPath != "" {
|
||||
absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
|
||||
@@ -216,8 +225,6 @@ func (a *Application) Serve() error {
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
a.Config.Server.StartedAt = time.Now()
|
||||
|
||||
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port)
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@ type HackerNews struct {
|
||||
}
|
||||
|
||||
func (widget *HackerNews) Initialize() error {
|
||||
widget.withTitle("Hacker News").withCacheDuration(30 * time.Minute)
|
||||
widget.
|
||||
withTitle("Hacker News").
|
||||
withTitleURL("https://news.ycombinator.com/").
|
||||
withCacheDuration(30 * time.Minute)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 15
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
type Lobsters struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
Posts feed.ForumPosts `yaml:"-"`
|
||||
InstanceURL string `yaml:"instance-url"`
|
||||
CustomURL string `yaml:"custom-url"`
|
||||
Limit int `yaml:"limit"`
|
||||
CollapseAfter int `yaml:"collapse-after"`
|
||||
SortBy string `yaml:"sort-by"`
|
||||
@@ -20,7 +22,13 @@ type Lobsters struct {
|
||||
}
|
||||
|
||||
func (widget *Lobsters) Initialize() error {
|
||||
widget.withTitle("Lobsters").withCacheDuration(30 * time.Minute)
|
||||
widget.withTitle("Lobsters").withCacheDuration(time.Hour)
|
||||
|
||||
if widget.InstanceURL == "" {
|
||||
widget.withTitleURL("https://lobste.rs")
|
||||
} else {
|
||||
widget.withTitleURL(widget.InstanceURL)
|
||||
}
|
||||
|
||||
if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") {
|
||||
widget.SortBy = "hot"
|
||||
@@ -38,7 +46,7 @@ func (widget *Lobsters) Initialize() error {
|
||||
}
|
||||
|
||||
func (widget *Lobsters) Update(ctx context.Context) {
|
||||
posts, err := feed.FetchLobstersPosts(widget.SortBy, widget.Tags)
|
||||
posts, err := feed.FetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags)
|
||||
|
||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||
return
|
||||
|
||||
@@ -54,7 +54,10 @@ func (widget *Reddit) Initialize() error {
|
||||
}
|
||||
}
|
||||
|
||||
widget.withTitle("/r/" + widget.Subreddit).withCacheDuration(30 * time.Minute)
|
||||
widget.
|
||||
withTitle("/r/" + widget.Subreddit).
|
||||
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
|
||||
withCacheDuration(30 * time.Minute)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -39,10 +39,9 @@ func (widget *RSS) Initialize() error {
|
||||
widget.CardHeight = 0
|
||||
}
|
||||
|
||||
if widget.Style != "detailed-list" {
|
||||
if widget.Style == "detailed-list" {
|
||||
for i := range widget.FeedRequests {
|
||||
widget.FeedRequests[i].HideCategories = true
|
||||
widget.FeedRequests[i].HideDescription = true
|
||||
widget.FeedRequests[i].IsDetailed = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ type Search struct {
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
SearchEngine string `yaml:"search-engine"`
|
||||
Bangs []SearchBang `yaml:"bangs"`
|
||||
NewTab bool `yaml:"new-tab"`
|
||||
Autofocus bool `yaml:"autofocus"`
|
||||
}
|
||||
|
||||
func convertSearchUrl(url string) string {
|
||||
|
||||
@@ -18,7 +18,10 @@ type TwitchChannels struct {
|
||||
}
|
||||
|
||||
func (widget *TwitchChannels) Initialize() error {
|
||||
widget.withTitle("Twitch Channels").withCacheDuration(time.Minute * 10)
|
||||
widget.
|
||||
withTitle("Twitch Channels").
|
||||
withTitleURL("https://www.twitch.tv/directory/following").
|
||||
withCacheDuration(time.Minute * 10)
|
||||
|
||||
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||
widget.CollapseAfter = 5
|
||||
|
||||
@@ -18,7 +18,10 @@ type TwitchGames struct {
|
||||
}
|
||||
|
||||
func (widget *TwitchGames) Initialize() error {
|
||||
widget.withTitle("Top games on Twitch").withCacheDuration(time.Minute * 10)
|
||||
widget.
|
||||
withTitle("Top games on Twitch").
|
||||
withTitleURL("https://www.twitch.tv/directory?sort=VIEWER_COUNT").
|
||||
withCacheDuration(time.Minute * 10)
|
||||
|
||||
if widget.Limit <= 0 {
|
||||
widget.Limit = 10
|
||||
|
||||
@@ -119,6 +119,8 @@ const (
|
||||
type widgetBase struct {
|
||||
Type string `yaml:"type"`
|
||||
Title string `yaml:"title"`
|
||||
TitleURL string `yaml:"title-url"`
|
||||
CSSClass string `yaml:"css-class"`
|
||||
CustomCacheDuration DurationField `yaml:"cache"`
|
||||
ContentAvailable bool `yaml:"-"`
|
||||
Error error `yaml:"-"`
|
||||
@@ -185,6 +187,14 @@ func (w *widgetBase) withTitle(title string) *widgetBase {
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *widgetBase) withTitleURL(titleURL string) *widgetBase {
|
||||
if w.TitleURL == "" {
|
||||
w.TitleURL = titleURL
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *widgetBase) withCacheDuration(duration time.Duration) *widgetBase {
|
||||
w.cacheType = cacheTypeDuration
|
||||
|
||||
|
||||
Reference in New Issue
Block a user