Compare 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 | ||
|
|
e09ea60616 | ||
|
|
7cab07fa01 | ||
|
|
5a62a8afe9 | ||
|
|
23d6d41148 | ||
|
|
a07fbfd7b7 | ||
|
|
1411268e35 | ||
|
|
9bda88edbd | ||
|
|
16da25898f | ||
|
|
88ae2b8e83 | ||
|
|
3805f8b156 | ||
|
|
39f40670d0 | ||
|
|
514cf2b81c | ||
|
|
4dbb5975c0 | ||
|
|
b327e59ab1 | ||
|
|
917d51e54b | ||
|
|
122d05346b | ||
|
|
645802981e | ||
|
|
0348a2475c | ||
|
|
a2b7f59dc9 | ||
|
|
c23dc93c7a | ||
|
|
d3e3667405 | ||
|
|
d4ed5afe93 | ||
|
|
790ecef1c1 | ||
|
|
edb59ffdc6 | ||
|
|
e137038586 | ||
|
|
761eb343f9 | ||
|
|
6c15c907bf | ||
|
|
06b21e3d27 | ||
|
|
263d2e6f30 | ||
|
|
c97fec66f4 | ||
|
|
d5cf881d7a | ||
|
|
050d3e6cd2 | ||
|
|
757f68c872 | ||
|
|
b83bf9bc09 | ||
|
|
8d96e883d6 | ||
|
|
75079a981b | ||
|
|
e8043be107 | ||
|
|
c276f66017 | ||
|
|
db7df33b27 | ||
|
|
1b8e355944 | ||
|
|
342ef90cbe | ||
|
|
b3be60acf6 | ||
|
|
b58d3e2849 | ||
|
|
4206fb943d | ||
|
|
ee58ff40f1 | ||
|
|
21fd842bde | ||
|
|
b63b5eb262 | ||
|
|
9c69509a19 | ||
|
|
511147fb90 | ||
|
|
9ce0030482 | ||
|
|
4164263639 | ||
|
|
1bebb88d0e | ||
|
|
d2f5dbbc26 | ||
|
|
2af6500028 | ||
|
|
a7cf8be8c4 | ||
|
|
69b3e544bd | ||
|
|
0c63c61db5 | ||
|
|
8dc34ddfa7 | ||
|
|
8efda0047c | ||
|
|
e3c17e0347 | ||
|
|
038c43bc09 | ||
|
|
720549ec7e | ||
|
|
3aa1f273b6 | ||
|
|
5105ebb870 | ||
|
|
0a55c12f4f | ||
|
|
67c20c3a5f | ||
|
|
f4f7892123 | ||
|
|
00a93e466d | ||
|
|
d27993d2c9 | ||
|
|
bfc65d51e3 | ||
|
|
3c29d38599 | ||
|
|
f8816ff227 | ||
|
|
a5be460c2e | ||
|
|
e1e7853c34 | ||
|
|
3f862f67ab | ||
|
|
4bb5b2641b | ||
|
|
f044fdf5dc | ||
|
|
d1be4637ad | ||
|
|
d18f645c18 | ||
|
|
6663360caa | ||
|
|
c2d24ee4d9 | ||
|
|
37471fcdc1 | ||
|
|
0f42567d98 | ||
|
|
8148f09b9c | ||
|
|
c270afb5e5 | ||
|
|
6de0f73ec1 | ||
|
|
a80a34afb1 | ||
|
|
584f813fc4 | ||
|
|
a44056dc43 | ||
|
|
c941c78ebf | ||
|
|
9e9f001fdf | ||
|
|
9060887694 | ||
|
|
6d9ea27e9f | ||
|
|
4f4973157c | ||
|
|
859a6d2f27 | ||
|
|
e3ad646bae | ||
|
|
53a661861a | ||
|
|
4a27ec3271 | ||
|
|
926de71274 | ||
|
|
b0e57bae9f | ||
|
|
0bbb4573b2 | ||
|
|
eeda28a287 | ||
|
|
59c55b96be | ||
|
|
060609e2cb | ||
|
|
a1c48bf012 | ||
|
|
a67fb66d2e | ||
|
|
3001f4845a | ||
|
|
192c1ad669 | ||
|
|
89ae8a3607 | ||
|
|
21909e0bda | ||
|
|
5e9635a76f | ||
|
|
6627a635d5 | ||
|
|
20740f33af | ||
|
|
0681a04607 | ||
|
|
21d491843f | ||
|
|
566ac99a53 | ||
|
|
fca504901f | ||
|
|
e03231cbd7 | ||
|
|
693051f4e9 | ||
|
|
06e891f9a9 | ||
|
|
a2bb1b88ea | ||
|
|
9602269f27 | ||
|
|
27b9af6067 | ||
|
|
d43c0749c1 | ||
|
|
f4fca7600a | ||
|
|
6134f1c812 | ||
|
|
8392b7a606 | ||
|
|
94cf82c3d9 | ||
|
|
b0018c3f06 | ||
|
|
8977114806 | ||
|
|
36f8eac3e4 | ||
|
|
0f01a8eda5 | ||
|
|
de965dace8 | ||
|
|
f0ca2edb25 | ||
|
|
514d5f92d1 | ||
|
|
7e05f8a5eb | ||
|
|
48d4d4e966 | ||
|
|
6a7b50b35f | ||
|
|
6cad5a8efb | ||
|
|
7743664527 | ||
|
|
714e1b6d29 | ||
|
|
a0585e097f | ||
|
|
6363b76d33 | ||
|
|
503562f2e5 | ||
|
|
01a7331354 | ||
|
|
c07a1ab636 | ||
|
|
eb9f92f04a | ||
|
|
c05607934b | ||
|
|
f9ca93e0b0 | ||
|
|
09b7d71103 | ||
|
|
bc06578e53 | ||
|
|
344f518991 | ||
|
|
6bb44bef20 | ||
|
|
aef0c84286 | ||
|
|
0fbc8dcb41 | ||
|
|
818138340c | ||
|
|
4beab6bcec | ||
|
|
b9b7ae1cac | ||
|
|
91c75518da | ||
|
|
82f51293df | ||
|
|
840ba0f240 | ||
|
|
c5e3eed64b | ||
|
|
7dd1cca65d | ||
|
|
57414bdb67 | ||
|
|
646be63895 | ||
|
|
3c1fb97c12 | ||
|
|
e78619f5e1 | ||
|
|
aa04904a72 | ||
|
|
54102ae35e | ||
|
|
7c4c1798b6 | ||
|
|
f30006093d | ||
|
|
c8ef47064a | ||
|
|
461c984317 | ||
|
|
ecbad7ce6c | ||
|
|
7adf624e95 | ||
|
|
56cb97706b | ||
|
|
dd5bec799f | ||
|
|
2389415071 | ||
|
|
976fb02bd3 | ||
|
|
007fe18d27 | ||
|
|
11f2999b52 | ||
|
|
e204029d3c | ||
|
|
d3eaae2fb0 | ||
|
|
251c22a14f | ||
|
|
7d0a7186e8 | ||
|
|
7b444b88e3 | ||
|
|
f317b1b189 | ||
|
|
3523562c3a | ||
|
|
5fdc3e3f10 | ||
|
|
24ef8f6e2a | ||
|
|
d8d6625478 | ||
|
|
ad06146784 | ||
|
|
c97171883c | ||
|
|
9c5b6045af | ||
|
|
eb85a7656f | ||
|
|
25d45b309d | ||
|
|
7d0b4535bb | ||
|
|
f8af2025e8 | ||
|
|
948c117f5b | ||
|
|
58967ab758 | ||
|
|
44a153d30a | ||
|
|
d5d6103327 | ||
|
|
189b8895b0 | ||
|
|
748ad1ff8d | ||
|
|
ccdafcdfab | ||
|
|
78572a534b | ||
|
|
2eb8e71e89 | ||
|
|
5375103c4b | ||
|
|
faeeb7c899 | ||
|
|
99fd77feac |
@@ -0,0 +1,10 @@
|
|||||||
|
# https://docs.docker.com/build/building/context/#dockerignore-files
|
||||||
|
# Ignore all files by default
|
||||||
|
*
|
||||||
|
|
||||||
|
# Only add necessary files to the Docker build context (Dockerfiles are always included implicitly)
|
||||||
|
!/build/
|
||||||
|
!/internal/
|
||||||
|
!/go.mod
|
||||||
|
!/go.sum
|
||||||
|
!main.go
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
glanceapp@duck.com.
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
https://www.contributor-covenant.org/translations.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<!--
|
||||||
|
|
||||||
|
If your pull request adds new features or changes existing ones please use the latest release/* branch as the base.
|
||||||
|
|
||||||
|
Documentation updates (including new themes) can be submitted to the main branch.
|
||||||
|
|
||||||
|
-->
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
Security updates will be applied to the latest as well as previous minor version release depending on severity and if applicable.
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Please report any suspected security vulnerabilities to [glanceapp@duck.com](mailto:glanceapp@duck.com) and do not disclose them publicly. You should receive a response within a few days and if confirmed the issue will be resolved as soon as possible.
|
||||||
@@ -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
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
FROM alpine:3.19
|
FROM golang:1.22.5-alpine3.20 AS builder
|
||||||
|
|
||||||
ARG TARGETOS
|
|
||||||
ARG TARGETARCH
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY build/glance-$TARGETOS-$TARGETARCH /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"]
|
||||||
@@ -10,13 +10,18 @@
|
|||||||
* Subreddit posts
|
* Subreddit posts
|
||||||
* Weather
|
* Weather
|
||||||
* Bookmarks
|
* Bookmarks
|
||||||
|
* Hacker News
|
||||||
|
* Lobsters
|
||||||
* Latest YouTube videos from specific channels
|
* Latest YouTube videos from specific channels
|
||||||
|
* Clock
|
||||||
* Calendar
|
* Calendar
|
||||||
* Stocks
|
* Stocks
|
||||||
* iframe
|
* iframe
|
||||||
* Twitch channels & top games
|
* Twitch channels & top games
|
||||||
* GitHub releases
|
* GitHub releases
|
||||||
|
* Repository overview
|
||||||
* Site monitor
|
* Site monitor
|
||||||
|
* Search box
|
||||||
|
|
||||||
#### Themeable
|
#### Themeable
|
||||||

|

|
||||||
@@ -41,16 +46,16 @@ Checkout the [configuration docs](docs/configuration.md) to learn more. A [preco
|
|||||||
#### Manual
|
#### Manual
|
||||||
Checkout the [releases page](https://github.com/glanceapp/glance/releases) for available binaries. You can place the binary inside `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). To specify a different path for the config file use the `--config` option:
|
Checkout the [releases page](https://github.com/glanceapp/glance/releases) for available binaries. You can place the binary inside `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). To specify a different path for the config file use the `--config` option:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
/opt/glance/glance --config /etc/glance.yml
|
/opt/glance/glance --config /etc/glance.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
>
|
>
|
||||||
> Make sure you have a valid `glance.yml` file before running the container.
|
> Make sure you have a valid `glance.yml` file in the same directory before running the container.
|
||||||
|
|
||||||
```console
|
```bash
|
||||||
docker run -d -p 8080:8080 \
|
docker run -d -p 8080:8080 \
|
||||||
-v ./glance.yml:/app/glance.yml \
|
-v ./glance.yml:/app/glance.yml \
|
||||||
-v /etc/timezone:/etc/timezone:ro \
|
-v /etc/timezone:/etc/timezone:ro \
|
||||||
@@ -79,12 +84,28 @@ Requirements: [Go](https://go.dev/dl/) >= v1.22
|
|||||||
|
|
||||||
To build:
|
To build:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
go build .
|
go build -o build/glance .
|
||||||
```
|
```
|
||||||
|
|
||||||
To run:
|
To run:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Building Docker image
|
||||||
|
|
||||||
|
Build the image:
|
||||||
|
|
||||||
|
**Make sure to replace "owner" with your name or organization.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t owner/glance:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
Push the image to your registry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker push owner/glance:latest
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Extensions
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
>
|
||||||
|
> **This document as well as the extensions feature are a work in progress. The API may change in the future. You are responsible for maintaining your own extensions.**
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
With the intention of requiring minimal knowledge in order to develop extensions, rather than being a convoluted protocol they are nothing more than an HTTP request to a server that returns a few special headers. The exchange between Glance and extensions can be seen in the following diagram:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
If you know how to setup an HTTP server and a bit of HTML and CSS you're ready to start building your own extensions.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
>
|
||||||
|
> By default, the extension widget has a cache time of 30 minutes. To avoid having to restart Glance after every extension change you can set the cache time of the widget to 1 second:
|
||||||
|
> ```yaml
|
||||||
|
> - type: extension
|
||||||
|
> url: http://localhost:8081
|
||||||
|
> cache: 1s
|
||||||
|
> ```
|
||||||
|
|
||||||
|
## Headers
|
||||||
|
|
||||||
|
### `Widget-Title`
|
||||||
|
Used to specify the title of the widget. If not provided, the widget's title will be "Extension".
|
||||||
|
|
||||||
|
### `Widget-Content-Type`
|
||||||
|
Used to specify the content type that will be returned by the extension. If not provided, the content will be shown as plain text.
|
||||||
|
|
||||||
|
## Content Types
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
>
|
||||||
|
> Currently, `html` is the only supported content type. The long-term goal is to have generic content types such as `videos`, `forum-posts`, `markets`, `streams`, etc. which will be returned in JSON format and displayed by Glance using existing styles and functionality, allowing extension developers to achieve a native look while only focusing on providing data from their preferred source.
|
||||||
|
|
||||||
|
### `html`
|
||||||
|
Displays the content as HTML. This requires the user to have the `allow-potentially-dangerous-html` property set to `true`, otherwise the content will be shown as plain text.
|
||||||
|
|
||||||
|
|
||||||
|
#### Using existing classes and functionality
|
||||||
|
Most of the features seen throughout Glance can easily be used in your custom HTML extensions. Below is an example of some of these features:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<p class="color-subdue">Text with subdued color</p>
|
||||||
|
<p>Text with base color</p>
|
||||||
|
<p class="color-highlight">Text with highlighted color</p>
|
||||||
|
<p class="color-primary">Text with primary color</p>
|
||||||
|
<p class="color-positive">Text with positive color</p>
|
||||||
|
<p class="color-negative">Text with negative color</p>
|
||||||
|
|
||||||
|
<hr class="margin-block-15">
|
||||||
|
|
||||||
|
<p class="size-h1">Font size 1</p>
|
||||||
|
<p class="size-h2">Font size 2</p>
|
||||||
|
<p class="size-h3">Font size 3</p>
|
||||||
|
<p class="size-h4">Font size 4</p>
|
||||||
|
<p class="size-base">Font size base</p>
|
||||||
|
<p class="size-h5">Font size 5</p>
|
||||||
|
<p class="size-h6">Font size 6</p>
|
||||||
|
|
||||||
|
<hr class="margin-block-15">
|
||||||
|
|
||||||
|
<a class="visited-indicator" href="#notvisitedprobably">Link with visited indicator</a>
|
||||||
|
|
||||||
|
<hr class="margin-block-15">
|
||||||
|
|
||||||
|
<a class="color-primary-if-not-visited" href="#notvisitedprobably">Link with primary color if not visited</a>
|
||||||
|
|
||||||
|
<hr class="margin-block-15">
|
||||||
|
|
||||||
|
<p>Event happened <span data-dynamic-relative-time="<unix timestamp>"></span> ago</p>
|
||||||
|
|
||||||
|
<hr class="margin-block-15">
|
||||||
|
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
<li>horizontal</li>
|
||||||
|
<li>list</li>
|
||||||
|
<li>with</li>
|
||||||
|
<li>multiple</li>
|
||||||
|
<li>text</li>
|
||||||
|
<li>items</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr class="margin-block-15">
|
||||||
|
|
||||||
|
<ul class="list list-gap-10 list-with-separator">
|
||||||
|
<li>list</li>
|
||||||
|
<li>with</li>
|
||||||
|
<li>gap</li>
|
||||||
|
<li>and</li>
|
||||||
|
<li>horizontal</li>
|
||||||
|
<li>lines</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr class="margin-block-15">
|
||||||
|
|
||||||
|
<ul class="list collapsible-container" data-collapse-after="3">
|
||||||
|
<li>collapsible</li>
|
||||||
|
<li>list</li>
|
||||||
|
<li>with</li>
|
||||||
|
<li>many</li>
|
||||||
|
<li>items</li>
|
||||||
|
<li>that</li>
|
||||||
|
<li>will</li>
|
||||||
|
<li>appear</li>
|
||||||
|
<li>when</li>
|
||||||
|
<li>you</li>
|
||||||
|
<li>click</li>
|
||||||
|
<li>the</li>
|
||||||
|
<li>button</li>
|
||||||
|
<li>below</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr class="margin-bottom-15">
|
||||||
|
|
||||||
|
<p class="margin-bottom-10">Lazily loaded image:</p>
|
||||||
|
|
||||||
|
<img src="https://picsum.photos/200" alt="" loading="lazy">
|
||||||
|
|
||||||
|
<hr class="margin-block-15">
|
||||||
|
|
||||||
|
<p class="margin-bottom-10">List of posts:</p>
|
||||||
|
|
||||||
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="5">
|
||||||
|
<li>
|
||||||
|
<a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
<li data-dynamic-relative-time="<unix timestamp>"></li>
|
||||||
|
<li>3,321 points</li>
|
||||||
|
<li>139 comments</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
<li data-dynamic-relative-time="<unix timestamp>"></li>
|
||||||
|
<li>3,321 points</li>
|
||||||
|
<li>139 comments</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
<li data-dynamic-relative-time="<unix timestamp>"></li>
|
||||||
|
<li>3,321 points</li>
|
||||||
|
<li>139 comments</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
All of that will result in the following:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Class names or features may change, once again, you are responsible for maintaining your own extensions.**
|
||||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 531 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
Before Width: | Height: | Size: 341 KiB After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 549 KiB |
|
After Width: | Height: | Size: 330 KiB |
@@ -11,8 +11,30 @@ theme:
|
|||||||
contrast-multiplier: 1.1
|
contrast-multiplier: 1.1
|
||||||
```
|
```
|
||||||
|
|
||||||
### Catppuccin
|
### Catppuccin Frappe
|
||||||

|

|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
background-color: 229 19 23
|
||||||
|
contrast-multiplier: 1.2
|
||||||
|
primary-color: 222 74 74
|
||||||
|
positive-color: 96 44 68
|
||||||
|
negative-color: 359 68 71
|
||||||
|
```
|
||||||
|
|
||||||
|
### Catppuccin Macchiato
|
||||||
|

|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
background-color: 232 23 18
|
||||||
|
contrast-multiplier: 1.2
|
||||||
|
primary-color: 220 83 75
|
||||||
|
positive-color: 105 48 72
|
||||||
|
negative-color: 351 74 73
|
||||||
|
```
|
||||||
|
|
||||||
|
### Catppuccin Mocha
|
||||||
|

|
||||||
```yaml
|
```yaml
|
||||||
theme:
|
theme:
|
||||||
background-color: 240 21 15
|
background-color: 240 21 15
|
||||||
@@ -31,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
|
||||||
@@ -42,6 +74,18 @@ theme:
|
|||||||
|
|
||||||
## Light
|
## Light
|
||||||
|
|
||||||
|
### Catppuccin Latte
|
||||||
|

|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
light: true
|
||||||
|
background-color: 220 23 95
|
||||||
|
contrast-multiplier: 1.0
|
||||||
|
primary-color: 220 91 54
|
||||||
|
positive-color: 109 58 40
|
||||||
|
negative-color: 347 87 44
|
||||||
|
```
|
||||||
|
|
||||||
### Peachy
|
### Peachy
|
||||||

|

|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
module github.com/glanceapp/glance
|
module github.com/glanceapp/glance
|
||||||
|
|
||||||
go 1.22.0
|
go 1.22.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/text v0.14.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
github.com/mmcdole/gofeed v1.3.0
|
github.com/mmcdole/gofeed v1.3.0
|
||||||
|
golang.org/x/text v0.16.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
github.com/PuerkitoBio/goquery v1.9.2 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
|
github.com/mmcdole/goxpp v1.1.1 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
golang.org/x/net v0.4.0 // indirect
|
golang.org/x/net v0.27.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,34 +1,66 @@
|
|||||||
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
|
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
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=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
||||||
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
||||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
|
github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
|
||||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
|
github.com/mmcdole/goxpp v1.1.1/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
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.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=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
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.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-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=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
package assets
|
package assets
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/md5"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed static
|
//go:embed static
|
||||||
@@ -13,3 +19,38 @@ var _templateFS embed.FS
|
|||||||
|
|
||||||
var PublicFS, _ = fs.Sub(_publicFS, "static")
|
var PublicFS, _ = fs.Sub(_publicFS, "static")
|
||||||
var TemplateFS, _ = fs.Sub(_templateFS, "templates")
|
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)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 7.8 KiB |
@@ -33,9 +33,11 @@
|
|||||||
--color-widget-background: hsl(var(--color-widget-background-hsl-values));
|
--color-widget-background: hsl(var(--color-widget-background-hsl-values));
|
||||||
--color-separator: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 4% * var(--cm))));
|
--color-separator: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 4% * var(--cm))));
|
||||||
--color-widget-content-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
|
--color-widget-content-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
|
||||||
|
--color-widget-background-highlight: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
|
||||||
|
|
||||||
--ths: var(--bgh), calc(var(--bgs) * var(--tsm));
|
--ths: var(--bgh), calc(var(--bgs) * var(--tsm));
|
||||||
--color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
|
--color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
|
||||||
|
--color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%));
|
||||||
--color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
|
--color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
|
||||||
--color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
|
--color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
|
||||||
|
|
||||||
@@ -56,6 +58,19 @@
|
|||||||
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 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page.content-ready > .page-content {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.page-column-full .size-title-dynamic {
|
.page-column-full .size-title-dynamic {
|
||||||
font-size: var(--font-size-h3);
|
font-size: var(--font-size-h3);
|
||||||
}
|
}
|
||||||
@@ -70,17 +85,19 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-truncate-3-lines {
|
.text-truncate-2-lines, .text-truncate-3-lines {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-truncate-3-lines { -webkit-line-clamp: 3; }
|
||||||
|
.text-truncate-2-lines { -webkit-line-clamp: 2; }
|
||||||
|
|
||||||
.visited-indicator:not(.text-truncate)::after,
|
.visited-indicator:not(.text-truncate)::after,
|
||||||
.visited-indicator.text-truncate::before,
|
.visited-indicator.text-truncate::before,
|
||||||
.bookmarks-link::after {
|
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
|
||||||
content: '↗';
|
content: '↗';
|
||||||
margin-left: 0.5em;
|
margin-left: 0.5em;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -101,12 +118,14 @@
|
|||||||
.list { --list-half-gap: 0rem; }
|
.list { --list-half-gap: 0rem; }
|
||||||
.list-gap-2 { --list-half-gap: 0.1rem; }
|
.list-gap-2 { --list-half-gap: 0.1rem; }
|
||||||
.list-gap-4 { --list-half-gap: 0.2rem; }
|
.list-gap-4 { --list-half-gap: 0.2rem; }
|
||||||
|
.list-gap-10 { --list-half-gap: 0.5rem; }
|
||||||
.list-gap-14 { --list-half-gap: 0.7rem; }
|
.list-gap-14 { --list-half-gap: 0.7rem; }
|
||||||
.list-gap-20 { --list-half-gap: 1rem; }
|
.list-gap-20 { --list-half-gap: 1rem; }
|
||||||
.list-gap-24 { --list-half-gap: 1.2rem; }
|
.list-gap-24 { --list-half-gap: 1.2rem; }
|
||||||
|
.list-gap-34 { --list-half-gap: 1.7rem; }
|
||||||
|
|
||||||
.list > *:not(:first-child) {
|
.list > *:not(:first-child) {
|
||||||
margin-top: calc(var(--list-half-gap) * 2 + 1px);
|
margin-top: calc(var(--list-half-gap) * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-with-separator > *:not(:first-child) {
|
.list-with-separator > *:not(:first-child) {
|
||||||
@@ -115,70 +134,135 @@
|
|||||||
padding-top: var(--list-half-gap);
|
padding-top: var(--list-half-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes listItemReveal {
|
.collapsible-container:not(.container-expanded) > .collapsible-item {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-item {
|
||||||
|
animation: collapsibleItemReveal .25s backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes collapsibleItemReveal {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
transform: translateY(10px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-collapsible-item {
|
.expand-toggle-button {
|
||||||
display: none;
|
font: inherit;
|
||||||
animation: listItemReveal 0.3s backwards;
|
border: 0;
|
||||||
animation-delay: var(--animation-delay);
|
cursor: pointer;
|
||||||
}
|
display: block;
|
||||||
|
width: 100%;
|
||||||
.list-collapsible-label {
|
text-align: left;
|
||||||
display: flex;
|
color: var(--color-text-base);
|
||||||
align-items: center;
|
text-transform: uppercase;
|
||||||
gap: 1rem;
|
font-size: var(--font-size-h4);
|
||||||
padding: var(--widget-content-vertical-padding) 0;
|
padding: var(--widget-content-vertical-padding) 0;
|
||||||
background: var(--color-widget-background);
|
background: var(--color-widget-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-collapsible-label:has(.list-collapsible-input:checked) {
|
.expand-toggle-button.container-expanded {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
/* -1px to hide 1px gap on chrome */
|
||||||
|
bottom: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-collapsible:has(+ .list-collapsible-label > .list-collapsible-input:checked) .list-collapsible-item {
|
.expand-toggle-button-icon {
|
||||||
display: block;
|
display: inline-block;
|
||||||
|
margin-left: 1rem;
|
||||||
|
position: relative;
|
||||||
|
top: -.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-collapsible-input {
|
.expand-toggle-button-icon::before {
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-collapsible-label::before, .list-collapsible-label::after {
|
|
||||||
cursor: pointer;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-collapsible-label::before {
|
|
||||||
content: 'SHOW MORE';
|
|
||||||
font-size: var(--font-size-h4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-collapsible-label:has(.list-collapsible-input:checked)::before {
|
|
||||||
content: 'SHOW LESS';
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-collapsible-label::after {
|
|
||||||
content: '';
|
content: '';
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
display: inline-block;
|
||||||
transition: transform 0.3s;
|
transition: transform 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-collapsible-label:has(.list-collapsible-input:checked)::after {
|
.expand-toggle-button.container-expanded .expand-toggle-button-icon::before {
|
||||||
transform: rotate(-90deg);
|
transform: rotate(-90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-content:has(.list-collapsible-label:last-child) {
|
.widget-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) {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cards-grid.collapsible-container + .expand-toggle-button {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments > * {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
font-size: var(--font-size-h6);
|
||||||
|
background-color: var(--color-separator);
|
||||||
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
|
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
|
||||||
color: var(--color-text-highlight);
|
color: var(--color-text-highlight);
|
||||||
@@ -204,19 +288,42 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--color-separator);
|
||||||
|
}
|
||||||
|
|
||||||
img, svg {
|
img, svg {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img[loading=lazy].loaded:not(.finished-transition) {
|
||||||
|
transition: opacity .4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
img[loading=lazy].cached:not(.finished-transition) {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img[loading=lazy]:not(.loaded, .cached) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scrollbar-color: var(--color-text-subdue) transparent;
|
scrollbar-color: var(--color-text-subdue) transparent;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
@@ -246,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,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;
|
||||||
@@ -307,12 +419,76 @@ body {
|
|||||||
border: 1px solid var(--color-negative);
|
border: 1px solid var(--color-negative);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.1rem 0.8rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: 2px solid var(--color-widget-background-highlight);
|
||||||
|
box-shadow: 0 2px 0 var(--color-widget-background-highlight);
|
||||||
|
user-select: none;
|
||||||
|
transition: transform .1s, box-shadow .1s;
|
||||||
|
font-size: var(--font-size-h5);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd:active {
|
||||||
|
transform: translateY(2px);
|
||||||
|
box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
.content-bounds {
|
.content-bounds {
|
||||||
max-width: 1600px;
|
max-width: 1600px;
|
||||||
|
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 {
|
||||||
|
gap: calc(var(--widget-content-vertical-padding) / 2);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--columns-per-row), 1fr);
|
||||||
|
margin: calc(0px - var(--widget-content-vertical-padding) / 2) calc(0px - var(--widget-content-horizontal-padding) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-columns > * {
|
||||||
|
padding: calc(var(--widget-content-vertical-padding) / 2) calc(var(--widget-content-horizontal-padding) / 1.5);
|
||||||
|
background-color: var(--color-background);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||||
|
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
|
||||||
|
.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
|
||||||
|
.dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
|
||||||
|
.dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; }
|
||||||
|
|
||||||
|
@container widget (max-width: 1500px) {
|
||||||
|
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||||
|
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
|
||||||
|
.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
|
||||||
|
.dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
|
||||||
|
}
|
||||||
|
@container widget (max-width: 1250px) {
|
||||||
|
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||||
|
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
|
||||||
|
.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
|
||||||
|
}
|
||||||
|
@container widget (max-width: 850px) {
|
||||||
|
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||||
|
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
|
||||||
|
}
|
||||||
|
@container widget (max-width: 550px) {
|
||||||
|
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
.cards-vertical {
|
.cards-vertical {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -321,30 +497,44 @@ body {
|
|||||||
--cards-per-row: 6.5;
|
--cards-per-row: 6.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cards-grid {
|
.cards-horizontal, .cards-vertical {
|
||||||
--cards-per-row: 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cards-horizontal, .cards-vertical, .cards-grid {
|
|
||||||
--cards-gap: calc(var(--widget-content-vertical-padding) * 0.7);
|
--cards-gap: calc(var(--widget-content-vertical-padding) * 0.7);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--cards-gap);
|
gap: var(--cards-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-horizontal .card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: calc(100% / var(--cards-per-row) - var(--cards-gap) * (var(--cards-per-row) - 1) / var(--cards-per-row));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-grid .card {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.cards-horizontal {
|
.cards-horizontal {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cards-grid {
|
.cards-grid {
|
||||||
flex-wrap: wrap;
|
--cards-per-row: 6;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cards-per-row), 1fr);
|
||||||
|
gap: calc(var(--widget-content-vertical-padding) * 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
@container widget (max-width: 1300px) { .cards-horizontal { --cards-per-row: 5.5; } }
|
@container widget (max-width: 1300px) { .cards-horizontal { --cards-per-row: 5.5; } }
|
||||||
@container widget (max-width: 1100px) { .cards-horizontal { --cards-per-row: 4.5; } }
|
@container widget (max-width: 1100px) { .cards-horizontal { --cards-per-row: 4.5; } }
|
||||||
@container widget (max-width: 850px) { .cards-horizontal { --cards-per-row: 3.5; } }
|
@container widget (max-width: 850px) { .cards-horizontal { --cards-per-row: 3.5; } }
|
||||||
@container widget (max-width: 750px) { .cards-horizontal { --cards-per-row: 3.5; } }
|
@container widget (max-width: 750px) { .cards-horizontal { --cards-per-row: 3.5; } }
|
||||||
@container widget (max-width: 650px) { .cards-horizontal { --cards-per-row: 2.2; } }
|
@container widget (max-width: 650px) { .cards-horizontal { --cards-per-row: 2.5; } }
|
||||||
|
@container widget (max-width: 450px) { .cards-horizontal { --cards-per-row: 2.3; } }
|
||||||
|
|
||||||
@container widget (max-width: 1300px) { .cards-grid { --cards-per-row: 5; } }
|
@container widget (max-width: 1300px) { .cards-grid { --cards-per-row: 5; } }
|
||||||
@container widget (max-width: 1100px) { .cards-grid { --cards-per-row: 4; } }
|
@container widget (max-width: 1100px) { .cards-grid { --cards-per-row: 4; } }
|
||||||
@@ -352,12 +542,7 @@ body {
|
|||||||
@container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } }
|
@container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } }
|
||||||
@container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } }
|
@container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } }
|
||||||
|
|
||||||
.card {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: calc(100% / var(--cards-per-row) - var(--cards-gap) * (var(--cards-per-row) - 1) / var(--cards-per-row));
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-error-header {
|
.widget-error-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -484,12 +669,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-navigation {
|
.mobile-navigation, .mobile-reachability-header {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,12 +697,16 @@ body {
|
|||||||
color: var(--color-text-highlight);
|
color: var(--color-text-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-chart {
|
.market-chart {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
width: 6.5rem;
|
width: 6.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-values {
|
.market-chart svg {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-values {
|
||||||
min-width: 8rem;
|
min-width: 8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,7 +742,7 @@ body {
|
|||||||
|
|
||||||
.video-thumbnail {
|
.video-thumbnail {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 8.9;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
}
|
}
|
||||||
@@ -567,6 +757,105 @@ body {
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
width: 2.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon-container {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* gives a wider hit area for the 3 people that will notice the animation : ) */
|
||||||
|
.search-icon-container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon-container:hover > .search-icon {
|
||||||
|
animation: searchIconHover 2.9s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes searchIconHover {
|
||||||
|
0%, 39% { translate: 0 0; }
|
||||||
|
20% { scale: 1.3; }
|
||||||
|
40% { scale: 1; }
|
||||||
|
50% { translate: -30% 30%; }
|
||||||
|
70% { translate: 30% -30%; }
|
||||||
|
90% { translate: -30% -30%; }
|
||||||
|
100% { translate: 0 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
transition: border-color .2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search:hover {
|
||||||
|
border-color: var(--color-text-subdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search:focus-within {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 6rem;
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: var(--color-text-base-muted);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bangs { display: none; }
|
||||||
|
|
||||||
|
.search-bang {
|
||||||
|
border-radius: calc(var(--border-radius) * 2);
|
||||||
|
background: var(--color-widget-background-highlight);
|
||||||
|
padding: 0.3rem 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: var(--font-size-h5);
|
||||||
|
animation: searchBangsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes searchBangsEntrance {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bang:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forum-post-list-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forum-post-list-thumbnail {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 6rem;
|
||||||
|
height: 4.1rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid var(--color-separator);
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forum-post-tags-container {
|
||||||
|
transform: translateY(-0.15rem);
|
||||||
|
}
|
||||||
|
|
||||||
.bookmarks-group {
|
.bookmarks-group {
|
||||||
--bookmarks-group-color: var(--color-primary);
|
--bookmarks-group-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
@@ -575,10 +864,31 @@ body {
|
|||||||
color: var(--bookmarks-group-color);
|
color: var(--bookmarks-group-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bookmarks-link::after {
|
.bookmarks-group .bookmarks-link::after {
|
||||||
color: var(--bookmarks-group-color);
|
color: var(--bookmarks-group-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bookmarks-icon-container {
|
||||||
|
margin-block: 0.1rem;
|
||||||
|
background-color: var(--color-widget-background-highlight);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-icon {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not(.light-scheme) .simple-icon {
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-day {
|
.calendar-day {
|
||||||
width: calc(100% / 7);
|
width: calc(100% / 7);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -599,7 +909,7 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: calc(100% / 12);
|
width: calc(100% / 12);
|
||||||
padding-top: 3px;
|
padding-top: 3px;
|
||||||
max-width: 3.5rem;
|
max-width: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-column-value, .weather-columns:hover .weather-column-value {
|
.weather-column-value, .weather-columns:hover .weather-column-value {
|
||||||
@@ -733,6 +1043,10 @@ body {
|
|||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clock-time span {
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
.monitor-site-icon {
|
.monitor-site-icon {
|
||||||
display: block;
|
display: block;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
@@ -751,6 +1065,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.monitor-site-status-icon {
|
.monitor-site-status-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
@@ -758,23 +1073,86 @@ body {
|
|||||||
|
|
||||||
.thumbnail {
|
.thumbnail {
|
||||||
filter: grayscale(0.2) contrast(0.9);
|
filter: grayscale(0.2) contrast(0.9);
|
||||||
transition: all 0.2s;
|
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
transition: filter 0.2s, opacity .2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumbnail-container:hover .thumbnail {
|
.thumbnail-container {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--color-separator);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-container > * {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-parent:hover .thumbnail {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
filter: none;
|
filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rss-card-image {
|
.rss-card-image {
|
||||||
height: 10rem;
|
height: var(--rss-thumbnail-height, 10rem);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rss-card-2 {
|
||||||
|
position: relative;
|
||||||
|
height: var(--rss-card-height, 27rem);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-card-2::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
0deg,
|
||||||
|
var(--color-widget-background),
|
||||||
|
hsla(var(--color-widget-background-hsl-values), 0.8) 6rem, transparent 14rem
|
||||||
|
);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-card-2-image {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
/* +1px is required to fix some weird graphical bug where the image overflows on the bottom in firefox */
|
||||||
|
border-radius: calc(var(--border-radius) + 1px);
|
||||||
|
opacity: 0.9;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-card-2-content {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
bottom: var(--widget-content-vertical-padding);
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-detailed-description {
|
||||||
|
max-width: 55rem;
|
||||||
|
color: var(--color-text-base-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-detailed-thumbnail {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-detailed-thumbnail > * {
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
|
height: 8.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
.twitch-category-thumbnail {
|
.twitch-category-thumbnail {
|
||||||
width: 5rem;
|
width: 5rem;
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,10 +1229,10 @@ body {
|
|||||||
|
|
||||||
.page-column {
|
.page-column {
|
||||||
display: none;
|
display: none;
|
||||||
animation: columnEntrance 0s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
animation: columnEntrance .0s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-element-transition .page-column {
|
.page-columns-transitioned .page-column {
|
||||||
animation-duration: .3s;
|
animation-duration: .3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,8 +1243,9 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -899,7 +1278,7 @@ body {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,9 +1341,48 @@ body {
|
|||||||
box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor;
|
box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-collapsible-label:has(.list-collapsible-input:checked) {
|
.expand-toggle-button.container-expanded {
|
||||||
bottom: var(--mobile-navigation-height);
|
bottom: var(--mobile-navigation-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cards-grid + .expand-toggle-button.container-expanded {
|
||||||
|
/* hides content that peeks through the rounded borders of the mobile navigation */
|
||||||
|
box-shadow: 0 var(--border-radius) 0 0 var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-rain::before {
|
||||||
|
background-size: 7px 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1190px) and (display-mode: standalone) {
|
||||||
|
:root {
|
||||||
|
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-collapsible-label:has(.list-collapsible-input:checked) {
|
||||||
|
bottom: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation {
|
||||||
|
transform: translateY(calc(100% - var(--mobile-navigation-height) - var(--safe-area-inset-bottom)));
|
||||||
|
padding-bottom: var(--safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-icons {
|
||||||
|
padding-bottom: var(--safe-area-inset-bottom);
|
||||||
|
transition: padding-bottom .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-icons:has(.mobile-navigation-page-links-input:checked) {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (display-mode: standalone) {
|
||||||
|
body {
|
||||||
|
padding-top: env(safe-area-inset-top, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 550px) {
|
@media (max-width: 550px) {
|
||||||
@@ -975,6 +1393,33 @@ body {
|
|||||||
--widget-content-horizontal-padding: 10px;
|
--widget-content-horizontal-padding: 10px;
|
||||||
--content-bounds-padding: 10px;
|
--content-bounds-padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||||
|
|
||||||
|
.row-reverse-on-mobile {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-on-mobile, .thumbnail-container:has(> .hide-on-mobile) {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-reachability-header {
|
||||||
|
display: block;
|
||||||
|
font-size: 3rem;
|
||||||
|
padding: 10vh 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-detailed-thumbnail > * {
|
||||||
|
height: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-detailed-description {
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.size-h1 { font-size: var(--font-size-h1); }
|
.size-h1 { font-size: var(--font-size-h1); }
|
||||||
@@ -996,11 +1441,16 @@ body {
|
|||||||
.text-left { text-align: left; }
|
.text-left { text-align: left; }
|
||||||
.text-right { text-align: right; }
|
.text-right { text-align: right; }
|
||||||
.text-center { text-align: center; }
|
.text-center { text-align: center; }
|
||||||
|
.text-elevate { margin-top: -0.2em; }
|
||||||
|
.text-compact { word-spacing: -0.18em; }
|
||||||
.rtl { direction: rtl; }
|
.rtl { direction: rtl; }
|
||||||
.shrink { flex-shrink: 1; }
|
.shrink { flex-shrink: 1; }
|
||||||
.shrink-0 { flex-shrink: 0; }
|
.shrink-0 { flex-shrink: 0; }
|
||||||
.min-width-0 { min-width: 0; }
|
.min-width-0 { min-width: 0; }
|
||||||
|
.max-width-100 { max-width: 100%; }
|
||||||
|
.height-100 { height: 100%; }
|
||||||
.block { display: block; }
|
.block { display: block; }
|
||||||
|
.inline-block { display: inline-block; }
|
||||||
.overflow-hidden { overflow: hidden; }
|
.overflow-hidden { overflow: hidden; }
|
||||||
.relative { position: relative; }
|
.relative { position: relative; }
|
||||||
.flex { display: flex; }
|
.flex { display: flex; }
|
||||||
@@ -1008,10 +1458,11 @@ body {
|
|||||||
.flex-nowrap { flex-wrap: nowrap; }
|
.flex-nowrap { flex-wrap: nowrap; }
|
||||||
.justify-between { justify-content: space-between; }
|
.justify-between { justify-content: space-between; }
|
||||||
.justify-stretch { justify-content: stretch; }
|
.justify-stretch { justify-content: stretch; }
|
||||||
|
.justify-evenly { justify-content: space-evenly; }
|
||||||
.justify-center { justify-content: center; }
|
.justify-center { justify-content: center; }
|
||||||
.justify-end { justify-content: end; }
|
.justify-end { justify-content: end; }
|
||||||
.uppercase { text-transform: uppercase; }
|
.uppercase { text-transform: uppercase; }
|
||||||
.flex-grow { flex-grow: 1; }
|
.grow { flex-grow: 1; }
|
||||||
.flex-column { flex-direction: column; }
|
.flex-column { flex-direction: column; }
|
||||||
.items-center { align-items: center; }
|
.items-center { align-items: center; }
|
||||||
.items-start { align-items: start; }
|
.items-start { align-items: start; }
|
||||||
@@ -1019,14 +1470,26 @@ body {
|
|||||||
.gap-7 { gap: 0.7rem; }
|
.gap-7 { gap: 0.7rem; }
|
||||||
.gap-10 { gap: 1rem; }
|
.gap-10 { gap: 1rem; }
|
||||||
.gap-15 { gap: 1.5rem; }
|
.gap-15 { gap: 1.5rem; }
|
||||||
|
.gap-20 { gap: 2rem; }
|
||||||
|
.gap-25 { gap: 2.5rem; }
|
||||||
|
.gap-35 { gap: 3.5rem; }
|
||||||
|
.gap-45 { gap: 4.5rem; }
|
||||||
|
.gap-55 { gap: 5.5rem; }
|
||||||
.margin-top-3 { margin-top: 0.3rem; }
|
.margin-top-3 { margin-top: 0.3rem; }
|
||||||
.margin-top-5 { margin-top: 0.5rem; }
|
.margin-top-5 { margin-top: 0.5rem; }
|
||||||
.margin-top-7 { margin-top: 0.7rem; }
|
.margin-top-7 { margin-top: 0.7rem; }
|
||||||
.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-5 { margin-block: 0.5rem; }
|
||||||
|
.margin-block-7 { margin-block: 0.7rem; }
|
||||||
|
.margin-block-10 { margin-block: 1rem; }
|
||||||
|
.margin-block-15 { margin-block: 1.5rem; }
|
||||||
.margin-bottom-3 { margin-bottom: 0.3rem; }
|
.margin-bottom-3 { margin-bottom: 0.3rem; }
|
||||||
.margin-bottom-5 { margin-bottom: 0.5rem; }
|
.margin-bottom-5 { margin-bottom: 0.5rem; }
|
||||||
.margin-bottom-7 { margin-bottom: 0.7rem; }
|
.margin-bottom-7 { margin-bottom: 0.7rem; }
|
||||||
.margin-bottom-10 { margin-bottom: 1rem; }
|
.margin-bottom-10 { margin-bottom: 1rem; }
|
||||||
.margin-bottom-15 { margin-bottom: 1.5rem; }
|
.margin-bottom-15 { margin-bottom: 1.5rem; }
|
||||||
.margin-bottom-auto { margin-bottom: auto; }
|
.margin-bottom-auto { margin-bottom: auto; }
|
||||||
|
.scale-half { transform: scale(0.5); }
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
async function fetchPageContents (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;
|
||||||
@@ -33,8 +33,13 @@ async function fetchPageContents (pageSlug) {
|
|||||||
function setupCarousels() {
|
function setupCarousels() {
|
||||||
const carouselElements = document.getElementsByClassName("carousel-container");
|
const carouselElements = document.getElementsByClassName("carousel-container");
|
||||||
|
|
||||||
|
if (carouselElements.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < carouselElements.length; i++) {
|
for (let i = 0; i < carouselElements.length; i++) {
|
||||||
const carousel = carouselElements[i];
|
const carousel = carouselElements[i];
|
||||||
|
carousel.classList.add("show-right-cutoff");
|
||||||
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
|
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
|
||||||
|
|
||||||
const determineSideCutoffs = () => {
|
const determineSideCutoffs = () => {
|
||||||
@@ -54,9 +59,9 @@ function setupCarousels() {
|
|||||||
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
|
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
|
||||||
|
|
||||||
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
|
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
|
||||||
document.addEventListener("resize", determineSideCutoffsRateLimited);
|
window.addEventListener("resize", determineSideCutoffsRateLimited);
|
||||||
|
|
||||||
determineSideCutoffs();
|
afterContentReady(determineSideCutoffs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +103,108 @@ function updateRelativeTimeForElements(elements)
|
|||||||
if (timestamp === undefined)
|
if (timestamp === undefined)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
element.innerText = relativeTimeSince(timestamp);
|
element.textContent = relativeTimeSince(timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSearchBoxes() {
|
||||||
|
const searchWidgets = document.getElementsByClassName("search");
|
||||||
|
|
||||||
|
if (searchWidgets.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < searchWidgets.length; i++) {
|
||||||
|
const widget = searchWidgets[i];
|
||||||
|
const defaultSearchUrl = widget.dataset.defaultSearchUrl;
|
||||||
|
const 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");
|
||||||
|
const bangsMap = {};
|
||||||
|
const kbdElement = widget.getElementsByTagName("kbd")[0];
|
||||||
|
let currentBang = null;
|
||||||
|
|
||||||
|
for (let j = 0; j < bangs.length; j++) {
|
||||||
|
const bang = bangs[j];
|
||||||
|
bangsMap[bang.dataset.shortcut] = bang;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (event.key == "Escape") {
|
||||||
|
inputElement.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key == "Enter") {
|
||||||
|
const input = inputElement.value.trim();
|
||||||
|
let query;
|
||||||
|
let searchUrlTemplate;
|
||||||
|
|
||||||
|
if (currentBang != null) {
|
||||||
|
query = input.slice(currentBang.dataset.shortcut.length + 1);
|
||||||
|
searchUrlTemplate = currentBang.dataset.url;
|
||||||
|
} else {
|
||||||
|
query = input;
|
||||||
|
searchUrlTemplate = defaultSearchUrl;
|
||||||
|
}
|
||||||
|
if (query.length == 0 && currentBang == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
|
||||||
|
|
||||||
|
if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
|
||||||
|
window.open(url, '_blank').focus();
|
||||||
|
} else {
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeCurrentBang = (bang) => {
|
||||||
|
currentBang = bang;
|
||||||
|
bangElement.textContent = bang != null ? bang.dataset.title : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInput = (event) => {
|
||||||
|
const value = event.target.value.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeCurrentBang(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
inputElement.addEventListener("focus", () => {
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
document.addEventListener("input", handleInput);
|
||||||
|
});
|
||||||
|
inputElement.addEventListener("blur", () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
document.removeEventListener("input", handleInput);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
|
||||||
|
if (event.key != "s") return;
|
||||||
|
|
||||||
|
inputElement.focus();
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
kbdElement.addEventListener("mousedown", () => {
|
||||||
|
requestAnimationFrame(() => inputElement.focus());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +213,8 @@ function setupDynamicRelativeTime() {
|
|||||||
const updateInterval = 60 * 1000;
|
const updateInterval = 60 * 1000;
|
||||||
let lastUpdateTime = Date.now();
|
let lastUpdateTime = Date.now();
|
||||||
|
|
||||||
|
updateRelativeTimeForElements(elements);
|
||||||
|
|
||||||
const updateElementsAndTimestamp = () => {
|
const updateElementsAndTimestamp = () => {
|
||||||
updateRelativeTimeForElements(elements);
|
updateRelativeTimeForElements(elements);
|
||||||
lastUpdateTime = Date.now();
|
lastUpdateTime = Date.now();
|
||||||
@@ -142,22 +250,368 @@ 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() {
|
||||||
|
const images = document.querySelectorAll("img[loading=lazy]");
|
||||||
|
|
||||||
|
if (images.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageFinishedTransition(image) {
|
||||||
|
image.classList.add("finished-transition");
|
||||||
|
}
|
||||||
|
|
||||||
|
afterContentReady(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
const image = images[i];
|
||||||
|
|
||||||
|
if (image.complete) {
|
||||||
|
image.classList.add("cached");
|
||||||
|
setTimeout(() => imageFinishedTransition(image), 1);
|
||||||
|
} else {
|
||||||
|
// TODO: also handle error event
|
||||||
|
image.addEventListener("load", () => {
|
||||||
|
image.classList.add("loaded");
|
||||||
|
setTimeout(() => imageFinishedTransition(image), 400);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachExpandToggleButton(collapsibleContainer) {
|
||||||
|
const showMoreText = "Show more";
|
||||||
|
const showLessText = "Show less";
|
||||||
|
|
||||||
|
let expanded = false;
|
||||||
|
const button = document.createElement("button");
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.classList.add("expand-toggle-button-icon");
|
||||||
|
const textNode = document.createTextNode(showMoreText);
|
||||||
|
button.classList.add("expand-toggle-button");
|
||||||
|
button.append(textNode, icon);
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
expanded = !expanded;
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
collapsibleContainer.classList.add("container-expanded");
|
||||||
|
button.classList.add("container-expanded");
|
||||||
|
textNode.nodeValue = showLessText;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const topBefore = button.getClientRects()[0].top;
|
||||||
|
|
||||||
|
collapsibleContainer.classList.remove("container-expanded");
|
||||||
|
button.classList.remove("container-expanded");
|
||||||
|
textNode.nodeValue = showMoreText;
|
||||||
|
|
||||||
|
const topAfter = button.getClientRects()[0].top;
|
||||||
|
|
||||||
|
if (topAfter > 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
window.scrollBy({
|
||||||
|
top: topAfter - topBefore,
|
||||||
|
behavior: "instant"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
collapsibleContainer.after(button);
|
||||||
|
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function setupCollapsibleLists() {
|
||||||
|
const collapsibleLists = document.querySelectorAll(".list.collapsible-container");
|
||||||
|
|
||||||
|
if (collapsibleLists.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < collapsibleLists.length; i++) {
|
||||||
|
const list = collapsibleLists[i];
|
||||||
|
|
||||||
|
if (list.dataset.collapseAfter === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapseAfter = parseInt(list.dataset.collapseAfter);
|
||||||
|
|
||||||
|
if (collapseAfter == -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.children.length <= collapseAfter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachExpandToggleButton(list);
|
||||||
|
|
||||||
|
for (let c = collapseAfter; c < list.children.length; c++) {
|
||||||
|
const child = list.children[c];
|
||||||
|
child.classList.add("collapsible-item");
|
||||||
|
child.style.animationDelay = ((c - collapseAfter) * 20).toString() + "ms";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCollapsibleGrids() {
|
||||||
|
const collapsibleGridElements = document.querySelectorAll(".cards-grid.collapsible-container");
|
||||||
|
|
||||||
|
if (collapsibleGridElements.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < collapsibleGridElements.length; i++) {
|
||||||
|
const gridElement = collapsibleGridElements[i];
|
||||||
|
|
||||||
|
if (gridElement.dataset.collapseAfterRows === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapseAfterRows = parseInt(gridElement.dataset.collapseAfterRows);
|
||||||
|
|
||||||
|
if (collapseAfterRows == -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCardsPerRow = () => {
|
||||||
|
return parseInt(getComputedStyle(gridElement).getPropertyValue('--cards-per-row'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const button = attachExpandToggleButton(gridElement);
|
||||||
|
|
||||||
|
let cardsPerRow = 2;
|
||||||
|
|
||||||
|
const resolveCollapsibleItems = () => {
|
||||||
|
const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
|
||||||
|
|
||||||
|
if (hideItemsAfterIndex >= gridElement.children.length) {
|
||||||
|
button.style.display = "none";
|
||||||
|
} else {
|
||||||
|
button.style.removeProperty("display");
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < gridElement.children.length; i++) {
|
||||||
|
const child = gridElement.children[i];
|
||||||
|
|
||||||
|
if (i >= hideItemsAfterIndex) {
|
||||||
|
child.classList.add("collapsible-item");
|
||||||
|
child.style.animationDelay = (row * 40).toString() + "ms";
|
||||||
|
|
||||||
|
if (i % cardsPerRow + 1 == cardsPerRow) {
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
child.classList.remove("collapsible-item");
|
||||||
|
child.style.removeProperty("animation-delay");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
afterContentReady(() => {
|
||||||
|
cardsPerRow = getCardsPerRow();
|
||||||
|
resolveCollapsibleItems();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
const newCardsPerRow = getCardsPerRow();
|
||||||
|
|
||||||
|
if (cardsPerRow == newCardsPerRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cardsPerRow = newCardsPerRow;
|
||||||
|
resolveCollapsibleItems();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentReadyCallbacks = [];
|
||||||
|
|
||||||
|
function afterContentReady(callback) {
|
||||||
|
contentReadyCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekDayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||||
|
|
||||||
|
function makeSettableTimeElement(element, hourFormat) {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
const hour = document.createElement('span');
|
||||||
|
const minute = document.createElement('span');
|
||||||
|
const amPm = document.createElement('span');
|
||||||
|
fragment.append(hour, document.createTextNode(':'), minute);
|
||||||
|
|
||||||
|
if (hourFormat == '12h') {
|
||||||
|
fragment.append(document.createTextNode(' '), amPm);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.append(fragment);
|
||||||
|
|
||||||
|
return (date) => {
|
||||||
|
const hours = date.getHours();
|
||||||
|
|
||||||
|
if (hourFormat == '12h') {
|
||||||
|
amPm.textContent = hours < 12 ? 'AM' : 'PM';
|
||||||
|
hour.textContent = hours % 12 || 12;
|
||||||
|
} else {
|
||||||
|
hour.textContent = hours < 10 ? '0' + hours : hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = date.getMinutes();
|
||||||
|
minute.textContent = minutes < 10 ? '0' + minutes : minutes;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function timeInZone(now, zone) {
|
||||||
|
let timeInZone;
|
||||||
|
|
||||||
|
try {
|
||||||
|
timeInZone = new Date(now.toLocaleString('en-US', { timeZone: zone }));
|
||||||
|
} catch (e) {
|
||||||
|
// TODO: indicate to the user that this is an invalid timezone
|
||||||
|
console.error(e);
|
||||||
|
timeInZone = now
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffInHours = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60 / 60);
|
||||||
|
|
||||||
|
return { time: timeInZone, diffInHours: diffInHours };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupClocks() {
|
||||||
|
const clocks = document.getElementsByClassName('clock');
|
||||||
|
|
||||||
|
if (clocks.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCallbacks = [];
|
||||||
|
|
||||||
|
for (var i = 0; i < clocks.length; i++) {
|
||||||
|
const clock = clocks[i];
|
||||||
|
const hourFormat = clock.dataset.hourFormat;
|
||||||
|
const localTimeContainer = clock.querySelector('[data-local-time]');
|
||||||
|
const localDateElement = localTimeContainer.querySelector('[data-date]');
|
||||||
|
const localWeekdayElement = localTimeContainer.querySelector('[data-weekday]');
|
||||||
|
const localYearElement = localTimeContainer.querySelector('[data-year]');
|
||||||
|
const timeZoneContainers = clock.querySelectorAll('[data-time-in-zone]');
|
||||||
|
|
||||||
|
const setLocalTime = makeSettableTimeElement(
|
||||||
|
localTimeContainer.querySelector('[data-time]'),
|
||||||
|
hourFormat
|
||||||
|
);
|
||||||
|
|
||||||
|
updateCallbacks.push((now) => {
|
||||||
|
setLocalTime(now);
|
||||||
|
localDateElement.textContent = now.getDate() + ' ' + monthNames[now.getMonth()];
|
||||||
|
localWeekdayElement.textContent = weekDayNames[now.getDay()];
|
||||||
|
localYearElement.textContent = now.getFullYear();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (var z = 0; z < timeZoneContainers.length; z++) {
|
||||||
|
const timeZoneContainer = timeZoneContainers[z];
|
||||||
|
const diffElement = timeZoneContainer.querySelector('[data-time-diff]');
|
||||||
|
|
||||||
|
const setZoneTime = makeSettableTimeElement(
|
||||||
|
timeZoneContainer.querySelector('[data-time]'),
|
||||||
|
hourFormat
|
||||||
|
);
|
||||||
|
|
||||||
|
updateCallbacks.push((now) => {
|
||||||
|
const { time, diffInHours } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
|
||||||
|
setZoneTime(time);
|
||||||
|
diffElement.textContent = (diffInHours <= 0 ? diffInHours : '+' + diffInHours) + 'h';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateClocks = () => {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (var i = 0; i < updateCallbacks.length; i++)
|
||||||
|
updateCallbacks[i](now);
|
||||||
|
|
||||||
|
setTimeout(updateClocks, (60 - now.getSeconds()) * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateClocks();
|
||||||
|
}
|
||||||
|
|
||||||
async function setupPage() {
|
async function setupPage() {
|
||||||
const pageElement = document.getElementById("page");
|
const pageElement = document.getElementById("page");
|
||||||
const pageContents = await fetchPageContents(pageData.slug);
|
const pageContentElement = document.getElementById("page-content");
|
||||||
|
const pageContent = await fetchPageContent(pageData);
|
||||||
|
|
||||||
pageElement.innerHTML = pageContents;
|
pageContentElement.innerHTML = pageContent;
|
||||||
|
|
||||||
setTimeout(() => {
|
try {
|
||||||
document.body.classList.add("animate-element-transition");
|
setupClocks()
|
||||||
}, 150);
|
setupCarousels();
|
||||||
|
setupSearchBoxes();
|
||||||
|
setupCollapsibleLists();
|
||||||
|
setupCollapsibleGrids();
|
||||||
|
setupGroups();
|
||||||
|
setupDynamicRelativeTime();
|
||||||
|
setupLazyImages();
|
||||||
|
} finally {
|
||||||
|
pageElement.classList.add("content-ready");
|
||||||
|
|
||||||
setupCarousels();
|
for (let i = 0; i < contentReadyCallbacks.length; i++) {
|
||||||
setupDynamicRelativeTime();
|
contentReadyCallbacks[i]();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.classList.add("page-columns-transitioned");
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
setupPage();
|
||||||
document.addEventListener("DOMContentLoaded", setupPage);
|
|
||||||
} else {
|
|
||||||
setupPage();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "Glance",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#151519",
|
||||||
|
"scope": "/",
|
||||||
|
"start_url": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "app-icon.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ var (
|
|||||||
PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
|
PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
|
||||||
PageContentTemplate = compileTemplate("content.html")
|
PageContentTemplate = compileTemplate("content.html")
|
||||||
CalendarTemplate = compileTemplate("calendar.html", "widget-base.html")
|
CalendarTemplate = compileTemplate("calendar.html", "widget-base.html")
|
||||||
|
ClockTemplate = compileTemplate("clock.html", "widget-base.html")
|
||||||
BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html")
|
BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html")
|
||||||
IFrameTemplate = compileTemplate("iframe.html", "widget-base.html")
|
IFrameTemplate = compileTemplate("iframe.html", "widget-base.html")
|
||||||
WeatherTemplate = compileTemplate("weather.html", "widget-base.html")
|
WeatherTemplate = compileTemplate("weather.html", "widget-base.html")
|
||||||
@@ -22,13 +23,21 @@ var (
|
|||||||
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
|
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
|
||||||
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
|
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
|
||||||
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
|
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
|
||||||
VideosTemplate = compileTemplate("videos.html", "widget-base.html")
|
ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html")
|
||||||
StocksTemplate = compileTemplate("stocks.html", "widget-base.html")
|
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
|
||||||
|
VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
|
||||||
|
MarketsTemplate = compileTemplate("markets.html", "widget-base.html")
|
||||||
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
|
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
|
||||||
RSSCardsTemplate = compileTemplate("rss-cards.html", "widget-base.html")
|
RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html")
|
||||||
|
RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
|
||||||
|
RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
|
||||||
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
|
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
|
||||||
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
|
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
|
||||||
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
|
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
|
||||||
|
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
|
||||||
|
SearchTemplate = compileTemplate("search.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{
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
|
{{ if ne .Style "dynamic-columns-experimental" }}
|
||||||
<ul class="list list-gap-24 list-with-separator">
|
<ul class="list list-gap-24 list-with-separator">
|
||||||
{{ range .Groups }}
|
{{ range .Groups }}
|
||||||
<li class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
|
<li class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
|
||||||
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
|
{{ template "group" . }}
|
||||||
<ul class="list list-gap-2">
|
|
||||||
{{ range .Links }}
|
|
||||||
<li><a href="{{ .URL }}" class="bookmarks-link color-highlight size-h4" target="_blank" rel="noreferrer">{{ .Title }}</a></li>
|
|
||||||
{{ end }}
|
|
||||||
</ul>
|
|
||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
|
{{ else }}
|
||||||
|
<div class="dynamic-columns">
|
||||||
|
{{ range .Groups }}
|
||||||
|
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
|
||||||
|
{{ template "group" . }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "group" }}
|
||||||
|
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
|
||||||
|
<ul class="list list-gap-2">
|
||||||
|
{{ range .Links }}
|
||||||
|
<li class="flex items-center gap-10">
|
||||||
|
{{ if ne "" .Icon }}
|
||||||
|
<div class="bookmarks-icon-container">
|
||||||
|
<img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
|
{{ range .ChangeDetections }}
|
||||||
|
<li>
|
||||||
|
<a class="size-h4 block text-truncate color-highlight" href="{{ .URL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
<li {{ dynamicRelativeTimeAttrs .LastChanged }}></li>
|
||||||
|
<li class="shrink min-width-0"><a class="visited-indicator" href="{{ .DiffURL }}" target="_blank" rel="noreferrer">diff:{{ .PreviousHash }}</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{{ else }}
|
||||||
|
<li>No watches configured</li>
|
||||||
|
{{ end}}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="clock" data-hour-format="{{ .HourFormat }}">
|
||||||
|
<div class="flex justify-between items-center" data-local-time>
|
||||||
|
<div>
|
||||||
|
<div class="color-highlight size-h1" data-date></div>
|
||||||
|
<div data-year></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="clock-time size-h1" data-time></div>
|
||||||
|
<div data-weekday></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ if gt (len .Timezones) 0 }}
|
||||||
|
<hr class="margin-block-10">
|
||||||
|
<ul class="list list-gap-4">
|
||||||
|
{{ range .Timezones }}
|
||||||
|
<li class="flex items-center gap-15" data-time-in-zone="{{ .Timezone }}">
|
||||||
|
<div class="grow min-width-0">
|
||||||
|
<div class="text-truncate">{{ if ne .Label "" }}{{ .Label }}{{ else }}{{ .Timezone }}{{ end }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-subdue" data-time-diff></div>
|
||||||
|
<div class="size-h4 clock-time shrink-0 text-right" data-time></div>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
{{ if .Page.ShowMobileHeader }}
|
||||||
|
<div class="mobile-reachability-header">{{ .Page.Title }}</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
<div class="page-columns">
|
<div class="page-columns">
|
||||||
{{ range .Page.Columns }}
|
{{ range .Page.Columns }}
|
||||||
<div class="page-column page-column-{{ .Size }}">
|
<div class="page-column page-column-{{ .Size }}">
|
||||||
|
|||||||
@@ -5,10 +5,18 @@
|
|||||||
<title>{{ block "document-title" . }}{{ end }}</title>
|
<title>{{ block "document-title" . }}{{ end }}</title>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<link rel="stylesheet" href="/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<script async src="/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script>
|
<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="{{ .App.AssetPath "app-icon.png" }}">
|
||||||
|
<link rel="icon" type="image/png" sizes="50x50" href="{{ .App.AssetPath "favicon.png" }}">
|
||||||
|
<link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
|
||||||
|
<link rel="icon" type="image/png" href="{{ .App.AssetPath "favicon.png" }}" />
|
||||||
|
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
|
||||||
|
<script type="module" src="{{ .App.AssetPath "main.js" }}"></script>
|
||||||
{{ block "document-head-after" . }}{{ end }}
|
{{ block "document-head-after" . }}{{ end }}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
{{ .Extension.Content }}
|
||||||
|
{{ end }}
|
||||||
@@ -1,22 +1,45 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-14 list-collapsible">
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range $i, $post := .Posts }}
|
{{ range .Posts }}
|
||||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
<li>
|
||||||
<a href="{{ $post.DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
|
||||||
<ul class="list-horizontal-text">
|
{{ if $.ShowThumbnails }}
|
||||||
<li title="{{ $post.TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs $post.TimePosted }}>{{ $post.TimePosted | relativeTime }}</li>
|
{{ if ne .ThumbnailUrl "" }}
|
||||||
<li>{{ $post.Score | formatNumber }} points</li>
|
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
|
||||||
<li>{{ $post.CommentCount | formatNumber }} comments</li>
|
{{ else if .HasTargetUrl }}
|
||||||
{{ if $post.HasTargetUrl }}
|
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||||
<li class="shrink min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ $post.TargetUrlDomain }}</a></li>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||||
|
</svg>
|
||||||
|
{{ else }}
|
||||||
|
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||||
|
</svg>
|
||||||
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
<div class="grow min-width-0">
|
||||||
|
<a href="{{ .DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
|
{{ if gt (len .Tags) 0 }}
|
||||||
|
<div class="inline-block forum-post-tags-container">
|
||||||
|
<ul class="attachments">
|
||||||
|
{{ range .Tags }}
|
||||||
|
<li>{{ . }}</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
|
<li>{{ .CommentCount | formatNumber }} comments</li>
|
||||||
|
{{ if .HasTargetUrl }}
|
||||||
|
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
{{ if gt (len .Posts) $.CollapseAfter }}
|
|
||||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
{{ if ne .Style "dynamic-columns-experimental" }}
|
||||||
|
<ul class="list list-gap-20 list-with-separator">
|
||||||
|
{{ range .Markets }}
|
||||||
|
<li class="flex items-center gap-15">
|
||||||
|
{{ template "market" . }}
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ else }}
|
||||||
|
<div class="dynamic-columns">
|
||||||
|
{{ range .Markets }}
|
||||||
|
<div class="flex items-center gap-15">
|
||||||
|
{{ template "market" . }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "market" }}
|
||||||
|
<div class="min-width-0">
|
||||||
|
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
|
||||||
|
<div class="text-truncate">{{ .Name }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
|
||||||
|
<svg class="market-chart shrink-0" viewBox="0 0 100 50">
|
||||||
|
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="market-values shrink-0">
|
||||||
|
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
|
||||||
|
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -1,39 +1,53 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
|
{{ if ne .Style "dynamic-columns-experimental" }}
|
||||||
<ul class="list list-gap-20 list-with-separator">
|
<ul class="list list-gap-20 list-with-separator">
|
||||||
{{ range .Sites }}
|
{{ range .Sites }}
|
||||||
<li class="monitor-site flex items-center gap-15">
|
<li class="monitor-site flex items-center gap-15">
|
||||||
{{ if .IconUrl }}
|
{{ template "site" . }}
|
||||||
<img class="monitor-site-icon" src="{{ .IconUrl }}" alt="" loading="lazy">
|
|
||||||
{{ end }}
|
|
||||||
<div>
|
|
||||||
<a class="size-h3 color-highlight" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
|
||||||
<ul class="list-horizontal-text">
|
|
||||||
{{ if not .Status.Error }}
|
|
||||||
<li>{{ .StatusText }}</li>
|
|
||||||
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
|
|
||||||
{{ else if .Status.TimedOut }}
|
|
||||||
<li class="color-negative">Timed Out</li>
|
|
||||||
{{ else }}
|
|
||||||
<li class="color-negative" title="{{ .Status.Error }}">ERROR</li>
|
|
||||||
{{ end }}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{{ if eq .StatusStyle "good" }}
|
|
||||||
<div class="monitor-site-status-icon">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
|
|
||||||
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{{ else }}
|
|
||||||
<div class="monitor-site-status-icon">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
|
|
||||||
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
|
{{ else }}
|
||||||
|
<ul class="dynamic-columns">
|
||||||
|
{{ range .Sites }}
|
||||||
|
<div class="flex items-center gap-15">
|
||||||
|
{{ template "site" . }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "site" }}
|
||||||
|
{{ if .IconUrl }}
|
||||||
|
<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy">
|
||||||
|
{{ end }}
|
||||||
|
<div>
|
||||||
|
<a class="size-h3 color-highlight" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
{{ if not .Status.Error }}
|
||||||
|
<li title="{{ .Status.Code }}">{{ .StatusText }}</li>
|
||||||
|
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
|
||||||
|
{{ else if .Status.TimedOut }}
|
||||||
|
<li class="color-negative">Timed Out</li>
|
||||||
|
{{ else }}
|
||||||
|
<li class="color-negative" title="{{ .Status.Error }}">ERROR</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ if eq .StatusStyle "ok" }}
|
||||||
|
<div class="monitor-site-status-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
|
||||||
|
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="monitor-site-status-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
|
||||||
|
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -1,64 +1,74 @@
|
|||||||
{{ 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" }}{{ template "page-style-overrides.gotmpl" . }}{{ end }}
|
|
||||||
|
{{ define "document-head-after" }}
|
||||||
|
{{ template "page-style-overrides.gotmpl" . }}
|
||||||
|
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
|
||||||
|
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
{{ 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 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-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>
|
||||||
<span class="size-h3">Glance</span> ({{ .App.Version }})
|
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-horizontal-text margin-top-5 size-h5 color-primary">
|
|
||||||
<li><a href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a></li>
|
<div class="mobile-navigation-offset"></div>
|
||||||
<li><a href="https://github.com/glanceapp/glance/discussions" target="_blank" rel="noreferrer">Submit feedback</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<img class="reddit-card-thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
<img class="reddit-card-thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div class="padding-widget flex flex-column flex-grow relative">
|
<div class="padding-widget flex flex-column grow relative">
|
||||||
{{ if ne "" .TargetUrl }}
|
{{ if ne "" .TargetUrl }}
|
||||||
<a class="color-highlight size-h5 text-truncate visited-indicator" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a>
|
<a class="color-highlight size-h5 text-truncate visited-indicator" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text margin-top-7">
|
<ul class="list-horizontal-text margin-top-7">
|
||||||
<li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li>{{ .Score | formatNumber }} points</li>
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text margin-top-7">
|
<ul class="list-horizontal-text margin-top-7">
|
||||||
<li title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li>{{ .Score | formatNumber }} points</li>
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-14 list-collapsible">
|
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range $i, $release := .Releases }}
|
{{ range $i, $release := .Releases }}
|
||||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
<li>
|
||||||
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li title="{{ $release.TimeReleased | formatTime }}" {{ dynamicRelativeTimeAttrs $release.TimeReleased }}>{{ $release.TimeReleased | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs $release.TimeReleased }}></li>
|
||||||
<li>{{ $release.Version }}</li>
|
<li>{{ $release.Version }}</li>
|
||||||
{{ if gt $release.Downvotes 3 }}
|
{{ if gt $release.Downvotes 3 }}
|
||||||
<li>{{ $release.Downvotes | formatNumber }} ⚠</li>
|
<li>{{ $release.Downvotes | formatNumber }} ⚠</li>
|
||||||
@@ -15,7 +15,4 @@
|
|||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
{{ if gt (len .Releases) $.CollapseAfter }}
|
|
||||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<a class="size-h4 color-highlight" href="https://github.com/{{ $.RepositoryDetails.Name }}" target="_blank" rel="noreferrer">{{ .RepositoryDetails.Name }}</a>
|
||||||
|
<ul class="list-horizontal-text">
|
||||||
|
<li>{{ .RepositoryDetails.Stars | formatNumber }} stars</li>
|
||||||
|
<li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{{ if gt (len .RepositoryDetails.PullRequests) 0 }}
|
||||||
|
<hr class="margin-block-10">
|
||||||
|
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)</a>
|
||||||
|
<div class="flex gap-7 size-h5 margin-top-3">
|
||||||
|
<ul class="list list-gap-2">
|
||||||
|
{{ range .RepositoryDetails.PullRequests }}
|
||||||
|
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
<ul class="list list-gap-2 min-width-0">
|
||||||
|
{{ range .RepositoryDetails.PullRequests }}
|
||||||
|
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if gt (len .RepositoryDetails.Issues) 0 }}
|
||||||
|
<hr class="margin-block-10">
|
||||||
|
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .RepositoryDetails.OpenIssues | formatNumber }} total)</a>
|
||||||
|
<div class="flex gap-7 size-h5 margin-top-3">
|
||||||
|
<ul class="list list-gap-2">
|
||||||
|
{{ range .RepositoryDetails.Issues }}
|
||||||
|
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
<ul class="list list-gap-2 min-width-0">
|
||||||
|
{{ range .RepositoryDetails.Issues }}
|
||||||
|
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<ul class="list list-gap-24 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
|
{{ range .Items }}
|
||||||
|
<li class="flex gap-15 items-start row-reverse-on-mobile thumbnail-parent">
|
||||||
|
<div class="thumbnail-container rss-detailed-thumbnail">
|
||||||
|
{{ if ne "" .ImageURL }}
|
||||||
|
<img class="thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||||
|
{{ else }}
|
||||||
|
<svg class="scale-half hide-on-mobile" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||||
|
</svg>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="grow min-width-0">
|
||||||
|
<a class="size-h3 color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text flex-nowrap">
|
||||||
|
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
|
<li class="min-width-0">
|
||||||
|
<a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{{ if ne "" .Description }}
|
||||||
|
<p class="rss-detailed-description text-truncate-2-lines margin-top-10">{{ .Description }}</p>
|
||||||
|
{{ end }}
|
||||||
|
{{ if gt (len .Categories) 0 }}
|
||||||
|
<ul class="attachments margin-top-10">
|
||||||
|
{{ range .Categories }}
|
||||||
|
<li>{{ . }}</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="carousel-container">
|
||||||
|
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
|
||||||
|
{{ range .Items }}
|
||||||
|
<div class="card rss-card-2 widget-content-frame thumbnail-parent">
|
||||||
|
{{ if ne "" .ImageURL }}
|
||||||
|
<img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||||
|
{{ else }}
|
||||||
|
<svg class="rss-card-2-image" style="transform: scale(0.35) translateY(-25%)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||||
|
</svg>
|
||||||
|
{{ end }}
|
||||||
|
<div class="rss-card-2-content padding-inline-widget">
|
||||||
|
<a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text flex-nowrap margin-top-5">
|
||||||
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
|
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
<div class="cards-horizontal carousel-items-container">
|
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
|
||||||
{{ range .Items }}
|
{{ range .Items }}
|
||||||
<div class="card widget-content-frame thumbnail-container">
|
<div class="card widget-content-frame thumbnail-parent">
|
||||||
{{ if ne "" .ImageURL }}
|
{{ if ne "" .ImageURL }}
|
||||||
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
|
||||||
{{ else }}
|
{{ else }}
|
||||||
@@ -14,11 +14,11 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div class="margin-bottom-widget padding-inline-widget flex flex-column flex-grow">
|
<div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
|
||||||
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
||||||
<li class="shrink-0" title="{{ .PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs .PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
<li class="shrink min-width-0 text-truncate">{{ .ChannelName }}</li>
|
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,20 +1,17 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-14 list-collapsible">
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range $i, $item := .Items }}
|
{{ range .Items }}
|
||||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
<li>
|
||||||
<a class="size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a class="size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text flex-nowrap">
|
||||||
<li title="{{ $item.PublishedAt | formatTime }}" {{ dynamicRelativeTimeAttrs $item.PublishedAt }}>{{ .PublishedAt | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
|
||||||
{{ if gt (len $.FeedRequests) 1 }}
|
<li class="min-width-0">
|
||||||
<li><a href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a></li>
|
<a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
|
||||||
{{ end }}
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
{{ if gt (len .Items) $.CollapseAfter }}
|
|
||||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}" data-new-tab="{{ .NewTab }}">
|
||||||
|
<div class="search-bangs">
|
||||||
|
{{ range .Bangs }}
|
||||||
|
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-icon-container">
|
||||||
|
<svg class="search-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off"{{ if .Autofocus }} autofocus{{ end }}>
|
||||||
|
|
||||||
|
<div class="search-bang"></div>
|
||||||
|
<kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{{ template "widget-base.html" . }}
|
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
|
||||||
<ul class="list list-gap-20 list-with-separator">
|
|
||||||
{{ range .Stocks }}
|
|
||||||
<li class="flex items-center gap-15">
|
|
||||||
<div class="shrink min-width-0">
|
|
||||||
<div class="color-highlight size-h3 text-truncate">{{ .Symbol }}</div>
|
|
||||||
<div class="text-truncate">{{ .Name }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<svg class="stock-chart shrink-0" viewBox="0 0 100 50">
|
|
||||||
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="stock-values shrink-0">
|
|
||||||
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
|
|
||||||
<div class="text-right">${{ .Price | formatPrice }}</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{{ end }}
|
|
||||||
</ul>
|
|
||||||
{{ end }}
|
|
||||||
@@ -1,27 +1,31 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-14 list-collapsible">
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range $i, $channel := .Channels }}
|
{{ range .Channels }}
|
||||||
<li {{ if shouldCollapse $i $.CollapseAfter }}class="list-collapsible-item" style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
<li>
|
||||||
<div class="{{ if $channel.IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-container">
|
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
|
||||||
<div class="twitch-channel-avatar-container">
|
<div class="twitch-channel-avatar-container">
|
||||||
{{ if $channel.Exists }}
|
{{ if .Exists }}
|
||||||
<img class="twitch-channel-avatar thumbnail" src="{{ $channel.AvatarUrl }}" alt="" loading="lazy">
|
<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" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink min-width-0">
|
<div class="min-width-0">
|
||||||
<a href="https://twitch.tv/{{ $channel.Login }}" class="size-h3{{ if $channel.IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ $channel.Name }}</a>
|
<a href="https://twitch.tv/{{ .Login }}" class="size-h3{{ if .IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
{{ if $channel.Exists }}
|
{{ if .Exists }}
|
||||||
{{ if $channel.IsLive }}
|
{{ if .IsLive }}
|
||||||
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ $channel.CategorySlug }}" target="_blank" rel="noreferrer">{{ $channel.Category }}</a>
|
{{ 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 title="{{ $channel.LiveSince | formatTime }}" {{ dynamicRelativeTimeAttrs $channel.LiveSince }}>{{ $channel.LiveSince | relativeTime }}</li>
|
<li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
|
||||||
<li>{{ $channel.ViewersCount | formatViewerCount }} viewers</li>
|
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
|
||||||
</ul>
|
</ul>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<div>Offline</div>
|
<div>Offline</div>
|
||||||
@@ -34,7 +38,4 @@
|
|||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
{{ if gt (len .Channels) $.CollapseAfter }}
|
|
||||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<ul class="list list-gap-14 list-collapsible">
|
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||||
{{ range $i, $category := .Categories }}
|
{{ range .Categories }}
|
||||||
{{ $shouldCollapseItem := shouldCollapse $i $.CollapseAfter }}
|
<li class="twitch-category thumbnail-parent">
|
||||||
<li class="twitch-category thumbnail-container{{ if $shouldCollapseItem }} list-collapsible-item{{ end }}" {{ if $shouldCollapseItem }}style="--animation-delay: {{ itemAnimationDelay $i $.CollapseAfter }};"{{ end }}>
|
<div class="flex gap-10 items-start">
|
||||||
<div class="flex gap-10 items-center">
|
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ .AvatarUrl }}" alt="">
|
||||||
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ $category.AvatarUrl }}" alt="">
|
<div class="min-width-0">
|
||||||
<div class="shrink min-width-0">
|
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ $category.Slug }}" target="_blank" rel="noreferrer">{{ $category.Name }}</a>
|
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li>{{ $category.ViewersCount | formatViewerCount }} viewers</li>
|
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
|
||||||
{{ if $category.IsNew }}
|
{{ if .IsNew }}
|
||||||
<li class="color-primary">NEW</li>
|
<li class="color-primary">NEW</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="list-horizontal-text flex-nowrap">
|
<ul class="list-horizontal-text flex-nowrap">
|
||||||
{{ range $i, $tag := $category.Tags }}
|
{{ range $i, $tag := .Tags }}
|
||||||
{{ if eq $i 0 }}
|
{{ if eq $i 0 }}
|
||||||
<li class="shrink-0">{{ $tag.Name }}</li>
|
<li class="shrink-0">{{ $tag.Name }}</li>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<li class="text-truncate shrink min-width-0">{{ $tag.Name }}</li>
|
<li class="text-truncate min-width-0">{{ $tag.Name }}</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -29,7 +28,4 @@
|
|||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
{{ if gt (len .Categories) $.CollapseAfter }}
|
|
||||||
<label class="list-collapsible-label"><input type="checkbox" autocomplete="off" class="list-collapsible-input"></label>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{{ define "video-card-contents" }}
|
||||||
|
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
||||||
|
<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
|
||||||
|
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
|
||||||
|
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
||||||
|
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
|
<li class="min-width-0">
|
||||||
|
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
|
{{ define "widget-content" }}
|
||||||
|
<div class="cards-grid collapsible-container" data-collapse-after-rows="{{ .CollapseAfterRows }}">
|
||||||
|
{{ range .Videos }}
|
||||||
|
<div class="card widget-content-frame thumbnail-parent">
|
||||||
|
{{ template "video-card-contents" . }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -4,19 +4,10 @@
|
|||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
<div class="videos cards-horizontal carousel-items-container">
|
<div class="cards-horizontal carousel-items-container">
|
||||||
{{ range .Videos }}
|
{{ range .Videos }}
|
||||||
<div class="card widget-content-frame thumbnail-container">
|
<div class="card widget-content-frame thumbnail-parent">
|
||||||
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
|
{{ template "video-card-contents" . }}
|
||||||
<div class="margin-top-10 margin-bottom-widget flex flex-column flex-grow padding-inline-widget">
|
|
||||||
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
|
|
||||||
<ul class="list-horizontal-text flex-nowrap margin-top-7">
|
|
||||||
<li class="shrink-0" title="{{ .TimePosted | formatTime }}" {{ dynamicRelativeTimeAttrs .TimePosted }}>{{ .TimePosted | relativeTime }}</li>
|
|
||||||
<li class="shrink min-width-0">
|
|
||||||
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<div class="size-h2 color-highlight text-center">{{ .Weather.WeatherCodeAsString }}</div>
|
<div class="size-h2 color-highlight text-center">{{ .Weather.WeatherCodeAsString }}</div>
|
||||||
<div class="size-h4 text-center">Feels like {{ .Weather.ApparentTemperature }}°C</div>
|
<div class="size-h4 text-center">Feels like {{ .Weather.ApparentTemperature }}°{{ if eq .Units "metric" }}C{{ else }}F{{ end }}</div>
|
||||||
|
|
||||||
<div class="weather-columns flex margin-top-15 justify-center">
|
<div class="weather-columns flex margin-top-15 justify-center">
|
||||||
{{ range $i, $column := .Weather.Columns }}
|
{{ range $i, $column := .Weather.Columns }}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
{{ if not .HideLocation }}
|
{{ if not .HideLocation }}
|
||||||
<div class="flex items-center justify-center margin-top-15 gap-7 size-h5">
|
<div class="flex items-center justify-center margin-top-15 gap-7 size-h5">
|
||||||
<div class="location-icon"></div>
|
<div class="location-icon"></div>
|
||||||
<div class="text-truncate">{{ .Place.Name }}, {{ .Place.Country }}</div>
|
<div class="text-truncate">{{ .Place.Name }},{{ if .ShowAreaName }} {{ .Place.Area }},{{ end }} {{ .Place.Country }}</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<div class="widget widget-type-{{ .GetType }}">
|
<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
|
||||||
|
{{ if not .HideHeader}}
|
||||||
<div class="widget-header">
|
<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 }}
|
{{ if and .Error .ContentAvailable }}
|
||||||
<div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
|
<div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
|
||||||
{{ else if .Notice }}
|
{{ else if .Notice }}
|
||||||
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
|
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="widget-content {{ if .ContentAvailable }}{{ block "widget-content-classes" . }}{{ end }}{{ 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 }}
|
||||||
{{ else }}
|
{{ else }}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChangeDetectionWatch struct {
|
||||||
|
Title string
|
||||||
|
URL string
|
||||||
|
LastChanged time.Time
|
||||||
|
DiffURL string
|
||||||
|
PreviousHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChangeDetectionWatches []ChangeDetectionWatch
|
||||||
|
|
||||||
|
func (r ChangeDetectionWatches) SortByNewest() ChangeDetectionWatches {
|
||||||
|
sort.Slice(r, func(i, j int) bool {
|
||||||
|
return r[i].LastChanged.After(r[j].LastChanged)
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type changeDetectionResponseJson struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
LastChanged int64 `json:"last_changed"`
|
||||||
|
DateCreated int64 `json:"date_created"`
|
||||||
|
PreviousHash string `json:"previous_md5"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) {
|
||||||
|
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil)
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
request.Header.Add("x-api-key", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultClient, request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uuids := make([]string, 0, len(uuidsMap))
|
||||||
|
|
||||||
|
for uuid := range uuidsMap {
|
||||||
|
uuids = append(uuids, uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) {
|
||||||
|
watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs))
|
||||||
|
|
||||||
|
if len(requestedWatchIDs) == 0 {
|
||||||
|
return watches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := make([]*http.Request, len(requestedWatchIDs))
|
||||||
|
|
||||||
|
for i, repository := range requestedWatchIDs {
|
||||||
|
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch/%s", instanceURL, repository), nil)
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
request.Header.Add("x-api-key", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
requests[i] = request
|
||||||
|
}
|
||||||
|
|
||||||
|
task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultClient)
|
||||||
|
job := newJob(task, requests).withWorkers(15)
|
||||||
|
responses, errs, err := workerPoolDo(job)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var failed int
|
||||||
|
|
||||||
|
for i := range responses {
|
||||||
|
if errs[i] != nil {
|
||||||
|
failed++
|
||||||
|
slog.Error("Failed to fetch or parse change detection watch", "error", errs[i], "url", requests[i].URL)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
watchJson := responses[i]
|
||||||
|
|
||||||
|
watch := ChangeDetectionWatch{
|
||||||
|
URL: watchJson.URL,
|
||||||
|
DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1),
|
||||||
|
}
|
||||||
|
|
||||||
|
if watchJson.LastChanged == 0 {
|
||||||
|
watch.LastChanged = time.Unix(watchJson.DateCreated, 0)
|
||||||
|
} else {
|
||||||
|
watch.LastChanged = time.Unix(watchJson.LastChanged, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if watchJson.Title != "" {
|
||||||
|
watch.Title = watchJson.Title
|
||||||
|
} else {
|
||||||
|
watch.Title = strings.TrimPrefix(strings.Trim(stripURLScheme(watchJson.URL), "/"), "www.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if watchJson.PreviousHash != "" {
|
||||||
|
var hashLength = 8
|
||||||
|
|
||||||
|
if len(watchJson.PreviousHash) < hashLength {
|
||||||
|
hashLength = len(watchJson.PreviousHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch.PreviousHash = watchJson.PreviousHash[0:hashLength]
|
||||||
|
}
|
||||||
|
|
||||||
|
watches = append(watches, watch)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(watches) == 0 {
|
||||||
|
return nil, ErrNoContent
|
||||||
|
}
|
||||||
|
|
||||||
|
watches.SortByNewest()
|
||||||
|
|
||||||
|
if failed > 0 {
|
||||||
|
return watches, fmt.Errorf("%w: could not get %d watches", ErrPartialContent, failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return watches, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExtensionType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ExtensionContentHTML ExtensionType = iota
|
||||||
|
ExtensionContentUnknown = iota
|
||||||
|
)
|
||||||
|
|
||||||
|
var ExtensionStringToType = map[string]ExtensionType{
|
||||||
|
"html": ExtensionContentHTML,
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ExtensionHeaderTitle = "Widget-Title"
|
||||||
|
ExtensionHeaderContentType = "Widget-Content-Type"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExtensionRequestOptions struct {
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
Parameters map[string]string `yaml:"parameters"`
|
||||||
|
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Extension struct {
|
||||||
|
Title string
|
||||||
|
Content template.HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertExtensionContent(options ExtensionRequestOptions, content []byte, contentType ExtensionType) template.HTML {
|
||||||
|
switch contentType {
|
||||||
|
case ExtensionContentHTML:
|
||||||
|
if options.AllowHtml {
|
||||||
|
return template.HTML(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
return template.HTML(html.EscapeString(string(content)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchExtension(options ExtensionRequestOptions) (Extension, error) {
|
||||||
|
request, _ := http.NewRequest("GET", options.URL, nil)
|
||||||
|
|
||||||
|
query := url.Values{}
|
||||||
|
|
||||||
|
for key, value := range options.Parameters {
|
||||||
|
query.Set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.URL.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
response, err := http.DefaultClient.Do(request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed fetching extension", "error", err, "url", options.URL)
|
||||||
|
return Extension{}, fmt.Errorf("%w: request failed: %w", ErrNoContent, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(response.Body)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed reading response body of extension", "error", err, "url", options.URL)
|
||||||
|
return Extension{}, fmt.Errorf("%w: could not read body: %w", ErrNoContent, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension := Extension{}
|
||||||
|
|
||||||
|
if response.Header.Get(ExtensionHeaderTitle) == "" {
|
||||||
|
extension.Title = "Extension"
|
||||||
|
} else {
|
||||||
|
extension.Title = response.Header.Get(ExtensionHeaderTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)]
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
contentType = ExtensionContentUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
extension.Content = convertExtensionContent(options, body, contentType)
|
||||||
|
|
||||||
|
return extension, nil
|
||||||
|
}
|
||||||
@@ -4,15 +4,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type githubReleaseResponseJson struct {
|
type githubReleaseLatestResponseJson struct {
|
||||||
TagName string `json:"tag_name"`
|
TagName string `json:"tag_name"`
|
||||||
PublishedAt string `json:"published_at"`
|
PublishedAt string `json:"published_at"`
|
||||||
HtmlUrl string `json:"html_url"`
|
HtmlUrl string `json:"html_url"`
|
||||||
Draft bool `json:"draft"`
|
|
||||||
PreRelease bool `json:"prerelease"`
|
|
||||||
Reactions struct {
|
Reactions struct {
|
||||||
Downvotes int `json:"-1"`
|
Downvotes int `json:"-1"`
|
||||||
} `json:"reactions"`
|
} `json:"reactions"`
|
||||||
@@ -38,7 +37,7 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele
|
|||||||
requests := make([]*http.Request, len(repositories))
|
requests := make([]*http.Request, len(repositories))
|
||||||
|
|
||||||
for i, repository := range 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 != "" {
|
if token != "" {
|
||||||
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||||
@@ -47,7 +46,7 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele
|
|||||||
requests[i] = request
|
requests[i] = request
|
||||||
}
|
}
|
||||||
|
|
||||||
task := decodeJsonFromRequestTask[[]githubReleaseResponseJson](defaultClient)
|
task := decodeJsonFromRequestTask[githubReleaseLatestResponseJson](defaultClient)
|
||||||
job := newJob(task, requests).withWorkers(15)
|
job := newJob(task, requests).withWorkers(15)
|
||||||
responses, errs, err := workerPoolDo(job)
|
responses, errs, err := workerPoolDo(job)
|
||||||
|
|
||||||
@@ -64,24 +63,7 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
releases := responses[i]
|
liveRelease := &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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if liveRelease == nil {
|
if liveRelease == nil {
|
||||||
slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
|
slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
|
||||||
@@ -115,3 +97,133 @@ func FetchLatestReleasesFromGithub(repositories []string, token string) (AppRele
|
|||||||
|
|
||||||
return appReleases, nil
|
return appReleases, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GithubTicket struct {
|
||||||
|
Number int
|
||||||
|
CreatedAt time.Time
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RepositoryDetails struct {
|
||||||
|
Name string
|
||||||
|
Stars int
|
||||||
|
Forks int
|
||||||
|
OpenPullRequests int
|
||||||
|
PullRequests []GithubTicket
|
||||||
|
OpenIssues int
|
||||||
|
Issues []GithubTicket
|
||||||
|
}
|
||||||
|
|
||||||
|
type githubRepositoryDetailsResponseJson struct {
|
||||||
|
Name string `json:"full_name"`
|
||||||
|
Stars int `json:"stargazers_count"`
|
||||||
|
Forks int `json:"forks_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type githubTicketResponseJson struct {
|
||||||
|
Count int `json:"total_count"`
|
||||||
|
Tickets []struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int) (RepositoryDetails, error) {
|
||||||
|
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil)
|
||||||
|
issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil)
|
||||||
|
|
||||||
|
if token != "" {
|
||||||
|
token = fmt.Sprintf("Bearer %s", token)
|
||||||
|
repositoryRequest.Header.Add("Authorization", token)
|
||||||
|
PRsRequest.Header.Add("Authorization", token)
|
||||||
|
issuesRequest.Header.Add("Authorization", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailsResponse githubRepositoryDetailsResponseJson
|
||||||
|
var detailsErr error
|
||||||
|
var PRsResponse githubTicketResponseJson
|
||||||
|
var PRsErr error
|
||||||
|
var issuesResponse githubTicketResponseJson
|
||||||
|
var issuesErr error
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go (func() {
|
||||||
|
defer wg.Done()
|
||||||
|
detailsResponse, detailsErr = decodeJsonFromRequest[githubRepositoryDetailsResponseJson](defaultClient, repositoryRequest)
|
||||||
|
})()
|
||||||
|
|
||||||
|
if maxPRs > 0 {
|
||||||
|
wg.Add(1)
|
||||||
|
go (func() {
|
||||||
|
defer wg.Done()
|
||||||
|
PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, PRsRequest)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxIssues > 0 {
|
||||||
|
wg.Add(1)
|
||||||
|
go (func() {
|
||||||
|
defer wg.Done()
|
||||||
|
issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, issuesRequest)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if detailsErr != nil {
|
||||||
|
return RepositoryDetails{}, fmt.Errorf("%w: could not get repository details: %s", ErrNoContent, detailsErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
details := RepositoryDetails{
|
||||||
|
Name: detailsResponse.Name,
|
||||||
|
Stars: detailsResponse.Stars,
|
||||||
|
Forks: detailsResponse.Forks,
|
||||||
|
PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
|
||||||
|
Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = nil
|
||||||
|
|
||||||
|
if maxPRs > 0 {
|
||||||
|
if PRsErr != nil {
|
||||||
|
err = fmt.Errorf("%w: could not get PRs: %s", ErrPartialContent, PRsErr)
|
||||||
|
} else {
|
||||||
|
details.OpenPullRequests = PRsResponse.Count
|
||||||
|
|
||||||
|
for i := range PRsResponse.Tickets {
|
||||||
|
details.PullRequests = append(details.PullRequests, GithubTicket{
|
||||||
|
Number: PRsResponse.Tickets[i].Number,
|
||||||
|
CreatedAt: parseGithubTime(PRsResponse.Tickets[i].CreatedAt),
|
||||||
|
Title: PRsResponse.Tickets[i].Title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxIssues > 0 {
|
||||||
|
if issuesErr != nil {
|
||||||
|
// TODO: fix, overwriting the previous error
|
||||||
|
err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, issuesErr)
|
||||||
|
} else {
|
||||||
|
details.OpenIssues = issuesResponse.Count
|
||||||
|
|
||||||
|
for i := range issuesResponse.Tickets {
|
||||||
|
details.Issues = append(details.Issues, GithubTicket{
|
||||||
|
Number: issuesResponse.Tickets[i].Number,
|
||||||
|
CreatedAt: parseGithubTime(issuesResponse.Tickets[i].CreatedAt),
|
||||||
|
Title: issuesResponse.Tickets[i].Title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return details, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,8 +18,8 @@ type hackerNewsPostResponseJson struct {
|
|||||||
TimePosted int64 `json:"time"`
|
TimePosted int64 `json:"time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHackerNewsTopPostIds() ([]int, error) {
|
func getHackerNewsPostIds(sort string) ([]int, error) {
|
||||||
request, _ := http.NewRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", nil)
|
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
|
||||||
response, err := decodeJsonFromRequest[[]int](defaultClient, request)
|
response, err := decodeJsonFromRequest[[]int](defaultClient, request)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -28,7 +29,7 @@ func getHackerNewsTopPostIds() ([]int, error) {
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHackerNewsPostsFromIds(postIds []int) (ForumPosts, error) {
|
func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (ForumPosts, error) {
|
||||||
requests := make([]*http.Request, len(postIds))
|
requests := make([]*http.Request, len(postIds))
|
||||||
|
|
||||||
for i, id := range postIds {
|
for i, id := range postIds {
|
||||||
@@ -52,9 +53,17 @@ func getHackerNewsPostsFromIds(postIds []int) (ForumPosts, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var commentsUrl string
|
||||||
|
|
||||||
|
if commentsUrlTemplate == "" {
|
||||||
|
commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
|
||||||
|
} else {
|
||||||
|
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
|
||||||
|
}
|
||||||
|
|
||||||
posts = append(posts, ForumPost{
|
posts = append(posts, ForumPost{
|
||||||
Title: results[i].Title,
|
Title: results[i].Title,
|
||||||
DiscussionUrl: "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id),
|
DiscussionUrl: commentsUrl,
|
||||||
TargetUrl: results[i].TargetUrl,
|
TargetUrl: results[i].TargetUrl,
|
||||||
TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
|
TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
|
||||||
CommentCount: results[i].CommentCount,
|
CommentCount: results[i].CommentCount,
|
||||||
@@ -74,8 +83,8 @@ func getHackerNewsPostsFromIds(postIds []int) (ForumPosts, error) {
|
|||||||
return posts, nil
|
return posts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchHackerNewsTopPosts(limit int) (ForumPosts, error) {
|
func FetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (ForumPosts, error) {
|
||||||
postIds, err := getHackerNewsTopPostIds()
|
postIds, err := getHackerNewsPostIds(sort)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -85,5 +94,5 @@ func FetchHackerNewsTopPosts(limit int) (ForumPosts, error) {
|
|||||||
postIds = postIds[:limit]
|
postIds = postIds[:limit]
|
||||||
}
|
}
|
||||||
|
|
||||||
return getHackerNewsPostsFromIds(postIds)
|
return getHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package feed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type lobstersPostResponseJson struct {
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
CommentCount int `json:"comment_count"`
|
||||||
|
CommentsURL string `json:"comments_url"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type lobstersFeedResponseJson []lobstersPostResponseJson
|
||||||
|
|
||||||
|
func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) {
|
||||||
|
request, err := http.NewRequest("GET", feedUrl, nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
posts := make(ForumPosts, 0, len(feed))
|
||||||
|
|
||||||
|
for i := range feed {
|
||||||
|
createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt)
|
||||||
|
|
||||||
|
posts = append(posts, ForumPost{
|
||||||
|
Title: feed[i].Title,
|
||||||
|
DiscussionUrl: feed[i].CommentsURL,
|
||||||
|
TargetUrl: feed[i].URL,
|
||||||
|
TargetUrlDomain: extractDomainFromUrl(feed[i].URL),
|
||||||
|
CommentCount: feed[i].CommentCount,
|
||||||
|
Score: feed[i].Score,
|
||||||
|
TimePosted: createdAt,
|
||||||
|
Tags: feed[i].Tags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(posts) == 0 {
|
||||||
|
return nil, ErrNoContent
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) {
|
||||||
|
var feedUrl string
|
||||||
|
|
||||||
|
if customURL != "" {
|
||||||
|
feedUrl = customURL
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type SiteStatusRequest struct {
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
CheckURL string `yaml:"check-url"`
|
||||||
|
AllowInsecure bool `yaml:"allow-insecure"`
|
||||||
|
}
|
||||||
|
|
||||||
type SiteStatus struct {
|
type SiteStatus struct {
|
||||||
Code int
|
Code int
|
||||||
TimedOut bool
|
TimedOut bool
|
||||||
@@ -14,14 +20,34 @@ type SiteStatus struct {
|
|||||||
Error error
|
Error error
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
|
func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
|
||||||
|
var url string
|
||||||
|
if statusRequest.CheckURL != "" {
|
||||||
|
url = statusRequest.CheckURL
|
||||||
|
} else {
|
||||||
|
url = statusRequest.URL
|
||||||
|
}
|
||||||
|
request, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return SiteStatus{
|
||||||
|
Error: err,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
request = request.WithContext(ctx)
|
request = request.WithContext(ctx)
|
||||||
start := time.Now()
|
requestSentAt := time.Now()
|
||||||
response, err := http.DefaultClient.Do(request)
|
var response *http.Response
|
||||||
took := time.Since(start)
|
|
||||||
status := SiteStatus{ResponseTime: took}
|
if !statusRequest.AllowInsecure {
|
||||||
|
response, err = defaultClient.Do(request)
|
||||||
|
} else {
|
||||||
|
response, err = defaultInsecureClient.Do(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := SiteStatus{ResponseTime: time.Since(requestSentAt)}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
@@ -29,7 +55,7 @@ func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
status.Error = err
|
status.Error = err
|
||||||
return status, err
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
@@ -39,7 +65,7 @@ func getSiteStatusTask(request *http.Request) (SiteStatus, error) {
|
|||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchStatusesForRequests(requests []*http.Request) ([]SiteStatus, error) {
|
func FetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) {
|
||||||
job := newJob(getSiteStatusTask, requests).withWorkers(20)
|
job := newJob(getSiteStatusTask, requests).withWorkers(20)
|
||||||
results, _, err := workerPoolDo(job)
|
results, _, err := workerPoolDo(job)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "time/tzdata"
|
_ "time/tzdata"
|
||||||
@@ -17,6 +18,7 @@ type PlacesResponseJson struct {
|
|||||||
|
|
||||||
type PlaceJson struct {
|
type PlaceJson struct {
|
||||||
Name string
|
Name string
|
||||||
|
Area string `json:"admin1"`
|
||||||
Latitude float64
|
Latitude float64
|
||||||
Longitude float64
|
Longitude float64
|
||||||
Timezone string
|
Timezone string
|
||||||
@@ -48,8 +50,41 @@ type weatherColumn struct {
|
|||||||
HasPrecipitation bool
|
HasPrecipitation bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var commonCountryAbbreviations = map[string]string{
|
||||||
|
"US": "United States",
|
||||||
|
"USA": "United States",
|
||||||
|
"UK": "United Kingdom",
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandCountryAbbreviations(name string) string {
|
||||||
|
if expanded, ok := commonCountryAbbreviations[strings.TrimSpace(name)]; ok {
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separates the location that Open Meteo accepts from the administrative area
|
||||||
|
// which can then be used to filter to the correct place after the list of places
|
||||||
|
// has been retrieved. Also expands abbreviations since Open Meteo does not accept
|
||||||
|
// country names like "US", "USA" and "UK"
|
||||||
|
func parsePlaceName(name string) (string, string) {
|
||||||
|
parts := strings.Split(name, ",")
|
||||||
|
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return name, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return parts[0] + ", " + expandCountryAbbreviations(parts[1]), ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts[0] + ", " + expandCountryAbbreviations(parts[2]), strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
func FetchPlaceFromName(location string) (*PlaceJson, error) {
|
func FetchPlaceFromName(location string) (*PlaceJson, error) {
|
||||||
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=en&format=json", url.QueryEscape(location))
|
location, area := parsePlaceName(location)
|
||||||
|
requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=10&language=en&format=json", url.QueryEscape(location))
|
||||||
request, _ := http.NewRequest("GET", requestUrl, nil)
|
request, _ := http.NewRequest("GET", requestUrl, nil)
|
||||||
responseJson, err := decodeJsonFromRequest[PlacesResponseJson](defaultClient, request)
|
responseJson, err := decodeJsonFromRequest[PlacesResponseJson](defaultClient, request)
|
||||||
|
|
||||||
@@ -61,7 +96,24 @@ func FetchPlaceFromName(location string) (*PlaceJson, error) {
|
|||||||
return nil, fmt.Errorf("no places found for %s", location)
|
return nil, fmt.Errorf("no places found for %s", location)
|
||||||
}
|
}
|
||||||
|
|
||||||
place := &responseJson.Results[0]
|
var place *PlaceJson
|
||||||
|
|
||||||
|
if area != "" {
|
||||||
|
area = strings.ToLower(area)
|
||||||
|
|
||||||
|
for i := range responseJson.Results {
|
||||||
|
if strings.ToLower(responseJson.Results[i].Area) == area {
|
||||||
|
place = &responseJson.Results[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if place == nil {
|
||||||
|
return nil, fmt.Errorf("no place found for %s in %s", location, area)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
place = &responseJson.Results[0]
|
||||||
|
}
|
||||||
|
|
||||||
loc, err := time.LoadLocation(place.Timezone)
|
loc, err := time.LoadLocation(place.Timezone)
|
||||||
|
|
||||||
@@ -79,18 +131,25 @@ func barIndexFromHour(h int) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: bunch of spaget, refactor
|
// TODO: bunch of spaget, refactor
|
||||||
// TODO: allow changing between C and F
|
func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) {
|
||||||
func FetchWeatherForPlace(place *PlaceJson) (*Weather, error) {
|
|
||||||
query := url.Values{}
|
query := url.Values{}
|
||||||
|
var temperatureUnit string
|
||||||
|
|
||||||
|
if units == "imperial" {
|
||||||
|
temperatureUnit = "fahrenheit"
|
||||||
|
} else {
|
||||||
|
temperatureUnit = "celsius"
|
||||||
|
}
|
||||||
|
|
||||||
query.Add("latitude", fmt.Sprintf("%f", place.Latitude))
|
query.Add("latitude", fmt.Sprintf("%f", place.Latitude))
|
||||||
query.Add("longitude", fmt.Sprintf("%f", place.Longitude))
|
query.Add("longitude", fmt.Sprintf("%f", place.Longitude))
|
||||||
query.Add("timeformat", "unixtime")
|
query.Add("timeformat", "unixtime")
|
||||||
query.Add("timezone", place.Timezone)
|
query.Add("timezone", place.Timezone)
|
||||||
query.Add("forecast_days", "1")
|
query.Add("forecast_days", "1")
|
||||||
query.Add("current", "temperature_2m,apparent_temperature,weather_code,wind_speed_10m")
|
query.Add("current", "temperature_2m,apparent_temperature,weather_code")
|
||||||
query.Add("hourly", "temperature_2m,precipitation_probability")
|
query.Add("hourly", "temperature_2m,precipitation_probability")
|
||||||
query.Add("daily", "sunrise,sunset")
|
query.Add("daily", "sunrise,sunset")
|
||||||
|
query.Add("temperature_unit", temperatureUnit)
|
||||||
|
|
||||||
requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode()
|
requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode()
|
||||||
request, _ := http.NewRequest("GET", requestUrl, nil)
|
request, _ := http.NewRequest("GET", requestUrl, nil)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type ForumPost struct {
|
|||||||
Score int
|
Score int
|
||||||
Engagement float64
|
Engagement float64
|
||||||
TimePosted time.Time
|
TimePosted time.Time
|
||||||
|
Tags []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ForumPosts []ForumPost
|
type ForumPosts []ForumPost
|
||||||
@@ -59,17 +60,49 @@ type Video struct {
|
|||||||
|
|
||||||
type Videos []Video
|
type Videos []Video
|
||||||
|
|
||||||
type Stock struct {
|
var currencyToSymbol = map[string]string{
|
||||||
Name string
|
"USD": "$",
|
||||||
Symbol string
|
"EUR": "€",
|
||||||
Price float64
|
"JPY": "¥",
|
||||||
PercentChange float64
|
"CAD": "C$",
|
||||||
SvgChartPoints string
|
"AUD": "A$",
|
||||||
|
"GBP": "£",
|
||||||
|
"CHF": "Fr",
|
||||||
|
"NZD": "N$",
|
||||||
|
"INR": "₹",
|
||||||
|
"BRL": "R$",
|
||||||
|
"RUB": "₽",
|
||||||
|
"TRY": "₺",
|
||||||
|
"ZAR": "R",
|
||||||
|
"CNY": "¥",
|
||||||
|
"KRW": "₩",
|
||||||
|
"HKD": "HK$",
|
||||||
|
"SGD": "S$",
|
||||||
|
"SEK": "kr",
|
||||||
|
"NOK": "kr",
|
||||||
|
"DKK": "kr",
|
||||||
|
"PLN": "zł",
|
||||||
|
"PHP": "₱",
|
||||||
}
|
}
|
||||||
|
|
||||||
type Stocks []Stock
|
type MarketRequest struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Symbol string `yaml:"symbol"`
|
||||||
|
ChartLink string `yaml:"chart-link"`
|
||||||
|
SymbolLink string `yaml:"symbol-link"`
|
||||||
|
}
|
||||||
|
|
||||||
func (t Stocks) SortByAbsChange() {
|
type Market struct {
|
||||||
|
MarketRequest
|
||||||
|
Currency string `yaml:"-"`
|
||||||
|
Price float64 `yaml:"-"`
|
||||||
|
PercentChange float64 `yaml:"-"`
|
||||||
|
SvgChartPoints string `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Markets []Market
|
||||||
|
|
||||||
|
func (t Markets) SortByAbsChange() {
|
||||||
sort.Slice(t, func(i, j int) bool {
|
sort.Slice(t, func(i, j int) bool {
|
||||||
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
|
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"html"
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ type subredditResponseJson struct {
|
|||||||
Data struct {
|
Data struct {
|
||||||
Children []struct {
|
Children []struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
|
Id string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Upvotes int `json:"ups"`
|
Upvotes int `json:"ups"`
|
||||||
Url string `json:"url"`
|
Url string `json:"url"`
|
||||||
@@ -28,8 +30,29 @@ type subredditResponseJson struct {
|
|||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchSubredditPosts(subreddit string) (ForumPosts, error) {
|
func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string) (ForumPosts, error) {
|
||||||
requestUrl := fmt.Sprintf("https://www.reddit.com/r/%s/hot.json", url.QueryEscape(subreddit))
|
query := url.Values{}
|
||||||
|
var requestUrl string
|
||||||
|
|
||||||
|
if search != "" {
|
||||||
|
query.Set("q", search+" subreddit:"+subreddit)
|
||||||
|
query.Set("sort", sort)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sort == "top" {
|
||||||
|
query.Set("t", topPeriod)
|
||||||
|
}
|
||||||
|
|
||||||
|
if search != "" {
|
||||||
|
requestUrl = fmt.Sprintf("https://www.reddit.com/search.json?%s", query.Encode())
|
||||||
|
} else {
|
||||||
|
requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode())
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestUrlTemplate != "" {
|
||||||
|
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
|
||||||
|
}
|
||||||
|
|
||||||
request, err := http.NewRequest("GET", requestUrl, nil)
|
request, err := http.NewRequest("GET", requestUrl, nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -57,9 +80,19 @@ func FetchSubredditPosts(subreddit string) (ForumPosts, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var commentsUrl string
|
||||||
|
|
||||||
|
if commentsUrlTemplate == "" {
|
||||||
|
commentsUrl = "https://www.reddit.com" + post.Permalink
|
||||||
|
} else {
|
||||||
|
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{SUBREDDIT}", subreddit)
|
||||||
|
commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-ID}", post.Id)
|
||||||
|
commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-PATH}", strings.TrimLeft(post.Permalink, "/"))
|
||||||
|
}
|
||||||
|
|
||||||
forumPost := ForumPost{
|
forumPost := ForumPost{
|
||||||
Title: html.UnescapeString(post.Title),
|
Title: html.UnescapeString(post.Title),
|
||||||
DiscussionUrl: "https://www.reddit.com" + post.Permalink,
|
DiscussionUrl: commentsUrl,
|
||||||
TargetUrlDomain: post.Domain,
|
TargetUrlDomain: post.Domain,
|
||||||
CommentCount: post.CommentsCount,
|
CommentCount: post.CommentsCount,
|
||||||
Score: post.Upvotes,
|
Score: post.Upvotes,
|
||||||
@@ -77,7 +110,5 @@ func FetchSubredditPosts(subreddit string) (ForumPosts, error) {
|
|||||||
posts = append(posts, forumPost)
|
posts = append(posts, forumPost)
|
||||||
}
|
}
|
||||||
|
|
||||||
posts.CalculateEngagement()
|
|
||||||
|
|
||||||
return posts, nil
|
return posts, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package feed
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -11,8 +12,19 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultClientTimeout = 5 * time.Second
|
||||||
|
|
||||||
var defaultClient = &http.Client{
|
var defaultClient = &http.Client{
|
||||||
Timeout: 5 * time.Second,
|
Timeout: defaultClientTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
var insecureClientTransport = &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultInsecureClient = &http.Client{
|
||||||
|
Timeout: defaultClientTimeout,
|
||||||
|
Transport: insecureClientTransport,
|
||||||
}
|
}
|
||||||
|
|
||||||
type RequestDoer interface {
|
type RequestDoer interface {
|
||||||
@@ -23,6 +35,16 @@ func addBrowserUserAgentHeader(request *http.Request) {
|
|||||||
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0")
|
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func truncateString(s string, maxLen int) string {
|
||||||
|
asRunes := []rune(s)
|
||||||
|
|
||||||
|
if len(asRunes) > maxLen {
|
||||||
|
return string(asRunes[:maxLen])
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T, error) {
|
func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T, error) {
|
||||||
response, err := client.Do(request)
|
response, err := client.Do(request)
|
||||||
var result T
|
var result T
|
||||||
@@ -40,7 +62,12 @@ func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if response.StatusCode != http.StatusOK {
|
if response.StatusCode != http.StatusOK {
|
||||||
return result, fmt.Errorf("unexpected status code %d for %s, response: %s", response.StatusCode, request.URL, string(body))
|
return result, fmt.Errorf(
|
||||||
|
"unexpected status code %d for %s, response: %s",
|
||||||
|
response.StatusCode,
|
||||||
|
request.URL,
|
||||||
|
truncateString(string(body), 256),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(body, &result)
|
err = json.Unmarshal(body, &result)
|
||||||
@@ -76,7 +103,12 @@ func decodeXmlFromRequest[T any](client RequestDoer, request *http.Request) (T,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if response.StatusCode != http.StatusOK {
|
if response.StatusCode != http.StatusOK {
|
||||||
return result, fmt.Errorf("unexpected status code %d for %s, response: %s", response.StatusCode, request.URL, string(body))
|
return result, fmt.Errorf(
|
||||||
|
"unexpected status code %d for %s, response: %s",
|
||||||
|
response.StatusCode,
|
||||||
|
request.URL,
|
||||||
|
truncateString(string(body), 256),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = xml.Unmarshal(body, &result)
|
err = xml.Unmarshal(body, &result)
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ package feed
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mmcdole/gofeed"
|
"github.com/mmcdole/gofeed"
|
||||||
|
gofeedext "github.com/mmcdole/gofeed/extensions"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RSSFeedItem struct {
|
type RSSFeedItem struct {
|
||||||
@@ -16,12 +21,48 @@ type RSSFeedItem struct {
|
|||||||
Title string
|
Title string
|
||||||
Link string
|
Link string
|
||||||
ImageURL string
|
ImageURL string
|
||||||
|
Categories []string
|
||||||
|
Description string
|
||||||
PublishedAt time.Time
|
PublishedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doesn't cover all cases but works the vast majority of the time
|
||||||
|
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
|
||||||
|
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
|
||||||
|
|
||||||
|
func sanitizeFeedDescription(description string) string {
|
||||||
|
if description == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
description = strings.ReplaceAll(description, "\n", " ")
|
||||||
|
description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
|
||||||
|
description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
|
||||||
|
description = strings.TrimSpace(description)
|
||||||
|
description = html.UnescapeString(description)
|
||||||
|
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
type RSSFeedRequest struct {
|
||||||
Url string `yaml:"url"`
|
Url string `yaml:"url"`
|
||||||
Title string `yaml:"title"`
|
Title string `yaml:"title"`
|
||||||
|
HideCategories bool `yaml:"hide-categories"`
|
||||||
|
HideDescription bool `yaml:"hide-description"`
|
||||||
|
ItemLinkPrefix string `yaml:"item-link-prefix"`
|
||||||
|
IsDetailed bool `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RSSFeedItems []RSSFeedItem
|
type RSSFeedItems []RSSFeedItem
|
||||||
@@ -53,8 +94,60 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
|||||||
|
|
||||||
rssItem := RSSFeedItem{
|
rssItem := RSSFeedItem{
|
||||||
ChannelURL: feed.Link,
|
ChannelURL: feed.Link,
|
||||||
Title: item.Title,
|
}
|
||||||
Link: item.Link,
|
|
||||||
|
if request.ItemLinkPrefix != "" {
|
||||||
|
rssItem.Link = request.ItemLinkPrefix + item.Link
|
||||||
|
} else if strings.HasPrefix(item.Link, "http://") || strings.HasPrefix(item.Link, "https://") {
|
||||||
|
rssItem.Link = item.Link
|
||||||
|
} else {
|
||||||
|
parsedUrl, err := url.Parse(feed.Link)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
parsedUrl, err = url.Parse(request.Url)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
var link string
|
||||||
|
|
||||||
|
if item.Link[0] == '/' {
|
||||||
|
link = item.Link
|
||||||
|
} else {
|
||||||
|
link = "/" + item.Link
|
||||||
|
}
|
||||||
|
|
||||||
|
rssItem.Link = parsedUrl.Scheme + "://" + parsedUrl.Host + link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Title != "" {
|
||||||
|
rssItem.Title = item.Title
|
||||||
|
} else {
|
||||||
|
rssItem.Title = shortenFeedDescriptionLen(item.Description, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.IsDetailed {
|
||||||
|
if !request.HideDescription && item.Description != "" && item.Title != "" {
|
||||||
|
rssItem.Description = shortenFeedDescriptionLen(item.Description, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !request.HideCategories {
|
||||||
|
var categories = make([]string, 0, 6)
|
||||||
|
|
||||||
|
for _, category := range item.Categories {
|
||||||
|
if len(categories) == 6 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(category) == 0 || len(category) > 30 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
categories = append(categories, category)
|
||||||
|
}
|
||||||
|
|
||||||
|
rssItem.Categories = categories
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.Title != "" {
|
if request.Title != "" {
|
||||||
@@ -65,6 +158,8 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
|||||||
|
|
||||||
if item.Image != nil {
|
if item.Image != nil {
|
||||||
rssItem.ImageURL = item.Image.URL
|
rssItem.ImageURL = item.Image.URL
|
||||||
|
} else if url := findThumbnailInItemExtensions(item); url != "" {
|
||||||
|
rssItem.ImageURL = url
|
||||||
} else if feed.Image != nil {
|
} else if feed.Image != nil {
|
||||||
rssItem.ImageURL = feed.Image.URL
|
rssItem.ImageURL = feed.Image.URL
|
||||||
}
|
}
|
||||||
@@ -81,6 +176,36 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
|||||||
return items, nil
|
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) {
|
func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
|
||||||
job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10)
|
job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10)
|
||||||
feeds, errs, err := workerPoolDo(job)
|
feeds, errs, err := workerPoolDo(job)
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ func (channels TwitchChannels) SortByViewers() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (channels TwitchChannels) SortByLive() {
|
||||||
|
sort.SliceStable(channels, func(i, j int) bool {
|
||||||
|
return channels[i].IsLive && !channels[j].IsLive
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type twitchOperationResponse struct {
|
type twitchOperationResponse struct {
|
||||||
Data json.RawMessage
|
Data json.RawMessage
|
||||||
Extensions struct {
|
Extensions struct {
|
||||||
@@ -198,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 {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -28,7 +29,7 @@ func extractDomainFromUrl(u string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.TrimPrefix(parsed.Host, "www.")
|
return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func SvgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
|
func SvgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
|
||||||
@@ -77,3 +78,20 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
|
|||||||
|
|
||||||
return values
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
|
||||||
|
|
||||||
|
func stripURLScheme(url string) string {
|
||||||
|
return urlSchemePattern.ReplaceAllString(url, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func limitStringLength(s string, max int) (string, bool) {
|
||||||
|
asRunes := []rune(s)
|
||||||
|
|
||||||
|
if len(asRunes) > max {
|
||||||
|
return string(asRunes[:max]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, false
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stockResponseJson struct {
|
type marketResponseJson struct {
|
||||||
Chart struct {
|
Chart struct {
|
||||||
Result []struct {
|
Result []struct {
|
||||||
Meta struct {
|
Meta struct {
|
||||||
|
Currency string `json:"currency"`
|
||||||
Symbol string `json:"symbol"`
|
Symbol string `json:"symbol"`
|
||||||
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||||
ChartPreviousClose float64 `json:"chartPreviousClose"`
|
ChartPreviousClose float64 `json:"chartPreviousClose"`
|
||||||
@@ -23,36 +24,31 @@ type stockResponseJson struct {
|
|||||||
} `json:"chart"`
|
} `json:"chart"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StockRequest struct {
|
|
||||||
Symbol string
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: allow changing chart time frame
|
// TODO: allow changing chart time frame
|
||||||
const stockChartDays = 21
|
const marketChartDays = 21
|
||||||
|
|
||||||
func FetchStocksDataFromYahoo(stockRequests []StockRequest) (Stocks, error) {
|
func FetchMarketsDataFromYahoo(marketRequests []MarketRequest) (Markets, error) {
|
||||||
requests := make([]*http.Request, 0, len(stockRequests))
|
requests := make([]*http.Request, 0, len(marketRequests))
|
||||||
|
|
||||||
for i := range stockRequests {
|
for i := range marketRequests {
|
||||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", stockRequests[i].Symbol), nil)
|
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil)
|
||||||
requests = append(requests, request)
|
requests = append(requests, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
job := newJob(decodeJsonFromRequestTask[stockResponseJson](defaultClient), requests)
|
job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests)
|
||||||
responses, errs, err := workerPoolDo(job)
|
responses, errs, err := workerPoolDo(job)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stocks := make(Stocks, 0, len(responses))
|
markets := make(Markets, 0, len(responses))
|
||||||
var failed int
|
var failed int
|
||||||
|
|
||||||
for i := range responses {
|
for i := range responses {
|
||||||
if errs[i] != nil {
|
if errs[i] != nil {
|
||||||
failed++
|
failed++
|
||||||
slog.Error("Failed to fetch stock data", "symbol", stockRequests[i].Symbol, "error", errs[i])
|
slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i])
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,14 +56,14 @@ func FetchStocksDataFromYahoo(stockRequests []StockRequest) (Stocks, error) {
|
|||||||
|
|
||||||
if len(response.Chart.Result) == 0 {
|
if len(response.Chart.Result) == 0 {
|
||||||
failed++
|
failed++
|
||||||
slog.Error("Stock response contains no data", "symbol", stockRequests[i].Symbol)
|
slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
prices := response.Chart.Result[0].Indicators.Quote[0].Close
|
prices := response.Chart.Result[0].Indicators.Quote[0].Close
|
||||||
|
|
||||||
if len(prices) > stockChartDays {
|
if len(prices) > marketChartDays {
|
||||||
prices = prices[len(prices)-stockChartDays:]
|
prices = prices[len(prices)-marketChartDays:]
|
||||||
}
|
}
|
||||||
|
|
||||||
previous := response.Chart.Result[0].Meta.RegularMarketPrice
|
previous := response.Chart.Result[0].Meta.RegularMarketPrice
|
||||||
@@ -78,10 +74,16 @@ func FetchStocksDataFromYahoo(stockRequests []StockRequest) (Stocks, error) {
|
|||||||
|
|
||||||
points := SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
|
points := SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
|
||||||
|
|
||||||
stocks = append(stocks, Stock{
|
currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency]
|
||||||
Name: stockRequests[i].Name,
|
|
||||||
Symbol: response.Chart.Result[0].Meta.Symbol,
|
if !exists {
|
||||||
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
|
currency = response.Chart.Result[0].Meta.Currency
|
||||||
|
}
|
||||||
|
|
||||||
|
markets = append(markets, Market{
|
||||||
|
MarketRequest: marketRequests[i],
|
||||||
|
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
|
||||||
|
Currency: currency,
|
||||||
PercentChange: percentChange(
|
PercentChange: percentChange(
|
||||||
response.Chart.Result[0].Meta.RegularMarketPrice,
|
response.Chart.Result[0].Meta.RegularMarketPrice,
|
||||||
previous,
|
previous,
|
||||||
@@ -90,13 +92,13 @@ func FetchStocksDataFromYahoo(stockRequests []StockRequest) (Stocks, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(stocks) == 0 {
|
if len(markets) == 0 {
|
||||||
return nil, ErrNoContent
|
return nil, ErrNoContent
|
||||||
}
|
}
|
||||||
|
|
||||||
if failed > 0 {
|
if failed > 0 {
|
||||||
return stocks, fmt.Errorf("%w: could not fetch data for %d stock(s)", ErrPartialContent, failed)
|
return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", ErrPartialContent, failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
return stocks, nil
|
return markets, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
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"`
|
||||||
@@ -38,11 +39,19 @@ func parseYoutubeFeedTime(t string) time.Time {
|
|||||||
return parsedTime
|
return parsedTime
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchYoutubeChannelUploads(channelIds []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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,16 +78,24 @@ func FetchYoutubeChannelUploads(channelIds []string) (Videos, error) {
|
|||||||
|
|
||||||
for j := range response.Videos {
|
for j := range response.Videos {
|
||||||
video := &response.Videos[j]
|
video := &response.Videos[j]
|
||||||
|
var videoUrl string
|
||||||
|
|
||||||
// TODO: figure out a better way of skipping shorts
|
if videoUrlTemplate == "" {
|
||||||
if strings.Contains(video.Title, "#shorts") {
|
videoUrl = video.Link.Href
|
||||||
continue
|
} else {
|
||||||
|
parsedUrl, err := url.Parse(video.Link.Href)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v"))
|
||||||
|
} else {
|
||||||
|
videoUrl = "#"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
videos = append(videos, Video{
|
videos = append(videos, Video{
|
||||||
ThumbnailUrl: video.Group.Thumbnail.Url,
|
ThumbnailUrl: video.Group.Thumbnail.Url,
|
||||||
Title: video.Title,
|
Title: video.Title,
|
||||||
Url: video.Link.Href,
|
Url: videoUrl,
|
||||||
Author: response.Channel,
|
Author: response.Channel,
|
||||||
AuthorUrl: response.ChannelLink.Href + "/videos",
|
AuthorUrl: response.ChannelLink.Href + "/videos",
|
||||||
TimePosted: parseYoutubeFeedTime(video.Published),
|
TimePosted: parseYoutubeFeedTime(video.Published),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -34,13 +36,16 @@ type Theme struct {
|
|||||||
Light bool `yaml:"light"`
|
Light bool `yaml:"light"`
|
||||||
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
||||||
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
||||||
|
CustomCSSFile string `yaml:"custom-css-file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
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"`
|
||||||
StartedAt time.Time `yaml:"-"`
|
BaseURL string `yaml:"base-url"`
|
||||||
|
AssetsHash string `yaml:"-"`
|
||||||
|
StartedAt time.Time `yaml:"-"` // used in custom css file
|
||||||
}
|
}
|
||||||
|
|
||||||
type Column struct {
|
type Column struct {
|
||||||
@@ -54,10 +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"`
|
||||||
Columns []Column `yaml:"columns"`
|
Width string `yaml:"width"`
|
||||||
mu sync.Mutex
|
ShowMobileHeader bool `yaml:"show-mobile-header"`
|
||||||
|
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
||||||
|
Columns []Column `yaml:"columns"`
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Page) UpdateOutdatedWidgets() {
|
func (p *Page) UpdateOutdatedWidgets() {
|
||||||
@@ -103,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
|
||||||
@@ -187,15 +213,47 @@ 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 {
|
||||||
|
return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Application) Serve() error {
|
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 gzip support, static files must have their gzipped contents cached
|
||||||
// TODO: add HTTPS support
|
// TODO: add HTTPS support
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
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.Handle("GET /static/{path...}", http.StripPrefix("/static/", FileServerWithCache(http.FS(assets.PublicFS), 2*time.Hour)))
|
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest)
|
||||||
|
|
||||||
|
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 != "" {
|
if a.Config.Server.AssetsPath != "" {
|
||||||
absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
|
absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
|
||||||
@@ -215,7 +273,7 @@ func (a *Application) Serve() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a.Config.Server.StartedAt = time.Now()
|
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)
|
||||||
|
|
||||||
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port)
|
|
||||||
return server.ListenAndServe()
|
return server.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func Main() int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if app.Serve() != nil {
|
if err := app.Serve(); err != nil {
|
||||||
fmt.Printf("http server error: %v\n", err)
|
fmt.Printf("http server error: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,14 +13,31 @@ type Bookmarks struct {
|
|||||||
Title string `yaml:"title"`
|
Title string `yaml:"title"`
|
||||||
Color *HSLColorField `yaml:"color"`
|
Color *HSLColorField `yaml:"color"`
|
||||||
Links []struct {
|
Links []struct {
|
||||||
Title string `yaml:"title"`
|
Title string `yaml:"title"`
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url"`
|
||||||
|
Icon string `yaml:"icon"`
|
||||||
|
IsSimpleIcon bool `yaml:"-"`
|
||||||
|
SameTab bool `yaml:"same-tab"`
|
||||||
|
HideArrow bool `yaml:"hide-arrow"`
|
||||||
} `yaml:"links"`
|
} `yaml:"links"`
|
||||||
} `yaml:"groups"`
|
} `yaml:"groups"`
|
||||||
|
Style string `yaml:"style"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Bookmarks) Initialize() error {
|
func (widget *Bookmarks) Initialize() error {
|
||||||
widget.withTitle("Bookmarks").withError(nil)
|
widget.withTitle("Bookmarks").withError(nil)
|
||||||
|
|
||||||
|
for g := range widget.Groups {
|
||||||
|
for l := range widget.Groups[g].Links {
|
||||||
|
if widget.Groups[g].Links[l].Icon == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
link := &widget.Groups[g].Links[l]
|
||||||
|
link.Icon, link.IsSimpleIcon = toSimpleIconIfPrefixed(link.Icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
widget.cachedHTML = widget.render(widget, assets.BookmarksTemplate)
|
widget.cachedHTML = widget.render(widget, assets.BookmarksTemplate)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
|
"github.com/glanceapp/glance/internal/feed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChangeDetection struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
ChangeDetections feed.ChangeDetectionWatches `yaml:"-"`
|
||||||
|
WatchUUIDs []string `yaml:"watches"`
|
||||||
|
InstanceURL string `yaml:"instance-url"`
|
||||||
|
Token OptionalEnvString `yaml:"token"`
|
||||||
|
Limit int `yaml:"limit"`
|
||||||
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *ChangeDetection) Initialize() error {
|
||||||
|
widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour)
|
||||||
|
|
||||||
|
if widget.Limit <= 0 {
|
||||||
|
widget.Limit = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||||
|
widget.CollapseAfter = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.InstanceURL == "" {
|
||||||
|
widget.InstanceURL = "https://www.changedetection.io"
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *ChangeDetection) Update(ctx context.Context) {
|
||||||
|
if len(widget.WatchUUIDs) == 0 {
|
||||||
|
uuids, err := feed.FetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token))
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.WatchUUIDs = uuids
|
||||||
|
}
|
||||||
|
|
||||||
|
watches, err := feed.FetchWatchesFromChangeDetection(widget.InstanceURL, widget.WatchUUIDs, string(widget.Token))
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(watches) > widget.Limit {
|
||||||
|
watches = watches[:widget.Limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.ChangeDetections = watches
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *ChangeDetection) Render() template.HTML {
|
||||||
|
return widget.render(widget, assets.ChangeDetectionTemplate)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Clock struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
cachedHTML template.HTML `yaml:"-"`
|
||||||
|
HourFormat string `yaml:"hour-format"`
|
||||||
|
Timezones []struct {
|
||||||
|
Timezone string `yaml:"timezone"`
|
||||||
|
Label string `yaml:"label"`
|
||||||
|
} `yaml:"timezones"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Clock) Initialize() error {
|
||||||
|
widget.withTitle("Clock").withError(nil)
|
||||||
|
|
||||||
|
if widget.HourFormat == "" {
|
||||||
|
widget.HourFormat = "24h"
|
||||||
|
} else if widget.HourFormat != "12h" && widget.HourFormat != "24h" {
|
||||||
|
return errors.New("invalid hour format for clock widget, must be either 12h or 24h")
|
||||||
|
}
|
||||||
|
|
||||||
|
for t := range widget.Timezones {
|
||||||
|
if widget.Timezones[t].Timezone == "" {
|
||||||
|
return errors.New("missing timezone value for clock widget")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := time.LoadLocation(widget.Timezones[t].Timezone)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid timezone '%s' for clock widget: %v", widget.Timezones[t].Timezone, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.cachedHTML = widget.render(widget, assets.ClockTemplate)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Clock) Render() template.HTML {
|
||||||
|
return widget.cachedHTML
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
|
"github.com/glanceapp/glance/internal/feed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Extension struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
Parameters map[string]string `yaml:"parameters"`
|
||||||
|
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||||
|
Extension feed.Extension `yaml:"-"`
|
||||||
|
cachedHTML template.HTML `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Extension) Initialize() error {
|
||||||
|
widget.withTitle("Extension").withCacheDuration(time.Minute * 30)
|
||||||
|
|
||||||
|
if widget.URL == "" {
|
||||||
|
return errors.New("no extension URL specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := url.Parse(widget.URL)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Extension) Update(ctx context.Context) {
|
||||||
|
extension, err := feed.FetchExtension(feed.ExtensionRequestOptions{
|
||||||
|
URL: widget.URL,
|
||||||
|
Parameters: widget.Parameters,
|
||||||
|
AllowHtml: widget.AllowHtml,
|
||||||
|
})
|
||||||
|
|
||||||
|
widget.canContinueUpdateAfterHandlingErr(err)
|
||||||
|
|
||||||
|
widget.Extension = extension
|
||||||
|
|
||||||
|
if extension.Title != "" {
|
||||||
|
widget.Title = extension.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.cachedHTML = widget.render(widget, assets.ExtensionTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Extension) Render() template.HTML {
|
||||||
|
return widget.cachedHTML
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -150,3 +151,14 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toSimpleIconIfPrefixed(icon string) (string, bool) {
|
||||||
|
if !strings.HasPrefix(icon, "si:") {
|
||||||
|
return icon, false
|
||||||
|
}
|
||||||
|
|
||||||
|
icon = strings.TrimPrefix(icon, "si:")
|
||||||
|
icon = "https://cdnjs.cloudflare.com/ajax/libs/simple-icons/11.14.0/" + icon + ".svg"
|
||||||
|
|
||||||
|
return icon, true
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -10,14 +10,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type HackerNews struct {
|
type HackerNews struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
Posts feed.ForumPosts `yaml:"-"`
|
Posts feed.ForumPosts `yaml:"-"`
|
||||||
Limit int `yaml:"limit"`
|
Limit int `yaml:"limit"`
|
||||||
CollapseAfter int `yaml:"collapse-after"`
|
SortBy string `yaml:"sort-by"`
|
||||||
|
ExtraSortBy string `yaml:"extra-sort-by"`
|
||||||
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
|
CommentsUrlTemplate string `yaml:"comments-url-template"`
|
||||||
|
ShowThumbnails bool `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *HackerNews) Initialize() error {
|
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 {
|
if widget.Limit <= 0 {
|
||||||
widget.Limit = 15
|
widget.Limit = 15
|
||||||
@@ -27,18 +34,24 @@ func (widget *HackerNews) Initialize() error {
|
|||||||
widget.CollapseAfter = 5
|
widget.CollapseAfter = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" {
|
||||||
|
widget.SortBy = "top"
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *HackerNews) Update(ctx context.Context) {
|
func (widget *HackerNews) Update(ctx context.Context) {
|
||||||
posts, err := feed.FetchHackerNewsTopPosts(40)
|
posts, err := feed.FetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate)
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
posts.CalculateEngagement()
|
if widget.ExtraSortBy == "engagement" {
|
||||||
posts.SortByEngagement()
|
posts.CalculateEngagement()
|
||||||
|
posts.SortByEngagement()
|
||||||
|
}
|
||||||
|
|
||||||
if widget.Limit < len(posts) {
|
if widget.Limit < len(posts) {
|
||||||
posts = posts[:widget.Limit]
|
posts = posts[:widget.Limit]
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HTML struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
Source template.HTML `yaml:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *HTML) Initialize() error {
|
||||||
|
widget.withTitle("").withError(nil)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *HTML) Render() template.HTML {
|
||||||
|
return widget.Source
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
|
"github.com/glanceapp/glance/internal/feed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Lobsters struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
Posts feed.ForumPosts `yaml:"-"`
|
||||||
|
InstanceURL string `yaml:"instance-url"`
|
||||||
|
CustomURL string `yaml:"custom-url"`
|
||||||
|
Limit int `yaml:"limit"`
|
||||||
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
|
SortBy string `yaml:"sort-by"`
|
||||||
|
Tags []string `yaml:"tags"`
|
||||||
|
ShowThumbnails bool `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Lobsters) Initialize() error {
|
||||||
|
widget.withTitle("Lobsters").withCacheDuration(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"
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Limit <= 0 {
|
||||||
|
widget.Limit = 15
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||||
|
widget.CollapseAfter = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Lobsters) Update(ctx context.Context) {
|
||||||
|
posts, err := feed.FetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags)
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Limit < len(posts) {
|
||||||
|
posts = posts[:widget.Limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.Posts = posts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Lobsters) Render() template.HTML {
|
||||||
|
return widget.render(widget, assets.ForumPostsTemplate)
|
||||||
|
}
|
||||||
@@ -2,9 +2,7 @@ package widget
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -37,46 +35,45 @@ func statusCodeToText(status int) string {
|
|||||||
|
|
||||||
func statusCodeToStyle(status int) string {
|
func statusCodeToStyle(status int) string {
|
||||||
if status == 200 {
|
if status == 200 {
|
||||||
return "good"
|
return "ok"
|
||||||
}
|
}
|
||||||
|
|
||||||
return "bad"
|
return "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
type Monitor struct {
|
type Monitor struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
Sites []struct {
|
Sites []struct {
|
||||||
Title string `yaml:"title"`
|
*feed.SiteStatusRequest `yaml:",inline"`
|
||||||
Url string `yaml:"url"`
|
Status *feed.SiteStatus `yaml:"-"`
|
||||||
IconUrl string `yaml:"icon"`
|
Title string `yaml:"title"`
|
||||||
Status *feed.SiteStatus `yaml:"-"`
|
IconUrl string `yaml:"icon"`
|
||||||
StatusText string `yaml:"-"`
|
IsSimpleIcon bool `yaml:"-"`
|
||||||
StatusStyle string `yaml:"-"`
|
SameTab bool `yaml:"same-tab"`
|
||||||
|
StatusText string `yaml:"-"`
|
||||||
|
StatusStyle string `yaml:"-"`
|
||||||
} `yaml:"sites"`
|
} `yaml:"sites"`
|
||||||
|
Style string `yaml:"style"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Monitor) Initialize() error {
|
func (widget *Monitor) Initialize() error {
|
||||||
widget.withTitle("Monitor").withCacheDuration(5 * time.Minute)
|
widget.withTitle("Monitor").withCacheDuration(5 * time.Minute)
|
||||||
|
|
||||||
|
for i := range widget.Sites {
|
||||||
|
widget.Sites[i].IconUrl, widget.Sites[i].IsSimpleIcon = toSimpleIconIfPrefixed(widget.Sites[i].IconUrl)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Monitor) Update(ctx context.Context) {
|
func (widget *Monitor) Update(ctx context.Context) {
|
||||||
requests := make([]*http.Request, len(widget.Sites))
|
requests := make([]*feed.SiteStatusRequest, len(widget.Sites))
|
||||||
|
|
||||||
for i := range widget.Sites {
|
for i := range widget.Sites {
|
||||||
request, err := http.NewRequest("HEAD", widget.Sites[i].Url, nil)
|
requests[i] = widget.Sites[i].SiteStatusRequest
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
message := fmt.Errorf("failed to create http request for %s: %s", widget.Sites[i].Url, err)
|
|
||||||
widget.withNotice(message)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
requests[i] = request
|
|
||||||
}
|
}
|
||||||
|
|
||||||
statuses, err := feed.FetchStatusesForRequests(requests)
|
statuses, err := feed.FetchStatusForSites(requests)
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glanceapp/glance/internal/assets"
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
@@ -11,12 +12,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Reddit struct {
|
type Reddit struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
Posts feed.ForumPosts `yaml:"-"`
|
Posts feed.ForumPosts `yaml:"-"`
|
||||||
Subreddit string `yaml:"subreddit"`
|
Subreddit string `yaml:"subreddit"`
|
||||||
Style string `yaml:"style"`
|
Style string `yaml:"style"`
|
||||||
Limit int `yaml:"limit"`
|
ShowThumbnails bool `yaml:"show-thumbnails"`
|
||||||
CollapseAfter int `yaml:"collapse-after"`
|
SortBy string `yaml:"sort-by"`
|
||||||
|
TopPeriod string `yaml:"top-period"`
|
||||||
|
Search string `yaml:"search"`
|
||||||
|
ExtraSortBy string `yaml:"extra-sort-by"`
|
||||||
|
CommentsUrlTemplate string `yaml:"comments-url-template"`
|
||||||
|
Limit int `yaml:"limit"`
|
||||||
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
|
RequestUrlTemplate string `yaml:"request-url-template"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Reddit) Initialize() error {
|
func (widget *Reddit) Initialize() error {
|
||||||
@@ -32,13 +40,54 @@ func (widget *Reddit) Initialize() error {
|
|||||||
widget.CollapseAfter = 5
|
widget.CollapseAfter = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
widget.withTitle("/r/" + widget.Subreddit).withCacheDuration(30 * time.Minute)
|
if !isValidRedditSortType(widget.SortBy) {
|
||||||
|
widget.SortBy = "hot"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidRedditTopPeriod(widget.TopPeriod) {
|
||||||
|
widget.TopPeriod = "day"
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.RequestUrlTemplate != "" {
|
||||||
|
if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") {
|
||||||
|
return errors.New("no `{REQUEST-URL}` placeholder specified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.
|
||||||
|
withTitle("/r/" + widget.Subreddit).
|
||||||
|
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
|
||||||
|
withCacheDuration(30 * time.Minute)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isValidRedditSortType(sortBy string) bool {
|
||||||
|
return sortBy == "hot" ||
|
||||||
|
sortBy == "new" ||
|
||||||
|
sortBy == "top" ||
|
||||||
|
sortBy == "rising"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidRedditTopPeriod(period string) bool {
|
||||||
|
return period == "hour" ||
|
||||||
|
period == "day" ||
|
||||||
|
period == "week" ||
|
||||||
|
period == "month" ||
|
||||||
|
period == "year" ||
|
||||||
|
period == "all"
|
||||||
|
}
|
||||||
|
|
||||||
func (widget *Reddit) Update(ctx context.Context) {
|
func (widget *Reddit) Update(ctx context.Context) {
|
||||||
posts, err := feed.FetchSubredditPosts(widget.Subreddit)
|
// TODO: refactor, use a struct to pass all of these
|
||||||
|
posts, err := feed.FetchSubredditPosts(
|
||||||
|
widget.Subreddit,
|
||||||
|
widget.SortBy,
|
||||||
|
widget.TopPeriod,
|
||||||
|
widget.Search,
|
||||||
|
widget.CommentsUrlTemplate,
|
||||||
|
widget.RequestUrlTemplate,
|
||||||
|
)
|
||||||
|
|
||||||
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
return
|
return
|
||||||
@@ -48,7 +97,11 @@ func (widget *Reddit) Update(ctx context.Context) {
|
|||||||
posts = posts[:widget.Limit]
|
posts = posts[:widget.Limit]
|
||||||
}
|
}
|
||||||
|
|
||||||
posts.SortByEngagement()
|
if widget.ExtraSortBy == "engagement" {
|
||||||
|
posts.CalculateEngagement()
|
||||||
|
posts.SortByEngagement()
|
||||||
|
}
|
||||||
|
|
||||||
widget.Posts = posts
|
widget.Posts = posts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glanceapp/glance/internal/assets"
|
||||||
|
"github.com/glanceapp/glance/internal/feed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
RequestedRepository string `yaml:"repository"`
|
||||||
|
Token OptionalEnvString `yaml:"token"`
|
||||||
|
PullRequestsLimit int `yaml:"pull-requests-limit"`
|
||||||
|
IssuesLimit int `yaml:"issues-limit"`
|
||||||
|
RepositoryDetails feed.RepositoryDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Repository) Initialize() error {
|
||||||
|
widget.withTitle("Repository").withCacheDuration(1 * time.Hour)
|
||||||
|
|
||||||
|
if widget.PullRequestsLimit == 0 || widget.PullRequestsLimit < -1 {
|
||||||
|
widget.PullRequestsLimit = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.IssuesLimit == 0 || widget.IssuesLimit < -1 {
|
||||||
|
widget.IssuesLimit = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Repository) Update(ctx context.Context) {
|
||||||
|
details, err := feed.FetchRepositoryDetailsFromGithub(
|
||||||
|
widget.RequestedRepository,
|
||||||
|
string(widget.Token),
|
||||||
|
widget.PullRequestsLimit,
|
||||||
|
widget.IssuesLimit,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.RepositoryDetails = details
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *Repository) Render() template.HTML {
|
||||||
|
return widget.render(widget, assets.RepositoryTemplate)
|
||||||
|
}
|
||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type RSS struct {
|
type RSS struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
FeedRequests []feed.RSSFeedRequest `yaml:"feeds"`
|
FeedRequests []feed.RSSFeedRequest `yaml:"feeds"`
|
||||||
Style string `yaml:"style"`
|
Style string `yaml:"style"`
|
||||||
Items feed.RSSFeedItems `yaml:"-"`
|
ThumbnailHeight float64 `yaml:"thumbnail-height"`
|
||||||
Limit int `yaml:"limit"`
|
CardHeight float64 `yaml:"card-height"`
|
||||||
CollapseAfter int `yaml:"collapse-after"`
|
Items feed.RSSFeedItems `yaml:"-"`
|
||||||
|
Limit int `yaml:"limit"`
|
||||||
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *RSS) Initialize() error {
|
func (widget *RSS) Initialize() error {
|
||||||
@@ -29,6 +31,20 @@ func (widget *RSS) Initialize() error {
|
|||||||
widget.CollapseAfter = 5
|
widget.CollapseAfter = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if widget.ThumbnailHeight < 0 {
|
||||||
|
widget.ThumbnailHeight = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.CardHeight < 0 {
|
||||||
|
widget.CardHeight = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Style == "detailed-list" {
|
||||||
|
for i := range widget.FeedRequests {
|
||||||
|
widget.FeedRequests[i].IsDetailed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +64,15 @@ func (widget *RSS) Update(ctx context.Context) {
|
|||||||
|
|
||||||
func (widget *RSS) Render() template.HTML {
|
func (widget *RSS) Render() template.HTML {
|
||||||
if widget.Style == "horizontal-cards" {
|
if widget.Style == "horizontal-cards" {
|
||||||
return widget.render(widget, assets.RSSCardsTemplate)
|
return widget.render(widget, assets.RSSHorizontalCardsTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Style == "horizontal-cards-2" {
|
||||||
|
return widget.render(widget, assets.RSSHorizontalCards2Template)
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Style == "detailed-list" {
|
||||||
|
return widget.render(widget, assets.RSSDetailedListTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
return widget.render(widget, assets.RSSListTemplate)
|
return widget.render(widget, assets.RSSListTemplate)
|
||||||
|
|||||||