Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
917e2cabfc | ||
|
|
a981025679 | ||
|
|
f9ea2d73e1 | ||
|
|
a5728e9407 | ||
|
|
404a2acc8f | ||
|
|
7bf61ee8ed | ||
|
|
bf1cc47525 | ||
|
|
74ad27d5fb | ||
|
|
60b73aff24 | ||
|
|
4de465e544 | ||
|
|
e1161b9227 | ||
|
|
c2cdd0fa08 | ||
|
|
b365a03e71 | ||
|
|
2e629efc7f | ||
|
|
fcaa2bef6e | ||
|
|
3b2a9fedef | ||
|
|
64256f2638 | ||
|
|
5c8122d93b | ||
|
|
6ffb95779d | ||
|
|
af975d0e7f | ||
|
|
34b2b88cf3 | ||
|
|
3fea166274 | ||
|
|
233b905492 | ||
|
|
7afad765cb | ||
|
|
39663cb594 | ||
|
|
457e0539fa | ||
|
|
871ba619a3 | ||
|
|
f5427310e8 | ||
|
|
e4435252f4 | ||
|
|
a66aa74c7f | ||
|
|
3949e15f77 | ||
|
|
acaf50bf8a | ||
|
|
09eedc08c1 | ||
|
|
738bcf8bcb | ||
|
|
795caa5d9d | ||
|
|
c041197f3f | ||
|
|
0730789cb3 | ||
|
|
1eb0fbd2c9 | ||
|
|
0484122b7f | ||
|
|
7bc9c704d1 | ||
|
|
c9efdc2c16 | ||
|
|
e0dee998d4 | ||
|
|
23d6d41148 | ||
|
|
a07fbfd7b7 | ||
|
|
1411268e35 | ||
|
|
645802981e | ||
|
|
0348a2475c | ||
|
|
d5cf881d7a | ||
|
|
050d3e6cd2 | ||
|
|
d27993d2c9 | ||
|
|
bfc65d51e3 | ||
|
|
3c29d38599 | ||
|
|
060609e2cb | ||
|
|
a1c48bf012 | ||
|
|
a67fb66d2e | ||
|
|
3001f4845a | ||
|
|
192c1ad669 | ||
|
|
89ae8a3607 |
@@ -0,0 +1,39 @@
|
|||||||
|
name: Create release
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout the target Git reference
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Golang
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Set up Docker buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Run GoReleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v5
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
args: release
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
project_name: glanceapp/glance
|
||||||
|
|
||||||
|
checksum:
|
||||||
|
disable: true
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- binary: glance
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- openbsd
|
||||||
|
- freebsd
|
||||||
|
- windows
|
||||||
|
- darwin
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- arm
|
||||||
|
- 386
|
||||||
|
goarm:
|
||||||
|
- 7
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X github.com/glanceapp/glance/internal/glance.buildVersion={{ .Tag }}
|
||||||
|
|
||||||
|
archives:
|
||||||
|
-
|
||||||
|
name_template: "glance-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}"
|
||||||
|
files:
|
||||||
|
- nothing*
|
||||||
|
format_overrides:
|
||||||
|
- goos: windows
|
||||||
|
format: zip
|
||||||
|
|
||||||
|
dockers:
|
||||||
|
- image_templates:
|
||||||
|
- &amd64_image "{{ .ProjectName }}:{{ .Tag }}-amd64"
|
||||||
|
build_flag_templates:
|
||||||
|
- --platform=linux/amd64
|
||||||
|
goarch: amd64
|
||||||
|
use: buildx
|
||||||
|
dockerfile: Dockerfile.goreleaser
|
||||||
|
|
||||||
|
- image_templates:
|
||||||
|
- &arm64v8_image "{{ .ProjectName }}:{{ .Tag }}-arm64"
|
||||||
|
build_flag_templates:
|
||||||
|
- --platform=linux/arm64
|
||||||
|
goarch: arm64
|
||||||
|
use: buildx
|
||||||
|
dockerfile: Dockerfile.goreleaser
|
||||||
|
|
||||||
|
- image_templates:
|
||||||
|
- &arm64v7_image "{{ .ProjectName }}:{{ .Tag }}-arm64v7"
|
||||||
|
build_flag_templates:
|
||||||
|
- --platform=linux/arm64/v7
|
||||||
|
goarch: arm
|
||||||
|
goarm: 7
|
||||||
|
use: buildx
|
||||||
|
dockerfile: Dockerfile.goreleaser
|
||||||
|
|
||||||
|
docker_manifests:
|
||||||
|
- name_template: "{{ .ProjectName }}:{{ .Tag }}"
|
||||||
|
image_templates:
|
||||||
|
- *amd64_image
|
||||||
|
- *arm64v8_image
|
||||||
|
- *arm64v7_image
|
||||||
|
- name_template: "{{ .ProjectName }}:latest"
|
||||||
|
skip_push: auto
|
||||||
|
image_templates:
|
||||||
|
- *amd64_image
|
||||||
|
- *arm64v8_image
|
||||||
|
- *arm64v7_image
|
||||||
+8
-6
@@ -1,11 +1,13 @@
|
|||||||
FROM alpine:3.20.1
|
FROM golang:1.22.5-alpine3.20 AS builder
|
||||||
|
|
||||||
ARG TARGETOS
|
|
||||||
ARG TARGETARCH
|
|
||||||
ARG TARGETVARIANT
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY build/glance-$TARGETOS-$TARGETARCH${TARGETVARIANT} /app/glance
|
COPY . /app
|
||||||
|
RUN CGO_ENABLED=0 go build .
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/glance .
|
||||||
|
|
||||||
EXPOSE 8080/tcp
|
EXPOSE 8080/tcp
|
||||||
ENTRYPOINT ["/app/glance"]
|
ENTRYPOINT ["/app/glance"]
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY glance .
|
||||||
|
|
||||||
|
EXPOSE 8080/tcp
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/glance"]
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
FROM golang:1.22.3-alpine3.19 AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY . /app
|
|
||||||
RUN CGO_ENABLED=0 go build .
|
|
||||||
|
|
||||||
|
|
||||||
FROM alpine:3.19
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=builder /app/glance .
|
|
||||||
|
|
||||||
EXPOSE 8080/tcp
|
|
||||||
ENTRYPOINT ["/app/glance"]
|
|
||||||
@@ -101,7 +101,7 @@ Build the image:
|
|||||||
**Make sure to replace "owner" with your name or organization.**
|
**Make sure to replace "owner" with your name or organization.**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t owner/glance:latest -f Dockerfile.single-platform .
|
docker build -t owner/glance:latest .
|
||||||
```
|
```
|
||||||
|
|
||||||
Push the image to your registry:
|
Push the image to your registry:
|
||||||
|
|||||||
+73
-1
@@ -13,6 +13,7 @@
|
|||||||
- [Lobsters](#lobsters)
|
- [Lobsters](#lobsters)
|
||||||
- [Reddit](#reddit)
|
- [Reddit](#reddit)
|
||||||
- [Search](#search-widget)
|
- [Search](#search-widget)
|
||||||
|
- [Group](#group)
|
||||||
- [Extension](#extension)
|
- [Extension](#extension)
|
||||||
- [Weather](#weather)
|
- [Weather](#weather)
|
||||||
- [Monitor](#monitor)
|
- [Monitor](#monitor)
|
||||||
@@ -123,6 +124,7 @@ server:
|
|||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| host | string | no | |
|
| host | string | no | |
|
||||||
| port | number | no | 8080 |
|
| port | number | no | 8080 |
|
||||||
|
| base-url | string | no | |
|
||||||
| assets-path | string | no | |
|
| assets-path | string | no | |
|
||||||
|
|
||||||
#### `host`
|
#### `host`
|
||||||
@@ -131,6 +133,9 @@ The address which the server will listen on. Setting it to `localhost` means tha
|
|||||||
#### `port`
|
#### `port`
|
||||||
A number between 1 and 65,535, so long as that port isn't already used by anything else.
|
A number between 1 and 65,535, so long as that port isn't already used by anything else.
|
||||||
|
|
||||||
|
#### `base-url`
|
||||||
|
The base URL that Glance is hosted under. No need to specify this unless you're using a reverse proxy and are hosting Glance under a directory. If that's the case then you can set this value to `/glance` or whatever the directory is called. Note that the forward slash (`/`) in the beginning is required unless you specify the full domain and path.
|
||||||
|
|
||||||
#### `assets-path`
|
#### `assets-path`
|
||||||
The path to a directory that will be served by the server under the `/assets/` path. This is handy for widgets like the Monitor where you have to specify an icon URL and you want to self host all the icons rather than pointing to an external source.
|
The path to a directory that will be served by the server under the `/assets/` path. This is handy for widgets like the Monitor where you have to specify an icon URL and you want to self host all the icons rather than pointing to an external source.
|
||||||
|
|
||||||
@@ -263,6 +268,8 @@ pages:
|
|||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| title | string | yes | |
|
| title | string | yes | |
|
||||||
| slug | string | no | |
|
| slug | string | no | |
|
||||||
|
| width | string | no | |
|
||||||
|
| hide-desktop-navigation | boolean | no | false |
|
||||||
| show-mobile-header | boolean | no | false |
|
| show-mobile-header | boolean | no | false |
|
||||||
| columns | array | yes | |
|
| columns | array | yes | |
|
||||||
|
|
||||||
@@ -272,6 +279,21 @@ The name of the page which gets shown in the navigation bar.
|
|||||||
#### `slug`
|
#### `slug`
|
||||||
The URL friendly version of the title which is used to access the page. For example if the title of the page is "RSS Feeds" you can make the page accessible via `localhost:8080/feeds` by setting the slug to `feeds`. If not defined, it will automatically be generated from the title.
|
The URL friendly version of the title which is used to access the page. For example if the title of the page is "RSS Feeds" you can make the page accessible via `localhost:8080/feeds` by setting the slug to `feeds`. If not defined, it will automatically be generated from the title.
|
||||||
|
|
||||||
|
#### `width`
|
||||||
|
The maximum width of the page on desktop. Possible values are `slim` and `wide`.
|
||||||
|
|
||||||
|
* default: `1600px`
|
||||||
|
* slim: `1100px`
|
||||||
|
* wide: `1920px`
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
>
|
||||||
|
> When using `slim`, the maximum number of columns allowed for that page is `2`.
|
||||||
|
|
||||||
|
|
||||||
|
#### `hide-desktop-navigation`
|
||||||
|
Whether to show the navigation links at the top of the page on desktop.
|
||||||
|
|
||||||
#### `show-mobile-header`
|
#### `show-mobile-header`
|
||||||
Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices.
|
Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices.
|
||||||
|
|
||||||
@@ -483,6 +505,7 @@ Preview:
|
|||||||
| limit | integer | no | 25 |
|
| limit | integer | no | 25 |
|
||||||
| style | string | no | horizontal-cards |
|
| style | string | no | horizontal-cards |
|
||||||
| collapse-after-rows | integer | no | 4 |
|
| collapse-after-rows | integer | no | 4 |
|
||||||
|
| include-shorts | boolean | no | false |
|
||||||
| video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} |
|
| video-url-template | string | no | https://www.youtube.com/watch?v={VIDEO-ID} |
|
||||||
|
|
||||||
##### `channels`
|
##### `channels`
|
||||||
@@ -806,6 +829,50 @@ url: https://store.steampowered.com/search/?term={QUERY}
|
|||||||
url: https://www.amazon.com/s?k={QUERY}
|
url: https://www.amazon.com/s?k={QUERY}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Group
|
||||||
|
Group multiple widgets into one using tabs. Widgets are defined using a `widgets` property exactly as you would on a page column. The only limitation is that you cannot place a group widget within a group widget.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: group
|
||||||
|
widgets:
|
||||||
|
- type: reddit
|
||||||
|
subreddit: gamingnews
|
||||||
|
show-thumbnails: true
|
||||||
|
collapse-after: 6
|
||||||
|
- type: reddit
|
||||||
|
subreddit: games
|
||||||
|
- type: reddit
|
||||||
|
subreddit: pcgaming
|
||||||
|
show-thumbnails: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Sharing properties
|
||||||
|
|
||||||
|
To avoid repetition you can use [YAML anchors](https://support.atlassian.com/bitbucket-cloud/docs/yaml-anchors/) and share properties between widgets.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: group
|
||||||
|
define: &shared-properties
|
||||||
|
type: reddit
|
||||||
|
show-thumbnails: true
|
||||||
|
collapse-after: 6
|
||||||
|
widgets:
|
||||||
|
- subreddit: gamingnews
|
||||||
|
<<: *shared-properties
|
||||||
|
- subreddit: games
|
||||||
|
<<: *shared-properties
|
||||||
|
- subreddit: pcgaming
|
||||||
|
<<: *shared-properties
|
||||||
|
```
|
||||||
|
|
||||||
### Extension
|
### Extension
|
||||||
Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP).
|
Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP).
|
||||||
|
|
||||||
@@ -951,6 +1018,7 @@ Properties for each site:
|
|||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| title | string | yes | |
|
| title | string | yes | |
|
||||||
| url | string | yes | |
|
| url | string | yes | |
|
||||||
|
| check-url | string | no | |
|
||||||
| icon | string | no | |
|
| icon | string | no | |
|
||||||
| allow-insecure | boolean | no | false |
|
| allow-insecure | boolean | no | false |
|
||||||
| same-tab | boolean | no | false |
|
| same-tab | boolean | no | false |
|
||||||
@@ -961,7 +1029,11 @@ The title used to indicate the site.
|
|||||||
|
|
||||||
`url`
|
`url`
|
||||||
|
|
||||||
The URL which will be requested and its response will determine the status of the site. Optionally, you can specify this using an environment variable with the syntax `${VARIABLE_NAME}`.
|
The public facing URL of a monitored service, the user will be redirected here. If `check-url` is not specified, this is used as the status check.
|
||||||
|
|
||||||
|
`check-url`
|
||||||
|
|
||||||
|
The URL which will be requested and its response will determine the status of the site. If not specified, the `url` property is used.
|
||||||
|
|
||||||
`icon`
|
`icon`
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 549 KiB |
@@ -53,6 +53,16 @@ theme:
|
|||||||
primary-color: 97 13 80
|
primary-color: 97 13 80
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Kanagawa Dark
|
||||||
|

|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
background-color: 240 13 14
|
||||||
|
primary-color: 51 33 68
|
||||||
|
negative-color: 358 100 68
|
||||||
|
contrast-multiplier: 1.2
|
||||||
|
```
|
||||||
|
|
||||||
### Tucan
|
### Tucan
|
||||||

|

|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module github.com/glanceapp/glance
|
module github.com/glanceapp/glance
|
||||||
|
|
||||||
go 1.22.3
|
go 1.22.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/mmcdole/gofeed v1.3.0
|
github.com/mmcdole/gofeed v1.3.0
|
||||||
|
|||||||
@@ -58,6 +58,11 @@
|
|||||||
font-size: var(--font-size-h4);
|
font-size: var(--font-size-h4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
height: 100%;
|
||||||
|
padding-block: var(--widget-gap);
|
||||||
|
}
|
||||||
|
|
||||||
.page-content, .page.content-ready .page-loading-container {
|
.page-content, .page.content-ready .page-loading-container {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -184,6 +189,57 @@
|
|||||||
transform: rotate(-90deg);
|
transform: rotate(-90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.widget-group-header {
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-title {
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-bottom: 0.1rem;
|
||||||
|
transition: color .3s, border-color .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-title:hover:not(.widget-group-title-current) {
|
||||||
|
border-bottom-color: var(--color-text-subdue);
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-title-current {
|
||||||
|
border-bottom-color: var(--color-primary);
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-content {
|
||||||
|
animation: widgetGroupContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-content[data-direction="right"] {
|
||||||
|
--direction: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-content[data-direction="left"] {
|
||||||
|
--direction: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes widgetGroupContentEntrance {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(var(--direction));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-content:not(.widget-group-content-current) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.widget-content:has(.expand-toggle-button:last-child) {
|
.widget-content:has(.expand-toggle-button:last-child) {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -260,6 +316,10 @@ html {
|
|||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -293,7 +353,6 @@ body {
|
|||||||
.page-columns {
|
.page-columns {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--widget-gap);
|
gap: var(--widget-gap);
|
||||||
margin: var(--widget-gap) 0;
|
|
||||||
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,13 +364,19 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-loading-container {
|
.page-loading-container {
|
||||||
margin: 50px auto;
|
height: 100%;
|
||||||
width: fit-content;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
animation: loadingContainerEntrance 200ms backwards;
|
animation: loadingContainerEntrance 200ms backwards;
|
||||||
animation-delay: 150ms;
|
animation-delay: 150ms;
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-loading-container > .loading-icon {
|
||||||
|
translate: 0 -250%;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes loadingContainerEntrance {
|
@keyframes loadingContainerEntrance {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -373,10 +438,19 @@ kbd:active {
|
|||||||
|
|
||||||
.content-bounds {
|
.content-bounds {
|
||||||
max-width: 1600px;
|
max-width: 1600px;
|
||||||
|
width: 100%;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
padding: 0 var(--content-bounds-padding);
|
padding: 0 var(--content-bounds-padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-width-wide .content-bounds {
|
||||||
|
max-width: 1920px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-width-slim .content-bounds {
|
||||||
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
|
||||||
.dynamic-columns {
|
.dynamic-columns {
|
||||||
gap: calc(var(--widget-content-vertical-padding) / 2);
|
gap: calc(var(--widget-content-vertical-padding) / 2);
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -595,7 +669,8 @@ kbd:active {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
margin-block: calc(var(--widget-gap) * 1.5);
|
padding-bottom: calc(var(--widget-gap) * 1.5);
|
||||||
|
padding-top: calc(var(--widget-gap) / 2);
|
||||||
animation: loadingContainerEntrance 200ms backwards;
|
animation: loadingContainerEntrance 200ms backwards;
|
||||||
animation-delay: 150ms;
|
animation-delay: 150ms;
|
||||||
}
|
}
|
||||||
@@ -1168,8 +1243,9 @@ kbd:active {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
.mobile-navigation-offset {
|
||||||
padding-bottom: calc(var(--mobile-navigation-height) + var(--content-bounds-padding));
|
height: var(--mobile-navigation-height);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-navigation {
|
.mobile-navigation {
|
||||||
@@ -1202,7 +1278,7 @@ kbd:active {
|
|||||||
padding: 15px var(--content-bounds-padding);
|
padding: 15px var(--content-bounds-padding);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow-x: scroll;
|
overflow-x: auto;
|
||||||
gap: 2.5rem;
|
gap: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1372,6 +1448,7 @@ kbd:active {
|
|||||||
.shrink-0 { flex-shrink: 0; }
|
.shrink-0 { flex-shrink: 0; }
|
||||||
.min-width-0 { min-width: 0; }
|
.min-width-0 { min-width: 0; }
|
||||||
.max-width-100 { max-width: 100%; }
|
.max-width-100 { max-width: 100%; }
|
||||||
|
.height-100 { height: 100%; }
|
||||||
.block { display: block; }
|
.block { display: block; }
|
||||||
.inline-block { display: inline-block; }
|
.inline-block { display: inline-block; }
|
||||||
.overflow-hidden { overflow: hidden; }
|
.overflow-hidden { overflow: hidden; }
|
||||||
@@ -1393,6 +1470,7 @@ kbd:active {
|
|||||||
.gap-7 { gap: 0.7rem; }
|
.gap-7 { gap: 0.7rem; }
|
||||||
.gap-10 { gap: 1rem; }
|
.gap-10 { gap: 1rem; }
|
||||||
.gap-15 { gap: 1.5rem; }
|
.gap-15 { gap: 1.5rem; }
|
||||||
|
.gap-20 { gap: 2rem; }
|
||||||
.gap-25 { gap: 2.5rem; }
|
.gap-25 { gap: 2.5rem; }
|
||||||
.gap-35 { gap: 3.5rem; }
|
.gap-35 { gap: 3.5rem; }
|
||||||
.gap-45 { gap: 4.5rem; }
|
.gap-45 { gap: 4.5rem; }
|
||||||
@@ -1402,6 +1480,7 @@ kbd:active {
|
|||||||
.margin-top-7 { margin-top: 0.7rem; }
|
.margin-top-7 { margin-top: 0.7rem; }
|
||||||
.margin-top-10 { margin-top: 1rem; }
|
.margin-top-10 { margin-top: 1rem; }
|
||||||
.margin-top-15 { margin-top: 1.5rem; }
|
.margin-top-15 { margin-top: 1.5rem; }
|
||||||
|
.margin-top-auto { margin-top: auto; }
|
||||||
.margin-block-3 { margin-block: 0.3rem; }
|
.margin-block-3 { margin-block: 0.3rem; }
|
||||||
.margin-block-5 { margin-block: 0.5rem; }
|
.margin-block-5 { margin-block: 0.5rem; }
|
||||||
.margin-block-7 { margin-block: 0.7rem; }
|
.margin-block-7 { margin-block: 0.7rem; }
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
async function fetchPageContent(pageSlug) {
|
async function fetchPageContent(pageData) {
|
||||||
// TODO: handle non 200 status codes/time outs
|
// TODO: handle non 200 status codes/time outs
|
||||||
// TODO: add retries
|
// TODO: add retries
|
||||||
const response = await fetch(`/api/pages/${pageSlug}/content/`);
|
const response = await fetch(`${pageData.baseURL}/api/pages/${pageData.slug}/content/`);
|
||||||
const content = await response.text();
|
const content = await response.text();
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
@@ -250,6 +250,46 @@ function setupDynamicRelativeTime() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupGroups() {
|
||||||
|
const groups = document.getElementsByClassName("widget-type-group");
|
||||||
|
|
||||||
|
if (groups.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let g = 0; g < groups.length; g++) {
|
||||||
|
const group = groups[g];
|
||||||
|
const titles = group.getElementsByClassName("widget-header")[0].children;
|
||||||
|
const tabs = group.getElementsByClassName("widget-group-contents")[0].children;
|
||||||
|
let current = 0;
|
||||||
|
|
||||||
|
for (let t = 0; t < titles.length; t++) {
|
||||||
|
const title = titles[t];
|
||||||
|
title.addEventListener("click", () => {
|
||||||
|
if (t == current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < titles.length; i++) {
|
||||||
|
titles[i].classList.remove("widget-group-title-current");
|
||||||
|
tabs[i].classList.remove("widget-group-content-current");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current < t) {
|
||||||
|
tabs[t].dataset.direction = "right";
|
||||||
|
} else {
|
||||||
|
tabs[t].dataset.direction = "left";
|
||||||
|
}
|
||||||
|
|
||||||
|
current = t;
|
||||||
|
|
||||||
|
title.classList.add("widget-group-title-current");
|
||||||
|
tabs[t].classList.add("widget-group-content-current");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setupLazyImages() {
|
function setupLazyImages() {
|
||||||
const images = document.querySelectorAll("img[loading=lazy]");
|
const images = document.querySelectorAll("img[loading=lazy]");
|
||||||
|
|
||||||
@@ -548,7 +588,7 @@ function setupClocks() {
|
|||||||
async function setupPage() {
|
async function setupPage() {
|
||||||
const pageElement = document.getElementById("page");
|
const pageElement = document.getElementById("page");
|
||||||
const pageContentElement = document.getElementById("page-content");
|
const pageContentElement = document.getElementById("page-content");
|
||||||
const pageContent = await fetchPageContent(pageData.slug);
|
const pageContent = await fetchPageContent(pageData);
|
||||||
|
|
||||||
pageContentElement.innerHTML = pageContent;
|
pageContentElement.innerHTML = pageContent;
|
||||||
|
|
||||||
@@ -558,6 +598,7 @@ async function setupPage() {
|
|||||||
setupSearchBoxes();
|
setupSearchBoxes();
|
||||||
setupCollapsibleLists();
|
setupCollapsibleLists();
|
||||||
setupCollapsibleGrids();
|
setupCollapsibleGrids();
|
||||||
|
setupGroups();
|
||||||
setupDynamicRelativeTime();
|
setupDynamicRelativeTime();
|
||||||
setupLazyImages();
|
setupLazyImages();
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ var (
|
|||||||
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
|
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
|
||||||
SearchTemplate = compileTemplate("search.html", "widget-base.html")
|
SearchTemplate = compileTemplate("search.html", "widget-base.html")
|
||||||
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
|
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
|
||||||
|
GroupTemplate = compileTemplate("group.html", "widget-base.html")
|
||||||
)
|
)
|
||||||
|
|
||||||
var globalTemplateFunctions = template.FuncMap{
|
var globalTemplateFunctions = template.FuncMap{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
|
<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="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="icon" type="image/png" sizes="50x50" href="{{ .App.AssetPath "favicon.png" }}">
|
||||||
<link rel="manifest" href="/static/manifest.json">
|
<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.AssetPath "favicon.png" }}" />
|
||||||
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
|
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
|
||||||
<script type="module" src="{{ .App.AssetPath "main.js" }}"></script>
|
<script type="module" src="{{ .App.AssetPath "main.js" }}"></script>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="widget-group-header">
|
||||||
|
<div class="widget-header gap-20">
|
||||||
|
{{ range $i, $widget := .Widgets }}
|
||||||
|
<button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}">{{ $widget.Title }}</button>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget-group-contents">
|
||||||
|
{{ range $i, $widget := .Widgets }}
|
||||||
|
<div class="widget-group-content{{ if eq $i 0 }} widget-group-content-current{{ end }}">{{ .Render }}</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ end }}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
{{ template "document.html" . }}
|
{{ template "document.html" . }}
|
||||||
|
|
||||||
{{ define "document-title" }}{{ .Page.Title }} - Glance{{ end }}
|
{{ define "document-title" }}{{ .Page.Title }}{{ end }}
|
||||||
|
|
||||||
{{ define "document-head-before" }}
|
{{ define "document-head-before" }}
|
||||||
<script>
|
<script>
|
||||||
const pageData = {
|
const pageData = {
|
||||||
slug: "{{ .Page.Slug }}",
|
slug: "{{ .Page.Slug }}",
|
||||||
|
baseURL: "{{ .App.Config.Server.BaseURL }}",
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "document-root-attrs" }}{{ if .App.Config.Theme.Light }}class="light-scheme"{{ end }}{{ end }}
|
{{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }}{{ end }}"{{ end }}
|
||||||
|
|
||||||
{{ define "document-head-after" }}
|
{{ define "document-head-after" }}
|
||||||
{{ template "page-style-overrides.gotmpl" . }}
|
{{ template "page-style-overrides.gotmpl" . }}
|
||||||
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
|
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
|
||||||
@@ -20,48 +22,53 @@
|
|||||||
|
|
||||||
{{ define "navigation-links" }}
|
{{ define "navigation-links" }}
|
||||||
{{ range .App.Config.Pages }}
|
{{ range .App.Config.Pages }}
|
||||||
<a href="/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a>
|
<a href="{{ $.App.Config.Server.BaseURL }}/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "document-body" }}
|
{{ define "document-body" }}
|
||||||
<div class="header-container content-bounds">
|
<div class="flex flex-column height-100">
|
||||||
<div class="header flex padding-inline-widget widget-content-frame">
|
{{ if not .Page.HideDesktopNavigation }}
|
||||||
<!-- TODO: Replace G with actual logo, first need an actual logo -->
|
<div class="header-container content-bounds">
|
||||||
<div class="logo">G</div>
|
<div class="header flex padding-inline-widget widget-content-frame">
|
||||||
<div class="nav flex grow">
|
<!-- TODO: Replace G with actual logo, first need an actual logo -->
|
||||||
|
<div class="logo">G</div>
|
||||||
|
<div class="nav flex grow">
|
||||||
|
{{ template "navigation-links" . }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<div class="mobile-navigation">
|
||||||
|
<div class="mobile-navigation-icons">
|
||||||
|
<a class="mobile-navigation-label" href="#top">↑</a>
|
||||||
|
{{ range $i, $column := .Page.Columns }}
|
||||||
|
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
|
||||||
|
{{ end }}
|
||||||
|
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-navigation-page-links">
|
||||||
{{ template "navigation-links" . }}
|
{{ template "navigation-links" . }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mobile-navigation">
|
<div class="content-bounds grow">
|
||||||
<div class="mobile-navigation-icons">
|
<div class="page" id="page">
|
||||||
<a class="mobile-navigation-label" href="#top">↑</a>
|
<div class="page-content" id="page-content"></div>
|
||||||
{{ range $i, $column := .Page.Columns }}
|
<div class="page-loading-container">
|
||||||
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
|
<!-- TODO: add a bigger/better loading indicator -->
|
||||||
{{ end }}
|
<div class="loading-icon"></div>
|
||||||
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="mobile-navigation-page-links">
|
|
||||||
{{ template "navigation-links" . }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer flex items-center flex-column">
|
<div class="footer flex items-center flex-column">
|
||||||
<div>
|
<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 }}
|
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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 class="mobile-navigation-offset"></div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
|
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
|
||||||
<div class="twitch-channel-avatar-container">
|
<div class="twitch-channel-avatar-container">
|
||||||
{{ if .Exists }}
|
{{ if .Exists }}
|
||||||
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
|
<a href="https://twitch.tv/{{ .Login }}" class="twitch-channel-avatar-link" target="_blank" rel="noreferrer">
|
||||||
|
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
|
||||||
|
</a>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||||
@@ -18,7 +20,9 @@
|
|||||||
<a href="https://twitch.tv/{{ .Login }}" class="size-h3{{ if .IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
<a href="https://twitch.tv/{{ .Login }}" class="size-h3{{ if .IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
{{ if .Exists }}
|
{{ if .Exists }}
|
||||||
{{ if .IsLive }}
|
{{ if .IsLive }}
|
||||||
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a>
|
{{ if .Category }}
|
||||||
|
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a>
|
||||||
|
{{ end }}
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
|
<li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
|
||||||
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
|
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
|
<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
|
||||||
|
{{ if not .HideHeader}}
|
||||||
<div class="widget-header">
|
<div class="widget-header">
|
||||||
{{ if ne "" .TitleURL}}<a href="{{ .TitleURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>{{ else }}<div class="uppercase">{{ .Title }}</div>{{ end }}
|
{{ 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 }}
|
{{ if and .Error .ContentAvailable }}
|
||||||
@@ -7,6 +8,7 @@
|
|||||||
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
|
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
{{ end }}
|
||||||
<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 }}
|
{{ if .ContentAvailable }}
|
||||||
{{ block "widget-content" . }}{{ end }}
|
{{ block "widget-content" . }}{{ end }}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
type SiteStatusRequest struct {
|
type SiteStatusRequest struct {
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url"`
|
||||||
|
CheckURL string `yaml:"check-url"`
|
||||||
AllowInsecure bool `yaml:"allow-insecure"`
|
AllowInsecure bool `yaml:"allow-insecure"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +21,13 @@ type SiteStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
|
func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
|
||||||
request, err := http.NewRequest(http.MethodGet, statusRequest.URL, nil)
|
var url string
|
||||||
|
if statusRequest.CheckURL != "" {
|
||||||
|
url = statusRequest.CheckURL
|
||||||
|
} else {
|
||||||
|
url = statusRequest.URL
|
||||||
|
}
|
||||||
|
request, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SiteStatus{
|
return SiteStatus{
|
||||||
|
|||||||
@@ -204,9 +204,11 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
|
|||||||
result.IsLive = true
|
result.IsLive = true
|
||||||
result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
|
result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
|
||||||
|
|
||||||
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil && streamMetadata.UserOrNull.Stream.Game != nil {
|
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil {
|
||||||
result.Category = streamMetadata.UserOrNull.Stream.Game.Name
|
if streamMetadata.UserOrNull.Stream.Game != nil {
|
||||||
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
|
result.Category = streamMetadata.UserOrNull.Stream.Game.Name
|
||||||
|
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
|
||||||
|
}
|
||||||
startedAt, err := time.Parse("2006-01-02T15:04:05Z", streamMetadata.UserOrNull.Stream.StartedAt)
|
startedAt, err := time.Parse("2006-01-02T15:04:05Z", streamMetadata.UserOrNull.Stream.StartedAt)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type youtubeFeedResponseXml struct {
|
type youtubeFeedResponseXml struct {
|
||||||
Channel string `xml:"title"`
|
Channel string `xml:"author>name"`
|
||||||
ChannelLink struct {
|
ChannelLink struct {
|
||||||
Href string `xml:"href,attr"`
|
Href string `xml:"href,attr"`
|
||||||
} `xml:"link"`
|
} `xml:"link"`
|
||||||
@@ -39,11 +39,19 @@ func parseYoutubeFeedTime(t string) time.Time {
|
|||||||
return parsedTime
|
return parsedTime
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (Videos, error) {
|
func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, includeShorts bool) (Videos, error) {
|
||||||
requests := make([]*http.Request, 0, len(channelIds))
|
requests := make([]*http.Request, 0, len(channelIds))
|
||||||
|
|
||||||
for i := range channelIds {
|
for i := range channelIds {
|
||||||
request, _ := http.NewRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id="+channelIds[i], nil)
|
var feedUrl string
|
||||||
|
if !includeShorts && strings.HasPrefix(channelIds[i], "UC") {
|
||||||
|
playlistId := strings.Replace(channelIds[i], "UC", "UULF", 1)
|
||||||
|
feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId
|
||||||
|
} else {
|
||||||
|
feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelIds[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
request, _ := http.NewRequest("GET", feedUrl, nil)
|
||||||
requests = append(requests, request)
|
requests = append(requests, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,12 +78,6 @@ func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (V
|
|||||||
|
|
||||||
for j := range response.Videos {
|
for j := range response.Videos {
|
||||||
video := &response.Videos[j]
|
video := &response.Videos[j]
|
||||||
|
|
||||||
// TODO: figure out a better way of skipping shorts
|
|
||||||
if strings.Contains(video.Title, "#shorts") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var videoUrl string
|
var videoUrl string
|
||||||
|
|
||||||
if videoUrlTemplate == "" {
|
if videoUrlTemplate == "" {
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for p := range config.Pages {
|
||||||
|
for c := range config.Pages[p].Columns {
|
||||||
|
for w := range config.Pages[p].Columns[c].Widgets {
|
||||||
|
if err := config.Pages[p].Columns[c].Widgets[w].Initialize(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,12 +60,22 @@ func configIsValid(config *Config) error {
|
|||||||
return fmt.Errorf("Page %d has no title", i+1)
|
return fmt.Errorf("Page %d has no title", i+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") {
|
||||||
|
return fmt.Errorf("Page %d: width can only be either wide or slim", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
if len(config.Pages[i].Columns) == 0 {
|
if len(config.Pages[i].Columns) == 0 {
|
||||||
return fmt.Errorf("Page %d has no columns", i+1)
|
return fmt.Errorf("Page %d has no columns", i+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(config.Pages[i].Columns) > 3 {
|
if config.Pages[i].Width == "slim" {
|
||||||
return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns))
|
if len(config.Pages[i].Columns) > 2 {
|
||||||
|
return fmt.Errorf("Page %d is slim and cannot have more than 2 columns", i+1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(config.Pages[i].Columns) > 3 {
|
||||||
|
return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
columnSizesCount := make(map[string]int)
|
columnSizesCount := make(map[string]int)
|
||||||
|
|||||||
+64
-15
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -24,6 +25,7 @@ type Application struct {
|
|||||||
Version string
|
Version string
|
||||||
Config Config
|
Config Config
|
||||||
slugToPage map[string]*Page
|
slugToPage map[string]*Page
|
||||||
|
widgetByID map[uint64]widget.Widget
|
||||||
}
|
}
|
||||||
|
|
||||||
type Theme struct {
|
type Theme struct {
|
||||||
@@ -38,10 +40,12 @@ type Theme struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
Port uint16 `yaml:"port"`
|
Port uint16 `yaml:"port"`
|
||||||
AssetsPath string `yaml:"assets-path"`
|
AssetsPath string `yaml:"assets-path"`
|
||||||
AssetsHash string `yaml:"-"`
|
BaseURL string `yaml:"base-url"`
|
||||||
|
AssetsHash string `yaml:"-"`
|
||||||
|
StartedAt time.Time `yaml:"-"` // used in custom css file
|
||||||
}
|
}
|
||||||
|
|
||||||
type Column struct {
|
type Column struct {
|
||||||
@@ -55,11 +59,13 @@ type templateData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Page struct {
|
type Page struct {
|
||||||
Title string `yaml:"name"`
|
Title string `yaml:"name"`
|
||||||
Slug string `yaml:"slug"`
|
Slug string `yaml:"slug"`
|
||||||
ShowMobileHeader bool `yaml:"show-mobile-header"`
|
Width string `yaml:"width"`
|
||||||
Columns []Column `yaml:"columns"`
|
ShowMobileHeader bool `yaml:"show-mobile-header"`
|
||||||
mu sync.Mutex
|
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
||||||
|
Columns []Column `yaml:"columns"`
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Page) UpdateOutdatedWidgets() {
|
func (p *Page) UpdateOutdatedWidgets() {
|
||||||
@@ -105,16 +111,34 @@ func NewApplication(config *Config) (*Application, error) {
|
|||||||
Version: buildVersion,
|
Version: buildVersion,
|
||||||
Config: *config,
|
Config: *config,
|
||||||
slugToPage: make(map[string]*Page),
|
slugToPage: make(map[string]*Page),
|
||||||
|
widgetByID: make(map[uint64]widget.Widget),
|
||||||
}
|
}
|
||||||
|
|
||||||
app.slugToPage[""] = &config.Pages[0]
|
app.slugToPage[""] = &config.Pages[0]
|
||||||
|
|
||||||
for i := range config.Pages {
|
for p := range config.Pages {
|
||||||
if config.Pages[i].Slug == "" {
|
if config.Pages[p].Slug == "" {
|
||||||
config.Pages[i].Slug = titleToSlug(config.Pages[i].Title)
|
config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.slugToPage[config.Pages[i].Slug] = &config.Pages[i]
|
app.slugToPage[config.Pages[p].Slug] = &config.Pages[p]
|
||||||
|
|
||||||
|
for c := range config.Pages[p].Columns {
|
||||||
|
for w := range config.Pages[p].Columns[c].Widgets {
|
||||||
|
widget := config.Pages[p].Columns[c].Widgets[w]
|
||||||
|
app.widgetByID[widget.GetID()] = widget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config = &app.Config
|
||||||
|
|
||||||
|
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
|
||||||
|
|
||||||
|
if config.Server.BaseURL != "" &&
|
||||||
|
config.Theme.CustomCSSFile != "" &&
|
||||||
|
strings.HasPrefix(config.Theme.CustomCSSFile, "/assets/") {
|
||||||
|
config.Theme.CustomCSSFile = config.Server.BaseURL + config.Theme.CustomCSSFile
|
||||||
}
|
}
|
||||||
|
|
||||||
return app, nil
|
return app, nil
|
||||||
@@ -189,8 +213,28 @@ func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.H
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Application) HandleWidgetRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
widgetValue := r.PathValue("widget")
|
||||||
|
|
||||||
|
widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
a.HandleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
widget, exists := a.widgetByID[widgetID]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
a.HandleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.HandleRequest(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Application) AssetPath(asset string) string {
|
func (a *Application) AssetPath(asset string) string {
|
||||||
return "/static/" + a.Config.Server.AssetsHash + "/" + asset
|
return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Application) Serve() error {
|
func (a *Application) Serve() error {
|
||||||
@@ -202,7 +246,10 @@ func (a *Application) Serve() error {
|
|||||||
|
|
||||||
mux.HandleFunc("GET /{$}", a.HandlePageRequest)
|
mux.HandleFunc("GET /{$}", a.HandlePageRequest)
|
||||||
mux.HandleFunc("GET /{page}", a.HandlePageRequest)
|
mux.HandleFunc("GET /{page}", a.HandlePageRequest)
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
|
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
|
||||||
|
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest)
|
||||||
|
|
||||||
mux.Handle(
|
mux.Handle(
|
||||||
fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
|
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), 8*time.Hour)),
|
||||||
@@ -225,6 +272,8 @@ func (a *Application) Serve() error {
|
|||||||
Handler: mux,
|
Handler: mux,
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port)
|
a.Config.Server.StartedAt = time.Now()
|
||||||
|
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL)
|
||||||
|
|
||||||
return server.ListenAndServe()
|
return server.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Group struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
Widgets Widgets `yaml:"widgets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Group) Initialize() error {
|
||||||
|
widget.withError(nil)
|
||||||
|
widget.HideHeader = true
|
||||||
|
|
||||||
|
for i := range widget.Widgets {
|
||||||
|
widget.Widgets[i].SetHideHeader(true)
|
||||||
|
|
||||||
|
if widget.Widgets[i].GetType() == "group" {
|
||||||
|
return errors.New("nested groups are not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := widget.Widgets[i].Initialize(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Group) Update(ctx context.Context) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for w := range widget.Widgets {
|
||||||
|
widget := widget.Widgets[w]
|
||||||
|
|
||||||
|
if !widget.RequiresUpdate(&now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
widget.Update(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Group) RequiresUpdate(now *time.Time) bool {
|
||||||
|
for i := range widget.Widgets {
|
||||||
|
if widget.Widgets[i].RequiresUpdate(now) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Group) Render() template.HTML {
|
||||||
|
return widget.render(widget, assets.GroupTemplate)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ type Videos struct {
|
|||||||
CollapseAfterRows int `yaml:"collapse-after-rows"`
|
CollapseAfterRows int `yaml:"collapse-after-rows"`
|
||||||
Channels []string `yaml:"channels"`
|
Channels []string `yaml:"channels"`
|
||||||
Limit int `yaml:"limit"`
|
Limit int `yaml:"limit"`
|
||||||
|
IncludeShorts bool `yaml:"include-shorts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Videos) Initialize() error {
|
func (widget *Videos) Initialize() error {
|
||||||
@@ -34,7 +35,7 @@ func (widget *Videos) Initialize() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Videos) Update(ctx context.Context) {
|
func (widget *Videos) Update(ctx context.Context) {
|
||||||
videos, err := feed.FetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate)
|
videos, err := feed.FetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate, widget.IncludeShorts)
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
return
|
return
|
||||||
|
|||||||
+54
-24
@@ -8,6 +8,8 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glanceapp/glance/internal/feed"
|
"github.com/glanceapp/glance/internal/feed"
|
||||||
@@ -15,51 +17,61 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var uniqueID atomic.Uint64
|
||||||
|
|
||||||
func New(widgetType string) (Widget, error) {
|
func New(widgetType string) (Widget, error) {
|
||||||
|
var widget Widget
|
||||||
|
|
||||||
switch widgetType {
|
switch widgetType {
|
||||||
case "calendar":
|
case "calendar":
|
||||||
return &Calendar{}, nil
|
widget = &Calendar{}
|
||||||
case "clock":
|
case "clock":
|
||||||
return &Clock{}, nil
|
widget = &Clock{}
|
||||||
case "weather":
|
case "weather":
|
||||||
return &Weather{}, nil
|
widget = &Weather{}
|
||||||
case "bookmarks":
|
case "bookmarks":
|
||||||
return &Bookmarks{}, nil
|
widget = &Bookmarks{}
|
||||||
case "iframe":
|
case "iframe":
|
||||||
return &IFrame{}, nil
|
widget = &IFrame{}
|
||||||
case "html":
|
case "html":
|
||||||
return &HTML{}, nil
|
widget = &HTML{}
|
||||||
case "hacker-news":
|
case "hacker-news":
|
||||||
return &HackerNews{}, nil
|
widget = &HackerNews{}
|
||||||
case "releases":
|
case "releases":
|
||||||
return &Releases{}, nil
|
widget = &Releases{}
|
||||||
case "videos":
|
case "videos":
|
||||||
return &Videos{}, nil
|
widget = &Videos{}
|
||||||
case "markets", "stocks":
|
case "markets", "stocks":
|
||||||
return &Markets{}, nil
|
widget = &Markets{}
|
||||||
case "reddit":
|
case "reddit":
|
||||||
return &Reddit{}, nil
|
widget = &Reddit{}
|
||||||
case "rss":
|
case "rss":
|
||||||
return &RSS{}, nil
|
widget = &RSS{}
|
||||||
case "monitor":
|
case "monitor":
|
||||||
return &Monitor{}, nil
|
widget = &Monitor{}
|
||||||
case "twitch-top-games":
|
case "twitch-top-games":
|
||||||
return &TwitchGames{}, nil
|
widget = &TwitchGames{}
|
||||||
case "twitch-channels":
|
case "twitch-channels":
|
||||||
return &TwitchChannels{}, nil
|
widget = &TwitchChannels{}
|
||||||
case "lobsters":
|
case "lobsters":
|
||||||
return &Lobsters{}, nil
|
widget = &Lobsters{}
|
||||||
case "change-detection":
|
case "change-detection":
|
||||||
return &ChangeDetection{}, nil
|
widget = &ChangeDetection{}
|
||||||
case "repository":
|
case "repository":
|
||||||
return &Repository{}, nil
|
widget = &Repository{}
|
||||||
case "search":
|
case "search":
|
||||||
return &Search{}, nil
|
widget = &Search{}
|
||||||
case "extension":
|
case "extension":
|
||||||
return &Extension{}, nil
|
widget = &Extension{}
|
||||||
|
case "group":
|
||||||
|
widget = &Group{}
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
widget.SetID(uniqueID.Add(1))
|
||||||
|
|
||||||
|
return widget, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type Widgets []Widget
|
type Widgets []Widget
|
||||||
@@ -90,10 +102,6 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = widget.Initialize(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
*w = append(*w, widget)
|
*w = append(*w, widget)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +114,10 @@ type Widget interface {
|
|||||||
Update(context.Context)
|
Update(context.Context)
|
||||||
Render() template.HTML
|
Render() template.HTML
|
||||||
GetType() string
|
GetType() string
|
||||||
|
GetID() uint64
|
||||||
|
SetID(uint64)
|
||||||
|
HandleRequest(w http.ResponseWriter, r *http.Request)
|
||||||
|
SetHideHeader(bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
type cacheType int
|
type cacheType int
|
||||||
@@ -117,6 +129,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type widgetBase struct {
|
type widgetBase struct {
|
||||||
|
ID uint64 `yaml:"-"`
|
||||||
Type string `yaml:"type"`
|
Type string `yaml:"type"`
|
||||||
Title string `yaml:"title"`
|
Title string `yaml:"title"`
|
||||||
TitleURL string `yaml:"title-url"`
|
TitleURL string `yaml:"title-url"`
|
||||||
@@ -130,6 +143,7 @@ type widgetBase struct {
|
|||||||
cacheType cacheType `yaml:"-"`
|
cacheType cacheType `yaml:"-"`
|
||||||
nextUpdate time.Time `yaml:"-"`
|
nextUpdate time.Time `yaml:"-"`
|
||||||
updateRetriedTimes int `yaml:"-"`
|
updateRetriedTimes int `yaml:"-"`
|
||||||
|
HideHeader bool `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
|
func (w *widgetBase) RequiresUpdate(now *time.Time) bool {
|
||||||
@@ -148,6 +162,22 @@ func (w *widgetBase) Update(ctx context.Context) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *widgetBase) GetID() uint64 {
|
||||||
|
return w.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *widgetBase) SetID(id uint64) {
|
||||||
|
w.ID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *widgetBase) SetHideHeader(value bool) {
|
||||||
|
w.HideHeader = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *widgetBase) HandleRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "not implemented", http.StatusNotImplemented)
|
||||||
|
}
|
||||||
|
|
||||||
func (w *widgetBase) GetType() string {
|
func (w *widgetBase) GetType() string {
|
||||||
return w.Type
|
return w.Type
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// bunch of spaget but it does the job for now
|
|
||||||
// TODO: tidy up and add a proper build system with CI
|
|
||||||
|
|
||||||
const buildPath = "./build"
|
|
||||||
const archivesPath = "./build/archives"
|
|
||||||
const executableName = "glance"
|
|
||||||
const ownerAndRepo = "glanceapp/glance"
|
|
||||||
const moduleName = "github.com/" + ownerAndRepo
|
|
||||||
|
|
||||||
type archiveType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
archiveTypeTarGz archiveType = iota
|
|
||||||
archiveTypeZip
|
|
||||||
)
|
|
||||||
|
|
||||||
type buildInfo struct {
|
|
||||||
version string
|
|
||||||
}
|
|
||||||
|
|
||||||
type buildTarget struct {
|
|
||||||
os string
|
|
||||||
arch string
|
|
||||||
armV int
|
|
||||||
extension string
|
|
||||||
archive archiveType
|
|
||||||
}
|
|
||||||
|
|
||||||
var buildTargets = []buildTarget{
|
|
||||||
{
|
|
||||||
os: "windows",
|
|
||||||
arch: "amd64",
|
|
||||||
extension: ".exe",
|
|
||||||
archive: archiveTypeZip,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
os: "windows",
|
|
||||||
arch: "arm64",
|
|
||||||
extension: ".exe",
|
|
||||||
archive: archiveTypeZip,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
os: "linux",
|
|
||||||
arch: "amd64",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
os: "linux",
|
|
||||||
arch: "arm64",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
os: "linux",
|
|
||||||
arch: "arm",
|
|
||||||
armV: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
os: "linux",
|
|
||||||
arch: "arm",
|
|
||||||
armV: 7,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
os: "openbsd",
|
|
||||||
arch: "amd64",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
os: "openbsd",
|
|
||||||
arch: "386",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func 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 {
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = os.Stat(buildPath)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
fmt.Println("Cleaning up build path")
|
|
||||||
os.RemoveAll(buildPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Mkdir(buildPath, 0755)
|
|
||||||
os.Mkdir(archivesPath, 0755)
|
|
||||||
|
|
||||||
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(string(output))
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
info := buildInfo{
|
|
||||||
version: version,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, target := range buildTargets {
|
|
||||||
fmt.Printf("Building for %s/%s\n", target.os, target.arch)
|
|
||||||
if err := build(cwd, info, target); err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
versionTag := fmt.Sprintf("%s:%s", ownerAndRepo, version)
|
|
||||||
latestTag := fmt.Sprintf("%s:latest", ownerAndRepo)
|
|
||||||
|
|
||||||
fmt.Println("Building docker image")
|
|
||||||
|
|
||||||
var dockerBuildOptions = []string{
|
|
||||||
"docker", "build",
|
|
||||||
"--platform=linux/amd64,linux/arm64,linux/arm/v7",
|
|
||||||
"-t", versionTag,
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
var input string
|
|
||||||
fmt.Print("Push docker image? [y/n]: ")
|
|
||||||
fmt.Scanln(&input)
|
|
||||||
|
|
||||||
if input != "y" {
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
output, err = exec.Command(
|
|
||||||
"sudo", "docker", "push", versionTag,
|
|
||||||
).CombinedOutput()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Failed pushing %s:\n", versionTag)
|
|
||||||
fmt.Println(string(output))
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(version, "beta") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
output, err = exec.Command(
|
|
||||||
"sudo", "docker", "push", latestTag,
|
|
||||||
).CombinedOutput()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Failed pushing %s:\n", latestTag)
|
|
||||||
fmt.Println(string(output))
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getVersionFromGit() (string, error) {
|
|
||||||
output, err := exec.Command("git", "describe", "--tags", "--abbrev=0").CombinedOutput()
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
return strings.TrimSpace(string(output)), err
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(output), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func archiveFile(name string, target string, t archiveType) error {
|
|
||||||
var output []byte
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if t == archiveTypeZip {
|
|
||||||
output, err = exec.Command("zip", "-j", path.Join(archivesPath, name+".zip"), target).CombinedOutput()
|
|
||||||
} else if t == archiveTypeTarGz {
|
|
||||||
output, err = exec.Command("tar", "-C", buildPath, "-czf", path.Join(archivesPath, name+".tar.gz"), name).CombinedOutput()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(string(output))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func build(workingDir string, info buildInfo, target buildTarget) error {
|
|
||||||
var name string
|
|
||||||
|
|
||||||
if target.arch != "arm" {
|
|
||||||
name = fmt.Sprintf("%s-%s-%s%s", executableName, target.os, target.arch, target.extension)
|
|
||||||
} else {
|
|
||||||
name = fmt.Sprintf("%s-%s-%sv%d", executableName, target.os, target.arch, target.armV)
|
|
||||||
}
|
|
||||||
|
|
||||||
binaryPath := path.Join(buildPath, name)
|
|
||||||
|
|
||||||
glancePackage := moduleName + "/internal/glance"
|
|
||||||
|
|
||||||
flags := "-s -w"
|
|
||||||
flags += fmt.Sprintf(" -X %s.buildVersion=%s", glancePackage, info.version)
|
|
||||||
|
|
||||||
cmd := exec.Command(
|
|
||||||
"go",
|
|
||||||
"build",
|
|
||||||
"--trimpath",
|
|
||||||
"--ldflags",
|
|
||||||
flags,
|
|
||||||
"-o",
|
|
||||||
binaryPath,
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd.Dir = workingDir
|
|
||||||
env := append(os.Environ(), "GOOS="+target.os, "GOARCH="+target.arch, "CGO_ENABLED=0")
|
|
||||||
|
|
||||||
if target.arch == "arm" {
|
|
||||||
env = append(env, fmt.Sprintf("GOARM=%d", target.armV))
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Env = env
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
fmt.Println(string(output))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Chmod(binaryPath, 0755)
|
|
||||||
|
|
||||||
fmt.Println("Creating archive")
|
|
||||||
if err := archiveFile(name, binaryPath, target.archive); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user