Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
947a42ddd4 | ||
|
|
8e4f1380ce | ||
|
|
2351d909ca | ||
|
|
7a0a4b9780 | ||
|
|
e108feb74b | ||
|
|
7f36f30dad | ||
|
|
8ba6dcb704 | ||
|
|
4b8a34a2ae | ||
|
|
2e198a19b4 | ||
|
|
6c5b7a3f4c | ||
|
|
36d5ae023f | ||
|
|
478c08f6a7 | ||
|
|
cae90d16ba | ||
|
|
fbc07bd142 | ||
|
|
4a4d3e1755 | ||
|
|
f243a4938f | ||
|
|
9416de1497 | ||
|
|
283a5fcfd0 | ||
|
|
c88fd526e5 | ||
|
|
f0541ea5c8 | ||
|
|
8f986f1403 | ||
|
|
de9a192ba4 | ||
|
|
88d8fa56fb | ||
|
|
9044e640bc | ||
|
|
808f3c1436 | ||
|
|
d103c81df1 | ||
|
|
429f0be675 | ||
|
|
5a093f42b0 | ||
|
|
ded435df0e | ||
|
|
e52374fa24 | ||
|
|
b94647efc9 | ||
|
|
d512770c10 | ||
|
|
ea52318be6 | ||
|
|
9e5023522b | ||
|
|
4d0fdd9b11 | ||
|
|
78725c8591 | ||
|
|
f7adaad1c5 | ||
|
|
ab093cb232 | ||
|
|
2aaff02db8 | ||
|
|
f5bfd9d4d1 | ||
|
|
9e3639eb54 | ||
|
|
b4094b28bd | ||
|
|
b294839b79 | ||
|
|
14a21de37f | ||
|
|
d9239acbce | ||
|
|
a2247c0b6c | ||
|
|
c1aaec5ffc | ||
|
|
bcef9fbd61 | ||
|
|
32db59fda2 | ||
|
|
92bc68b61a | ||
|
|
a6382b2e1d | ||
|
|
571cdaf618 | ||
|
|
aa3b2c3b1b | ||
|
|
9bbf73db97 | ||
|
|
dc950e7ec2 | ||
|
|
91ca57e242 | ||
|
|
95541ef5e1 | ||
|
|
1ace129a58 | ||
|
|
af04605d7d | ||
|
|
0ec9cf4aaa | ||
|
|
a5b0664b9c | ||
|
|
c67eb4d2c0 | ||
|
|
e99ee4f774 | ||
|
|
74e6c5c960 | ||
|
|
f801da88a7 | ||
|
|
66dcd1fa34 | ||
|
|
4f9e48cc17 | ||
|
|
95702a2e53 | ||
|
|
9de6843a2e | ||
|
|
bd5cacd14a | ||
|
|
96007e98bc | ||
|
|
ae409768ac | ||
|
|
baaf306ebf | ||
|
|
7bbf103e01 | ||
|
|
49c07f397e | ||
|
|
dd91a506fa | ||
|
|
4ed8bef562 | ||
|
|
8db6544400 | ||
|
|
ca9e4d273b | ||
|
|
9324da6a2f | ||
|
|
ce3a7ee29c | ||
|
|
c9e6b774f3 | ||
|
|
9ffb2d9939 | ||
|
|
c5d4cf8f68 | ||
|
|
6b7d68d960 | ||
|
|
0cb8a810e6 | ||
|
|
40ae263248 | ||
|
|
bf97829814 | ||
|
|
b075607bac | ||
|
|
56bdd2c9a3 | ||
|
|
0f6e51ee5c | ||
|
|
1c5701fde5 | ||
|
|
6c1c909c9d | ||
|
|
26ab9c7b05 | ||
|
|
176f14df70 | ||
|
|
2d1e317c1f | ||
|
|
fcdea66a0f | ||
|
|
0e91958de5 | ||
|
|
6624b635df | ||
|
|
0612dc6f89 | ||
|
|
0769f33653 | ||
|
|
c2286f9a22 | ||
|
|
a2d8410fec | ||
|
|
a68805b55d | ||
|
|
8ca6879a1c | ||
|
|
1661f14adb | ||
|
|
7d08eb312f | ||
|
|
47340ed82a | ||
|
|
129441713b | ||
|
|
a3e9510c1d | ||
|
|
328ba9a239 | ||
|
|
3a3ff080ac | ||
|
|
e2d3e1c04f | ||
|
|
b3658bea09 | ||
|
|
1ad71f6974 | ||
|
|
9dde306c0c | ||
|
|
d7a17aab01 | ||
|
|
65adf9b9c3 | ||
|
|
aceb832645 | ||
|
|
ec2f549295 | ||
|
|
6e06c9c489 | ||
|
|
1862858c07 | ||
|
|
306c28f203 | ||
|
|
bba2c5b20c | ||
|
|
18436e91e0 | ||
|
|
77fb199cb3 | ||
|
|
272bf1d2ad | ||
|
|
ceebf34874 | ||
|
|
efd39e1f80 | ||
|
|
93c72f561b | ||
|
|
689e8f216c | ||
|
|
4e4c3cfe64 | ||
|
|
e2112e0d83 | ||
|
|
44fca91089 | ||
|
|
4ae40cbd1e | ||
|
|
ea04aec0b4 | ||
|
|
b8e2717faf | ||
|
|
333d40ed4f | ||
|
|
cb3555f5ed | ||
|
|
7a8f70db02 | ||
|
|
1cf4f520f8 | ||
|
|
e11ce5d3d0 | ||
|
|
502950709c | ||
|
|
2c3e14d84c | ||
|
|
8e71afe158 | ||
|
|
44ea98fc41 | ||
|
|
5214fb9145 | ||
|
|
41e74ba9b5 | ||
|
|
03b616622e | ||
|
|
50e1da01fe | ||
|
|
0daa620b11 | ||
|
|
f8f50b26d8 | ||
|
|
a3bc133bcb | ||
|
|
50dae22ff4 | ||
|
|
5a91154eab | ||
|
|
d5b89d512a | ||
|
|
c4e4c62072 | ||
|
|
33b54ded4f | ||
|
|
0d2f5a818b | ||
|
|
8f217e6f01 | ||
|
|
835d7767cb | ||
|
|
8892a39160 | ||
|
|
db97871945 | ||
|
|
90254b92d2 | ||
|
|
eeda2104a6 | ||
|
|
97f43f88eb | ||
|
|
793da48f5c | ||
|
|
3177af9524 | ||
|
|
f99d22738c | ||
|
|
ea7124db52 | ||
|
|
b1247e5de6 | ||
|
|
5ab962634e | ||
|
|
d99944dcff | ||
|
|
84f7286460 | ||
|
|
920bcb06dc | ||
|
|
3679cc21ab | ||
|
|
07e5626469 | ||
|
|
6bb6f87800 | ||
|
|
cb6b52ff4c | ||
|
|
03c377dff3 | ||
|
|
d9c934d3eb | ||
|
|
0bc31b0f31 | ||
|
|
5a38c8dd2f | ||
|
|
0e3f172032 | ||
|
|
fd704419d9 | ||
|
|
32ac72f592 | ||
|
|
15f20ffaeb | ||
|
|
9c77b87435 | ||
|
|
d4d9e94f44 | ||
|
|
47cef2abf0 | ||
|
|
0a5cdf380f | ||
|
|
7f0e9b3289 | ||
|
|
9da7967158 | ||
|
|
964744a9ae | ||
|
|
779304d035 | ||
|
|
f15d7445bd | ||
|
|
26d68ba3fc | ||
|
|
9effff25df | ||
|
|
958805a1fd | ||
|
|
dd74c173a5 | ||
|
|
bd020c93f5 | ||
|
|
75555f0426 | ||
|
|
4e6b14a467 | ||
|
|
233de7fc37 | ||
|
|
c6e0230e5d | ||
|
|
ca2732668e | ||
|
|
456b76ac96 | ||
|
|
a2c58bea82 | ||
|
|
7276b3d5ef | ||
|
|
1ef8315462 | ||
|
|
2f88ac419b | ||
|
|
cee7c85780 | ||
|
|
b2c5205035 | ||
|
|
2be59c3eb6 | ||
|
|
176fc896ae | ||
|
|
a943555d86 | ||
|
|
58bbfbbc42 | ||
|
|
026b644630 | ||
|
|
1483b355af | ||
|
|
6a5bb635bb | ||
|
|
18241136e3 | ||
|
|
6fb6467b07 | ||
|
|
83f0d16904 | ||
|
|
0e0aca3844 | ||
|
|
97b52792ef | ||
|
|
0680c5ff9c | ||
|
|
f1f0158238 | ||
|
|
bca3617116 | ||
|
|
55ae674e0b | ||
|
|
46eb610d26 | ||
|
|
bfc2e9c7fb | ||
|
|
ffedd9eaf9 | ||
|
|
1512718d41 | ||
|
|
e566dcd0d9 | ||
|
|
43b8f8f31b | ||
|
|
768f700719 | ||
|
|
ab1faedadf | ||
|
|
fa325bd519 | ||
|
|
568876fc4f | ||
|
|
d506604918 | ||
|
|
7c9f79f243 | ||
|
|
a77d0ce5c0 | ||
|
|
55685e6854 | ||
|
|
4731b90899 | ||
|
|
f36527995e | ||
|
|
fd5cf98072 | ||
|
|
844e32a11e | ||
|
|
82cb0143f2 | ||
|
|
51e70347e4 | ||
|
|
075bdfdc23 | ||
|
|
e84edb3e30 | ||
|
|
2bde4656ed | ||
|
|
0b2cfc45c1 | ||
|
|
c8be41ce6c | ||
|
|
ec2f710d06 | ||
|
|
71112173b9 | ||
|
|
1615c20e66 | ||
|
|
fbcea12786 | ||
|
|
047d13afd1 | ||
|
|
4c1165533c | ||
|
|
14bdcf71fe | ||
|
|
1b3f022b2d | ||
|
|
b6d93a7f09 | ||
|
|
4b2438c298 | ||
|
|
3b79c8e09f | ||
|
|
d22ac6a7a4 | ||
|
|
2f50f5ef34 | ||
|
|
d7bbf2b8e2 | ||
|
|
b8df34309f | ||
|
|
278fa3c397 | ||
|
|
6f48ee98e5 | ||
|
|
14db59318c | ||
|
|
f9b3deaff2 | ||
|
|
c265e42220 | ||
|
|
c0bdf1551d | ||
|
|
e373eeeed3 | ||
|
|
774b0c104b | ||
|
|
fbd4d9a74e | ||
|
|
6c8859863a | ||
|
|
31ecd91f7c | ||
|
|
474255c985 | ||
|
|
652f9ceb5c | ||
|
|
49668d4ba9 | ||
|
|
acddaf07db | ||
|
|
8da26ab409 | ||
|
|
3f2fefe4f7 | ||
|
|
fb8513bc9f | ||
|
|
e4ec958edb | ||
|
|
948289a038 | ||
|
|
78ad88a245 | ||
|
|
99660aeee8 | ||
|
|
ce293ed891 | ||
|
|
2738613344 | ||
|
|
5d12d934b8 | ||
|
|
19a89645a1 | ||
|
|
65a412eb59 | ||
|
|
9df9673e84 | ||
|
|
2002ed1c9c | ||
|
|
9c98c6d0c4 | ||
|
|
488a1f6070 | ||
|
|
4d6600b0a3 | ||
|
|
dac0d15e78 | ||
|
|
1e12d937aa | ||
|
|
e643a44b59 | ||
|
|
7319870289 | ||
|
|
72c1ebf66d | ||
|
|
008c4a3ab7 | ||
|
|
38d3d11571 | ||
|
|
5b45751c67 | ||
|
|
c00d937f4c | ||
|
|
d4565acfe7 | ||
|
|
16129c53bd | ||
|
|
cbf1961510 | ||
|
|
c76a4d4be7 | ||
|
|
c33fe45d4c | ||
|
|
e355c643f4 | ||
|
|
fcccb7eb38 | ||
|
|
f9209406fb | ||
|
|
facbf6f529 | ||
|
|
27af0400c0 | ||
|
|
76a80ff034 | ||
|
|
f7f333ad52 | ||
|
|
94806ed45d | ||
|
|
baee94ed1d | ||
|
|
0c8358beaa | ||
|
|
62e9c32082 |
@@ -1 +1,2 @@
|
|||||||
github: [glanceapp]
|
github: [glanceapp]
|
||||||
|
patreon: glanceapp
|
||||||
|
|||||||
@@ -3,3 +3,5 @@
|
|||||||
/playground
|
/playground
|
||||||
/.idea
|
/.idea
|
||||||
/glance*.yml
|
/glance*.yml
|
||||||
|
*.yml.bak
|
||||||
|
*.yml.bak.*
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.23.6-alpine3.21 AS builder
|
FROM golang:1.24.3-alpine3.21 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . /app
|
COPY . /app
|
||||||
@@ -6,11 +6,10 @@ RUN CGO_ENABLED=0 go build .
|
|||||||
|
|
||||||
FROM alpine:3.21
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source=https://github.com/uhlwoogi/modern-glance
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/glance .
|
COPY --from=builder /app/glance .
|
||||||
|
|
||||||
HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
|
|
||||||
CMD wget --spider -q http://localhost:8080/api/healthz
|
|
||||||
|
|
||||||
EXPOSE 8080/tcp
|
EXPOSE 8080/tcp
|
||||||
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
||||||
|
|||||||
@@ -3,8 +3,5 @@ FROM alpine:3.21
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY glance .
|
COPY glance .
|
||||||
|
|
||||||
HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
|
|
||||||
CMD wget --spider -q http://localhost:8080/api/healthz
|
|
||||||
|
|
||||||
EXPOSE 8080/tcp
|
EXPOSE 8080/tcp
|
||||||
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
||||||
|
|||||||
@@ -1,405 +1,123 @@
|
|||||||
<p align="center"><em>What if you could see everything at a...</em></p>
|
<p align="center"><img src="docs/logo.png"></p>
|
||||||
<h1 align="center">Glance</h1>
|
<h1 align="center">modern-glance</h1>
|
||||||
<p align="center"><a href="#installation">Install</a> • <a href="docs/configuration.md">Configuration</a> • <a href="docs/preconfigured-pages.md">Preconfigured pages</a> • <a href="docs/themes.md">Themes</a> • <a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a></p>
|
<p align="center">
|
||||||
|
<a href="#installation">Install</a> •
|
||||||
|
<a href="#web-based-editor">Editor</a> •
|
||||||
|
<a href="docs/configuration.md#configuring-glance">Configuration</a> •
|
||||||
|
<a href="docs/themes.md">Themes</a>
|
||||||
|
</p>
|
||||||
|
|
||||||

|
<p align="center">A fork of <a href="https://github.com/glanceapp/glance">glanceapp/glance</a> that adds a web-based dashboard editor so you can manage your config without touching YAML.</p>
|
||||||
|
|
||||||
## Features
|
> **Upstream credit:** All core functionality — widgets, theming, layout engine, hot-reload — is the work of the [Glance](https://github.com/glanceapp/glance) project and its contributors. This fork adds an editing UI on top and nothing else. If you find Glance useful, consider [sponsoring the upstream project](https://github.com/sponsors/glanceapp).
|
||||||
### Various widgets
|
|
||||||
* RSS feeds
|
|
||||||
* Subreddit posts
|
|
||||||
* Hacker News posts
|
|
||||||
* Weather forecasts
|
|
||||||
* YouTube channel uploads
|
|
||||||
* Twitch channels
|
|
||||||
* Market prices
|
|
||||||
* Docker containers status
|
|
||||||
* Server stats
|
|
||||||
* Custom widgets
|
|
||||||
* [and many more...](docs/configuration.md)
|
|
||||||
|
|
||||||
### Fast and lightweight
|
> **⚠️ Vibe coded:** The editing UI in this fork was built entirely with AI assistance and has not been rigorously tested. It may have bugs, edge cases, or security issues — particularly around config file handling. Use it on a trusted network, keep backups, and don't be surprised if something breaks. PRs welcome.
|
||||||
* Low memory usage
|
|
||||||
* Few dependencies
|
|
||||||
* Minimal vanilla JS
|
|
||||||
* Single <20mb binary available for multiple OSs & architectures and just as small Docker container
|
|
||||||
* Uncached pages usually load within ~1s (depending on internet speed and number of widgets)
|
|
||||||
|
|
||||||
### Tons of customizability
|
---
|
||||||
* Different layouts
|
|
||||||
* As many pages/tabs as you need
|
|
||||||
* Numerous configuration options for each widget
|
|
||||||
* Multiple styles for some widgets
|
|
||||||
* Custom CSS
|
|
||||||
|
|
||||||
### Optimized for mobile devices
|
## What's added
|
||||||
Because you'll want to take it with you on the go.
|
|
||||||
|
|
||||||

|
### In-place edit mode
|
||||||
|
A pencil button in the dashboard header toggles edit mode. While active you can:
|
||||||
|
- **Drag and drop** widgets between columns and reorder them
|
||||||
|
- **Add widgets** via a form dialog (for most widget types) or a YAML editor fallback
|
||||||
|
- **Edit widgets** inline with per-field forms — includes live lookup for weather locations, market symbols, and RSS feed validation
|
||||||
|
- **Delete widgets**
|
||||||
|
- **Add, delete, resize, and reorder columns**
|
||||||
|
- **Add and manage head widgets** (the row above the columns)
|
||||||
|
|
||||||
### Themeable
|
### Page management (`/edit`)
|
||||||
Easily create your own theme by tweaking a few numbers or choose from one of the [already available themes](docs/themes.md).
|
- Add, delete, and reorder pages
|
||||||
|
- Per-page settings: name, slug, width, navigation options, mobile header
|
||||||
|
|
||||||

|
### Site settings (`/edit/site-settings`)
|
||||||
|
- App name, logo, favicon, footer — all editable without touching YAML
|
||||||
|
|
||||||
<br>
|
### Theme settings (`/edit/theme-settings`)
|
||||||
|
- Color pickers for background, primary, positive, and negative colors (hex input, stored as HSL)
|
||||||
|
- Contrast and text saturation multipliers
|
||||||
|
- Light mode toggle, hide-picker toggle, custom CSS file path
|
||||||
|
- **Preset management**: add, edit, and delete named theme presets with live preview swatches
|
||||||
|
- **Built-in theme catalog**: 14 themes from the upstream docs (Dracula, Catppuccin variants, Gruvbox, and more) importable with one click
|
||||||
|
|
||||||
## Configuration
|
### Multi-step undo
|
||||||
Configuration is done through YAML files, to learn more about how the layout works, how to add more pages and how to configure widgets, visit the [configuration documentation](docs/configuration.md).
|
`/edit` keeps a 10-step numbered backup chain (`glance.yml.bak.1` … `.bak.10`). Each save rotates the chain; any backup can be restored individually.
|
||||||
|
|
||||||
<details>
|
### Auth gate
|
||||||
<summary><strong>Preview example configuration file</strong></summary>
|
`/edit` returns 403 unless `auth.users` is configured **or** `admin.allow-without-auth: true` is set. See `docs/glance.yml` for examples of both.
|
||||||
<br>
|
|
||||||
|
|
||||||
```yaml
|
---
|
||||||
pages:
|
|
||||||
- name: Home
|
|
||||||
columns:
|
|
||||||
- size: small
|
|
||||||
widgets:
|
|
||||||
- type: calendar
|
|
||||||
first-day-of-week: monday
|
|
||||||
|
|
||||||
- type: rss
|
|
||||||
limit: 10
|
|
||||||
collapse-after: 3
|
|
||||||
cache: 12h
|
|
||||||
feeds:
|
|
||||||
- url: https://selfh.st/rss/
|
|
||||||
title: selfh.st
|
|
||||||
limit: 4
|
|
||||||
- url: https://ciechanow.ski/atom.xml
|
|
||||||
- url: https://www.joshwcomeau.com/rss.xml
|
|
||||||
title: Josh Comeau
|
|
||||||
- url: https://samwho.dev/rss.xml
|
|
||||||
- url: https://ishadeed.com/feed.xml
|
|
||||||
title: Ahmad Shadeed
|
|
||||||
|
|
||||||
- type: twitch-channels
|
|
||||||
channels:
|
|
||||||
- theprimeagen
|
|
||||||
- j_blow
|
|
||||||
- piratesoftware
|
|
||||||
- cohhcarnage
|
|
||||||
- christitustech
|
|
||||||
- EJ_SA
|
|
||||||
|
|
||||||
- size: full
|
|
||||||
widgets:
|
|
||||||
- type: group
|
|
||||||
widgets:
|
|
||||||
- type: hacker-news
|
|
||||||
- type: lobsters
|
|
||||||
|
|
||||||
- type: videos
|
|
||||||
channels:
|
|
||||||
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
|
|
||||||
- UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
|
|
||||||
- UCsBjURrPoezykLs9EqgamOA # Fireship
|
|
||||||
- UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
|
|
||||||
- UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
|
|
||||||
|
|
||||||
- type: group
|
|
||||||
widgets:
|
|
||||||
- type: reddit
|
|
||||||
subreddit: technology
|
|
||||||
show-thumbnails: true
|
|
||||||
- type: reddit
|
|
||||||
subreddit: selfhosted
|
|
||||||
show-thumbnails: true
|
|
||||||
|
|
||||||
- size: small
|
|
||||||
widgets:
|
|
||||||
- type: weather
|
|
||||||
location: London, United Kingdom
|
|
||||||
units: metric
|
|
||||||
hour-format: 12h
|
|
||||||
|
|
||||||
- type: markets
|
|
||||||
markets:
|
|
||||||
- symbol: SPY
|
|
||||||
name: S&P 500
|
|
||||||
- symbol: BTC-USD
|
|
||||||
name: Bitcoin
|
|
||||||
- symbol: NVDA
|
|
||||||
name: NVIDIA
|
|
||||||
- symbol: AAPL
|
|
||||||
name: Apple
|
|
||||||
- symbol: MSFT
|
|
||||||
name: Microsoft
|
|
||||||
|
|
||||||
- type: releases
|
|
||||||
cache: 1d
|
|
||||||
repositories:
|
|
||||||
- glanceapp/glance
|
|
||||||
- go-gitea/gitea
|
|
||||||
- immich-app/immich
|
|
||||||
- syncthing/syncthing
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Choose one of the following methods:
|
### Docker (recommended)
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Docker compose using provided directory structure (recommended)</strong></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Create a new directory called `glance` as well as the template files within it by running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir glance && cd glance && curl -sL https://github.com/glanceapp/docker-compose-template/archive/refs/heads/main.tar.gz | tar -xzf - --strip-components 2
|
|
||||||
```
|
|
||||||
|
|
||||||
*[click here to view the files that will be created](https://github.com/glanceapp/docker-compose-template/tree/main/root)*
|
|
||||||
|
|
||||||
Then, edit the following files as desired:
|
|
||||||
* `docker-compose.yml` to configure the port, volumes and other containery things
|
|
||||||
* `config/home.yml` to configure the widgets or layout of the home page
|
|
||||||
* `config/glance.yml` if you want to change the theme or add more pages
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Other files you may want to edit</summary>
|
|
||||||
|
|
||||||
* `.env` to configure environment variables that will be available inside configuration files
|
|
||||||
* `assets/user.css` to add custom CSS
|
|
||||||
</details>
|
|
||||||
|
|
||||||
When ready, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
If you encounter any issues, you can check the logs by running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose logs
|
|
||||||
```
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Docker compose manual</strong></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Create a `docker-compose.yml` file with the following contents:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
services:
|
services:
|
||||||
glance:
|
glance:
|
||||||
container_name: glance
|
image: ghcr.io/uhlwoogi/modern-glance:latest
|
||||||
image: glanceapp/glance
|
|
||||||
volumes:
|
|
||||||
- ./config:/app/config
|
|
||||||
ports:
|
ports:
|
||||||
- 8080:8080
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
# Config dir — glance.yml lives here and is written by /edit
|
||||||
|
- ./config:/app/config
|
||||||
|
# Optional: custom CSS / images (set server.assets-path: /app/assets in glance.yml)
|
||||||
|
- ./assets:/app/assets
|
||||||
|
restart: unless-stopped
|
||||||
|
# environment:
|
||||||
|
# MY_SECRET_TOKEN: abc123 # reference as ${env:MY_SECRET_TOKEN} in glance.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, create a new directory called `config` and download the example starting [`glance.yml`](https://github.com/glanceapp/glance/blob/main/docs/glance.yml) file into it by running:
|
1. Create the config directory and grab the starter config:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir config && wget -O config/glance.yml https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
|
mkdir -p config assets
|
||||||
|
curl -o config/glance.yml https://raw.githubusercontent.com/uhlwoogi/modern-glance/main/docs/glance.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Feel free to edit the `glance.yml` file to your liking, and when ready run:
|
2. Start:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
If you encounter any issues, you can check the logs by running:
|
3. Open `http://localhost:8080` — click the **pencil icon** in the header to start editing.
|
||||||
|
|
||||||
|
> The starter `glance.yml` has `admin.allow-without-auth: true` so `/edit` is open by default. Switch to the auth block in the file before exposing to a network you don't fully trust.
|
||||||
|
|
||||||
|
### Updating
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker logs glance
|
docker compose pull && docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
<hr>
|
---
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
## Original Glance features
|
||||||
<summary><strong>Manual binary installation</strong></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Precompiled binaries are available for Linux, Windows and macOS (x86, x86_64, ARM and ARM64 architectures).
|
Everything from upstream is intact:
|
||||||
|
|
||||||
### Linux
|
- **Widgets**: RSS, Reddit, Hacker News, weather, YouTube, Twitch, markets, Docker containers, server stats, bookmarks, calendar, monitors, and [many more](docs/configuration.md#configuring-glance)
|
||||||
|
- **Fast and lightweight**: low memory, minimal JS, single ~20 MB binary, pages load in ~1s
|
||||||
|
- **Highly customizable**: multiple layouts, pages, themes, custom CSS
|
||||||
|
- **Mobile optimized**
|
||||||
|
- **Themeable**: tweak a few numbers or pick from the [theme gallery](docs/themes.md)
|
||||||
|
|
||||||
Visit the [latest release page](https://github.com/glanceapp/glance/releases/latest) for available binaries. You can place the binary in `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). By default, when running the binary, it will look for a `glance.yml` file in the directory it's placed in. To specify a different path for the config file, use the `--config` option:
|
Full configuration reference: [docs/configuration.md](docs/configuration.md#configuring-glance)
|
||||||
|
|
||||||
```bash
|
---
|
||||||
/opt/glance/glance --config /etc/glance.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
To grab a starting template for the config file, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wget https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
Download and extract the executable from the [latest release](https://github.com/glanceapp/glance/releases/latest) (most likely the file called `glance-windows-amd64.zip` if you're on a 64-bit system) and place it in a folder of your choice. Then, create a new text file called `glance.yml` in the same folder and paste the content from [here](https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml) in it. You should then be able to run the executable and access the dashboard by visiting `http://localhost:8080` in your browser.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Other</strong></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Glance can also be installed through the following 3rd party channels:
|
|
||||||
* [Proxmox VE Helper Script](https://community-scripts.github.io/ProxmoxVE/scripts?id=glance)
|
|
||||||
* [NixOS package](https://search.nixos.org/packages?channel=unstable&show=glance)
|
|
||||||
* [Coolify.io](https://coolify.io/docs/services/glance/)
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
## Building from source
|
## Building from source
|
||||||
|
|
||||||
Choose one of the following methods:
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Build binary with Go</strong></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Requirements: [Go](https://go.dev/dl/) >= v1.23
|
|
||||||
|
|
||||||
To build the project for your current OS and architecture, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go build -o build/glance .
|
|
||||||
```
|
|
||||||
|
|
||||||
To build for a specific OS and architecture, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOS=linux GOARCH=amd64 go build -o build/glance .
|
|
||||||
```
|
|
||||||
|
|
||||||
[*click here for a full list of GOOS and GOARCH combinations*](https://go.dev/doc/install/source#:~:text=$GOOS%20and%20$GOARCH)
|
|
||||||
|
|
||||||
Alternatively, if you just want to run the app without creating a binary, like when you're testing out changes, you can run:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Run locally
|
||||||
go run .
|
go run .
|
||||||
```
|
|
||||||
<hr>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
# Build binary
|
||||||
<summary><strong>Build project and Docker image with Docker</strong></summary>
|
go build -o glance .
|
||||||
<br>
|
|
||||||
|
|
||||||
Requirements: [Docker](https://docs.docker.com/engine/install/)
|
# Build and push Docker image
|
||||||
|
docker build -t ghcr.io/uhlwoogi/modern-glance:latest .
|
||||||
To build the project and image using just Docker, run:
|
docker push ghcr.io/uhlwoogi/modern-glance:latest
|
||||||
|
|
||||||
*(replace `owner` with your name or organization)*
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t owner/glance:latest .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If you wish to push the image to a registry (by default Docker Hub), run:
|
Requires Go ≥ 1.23.
|
||||||
|
|
||||||
```bash
|
|
||||||
docker push owner/glance:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
<details>
|
|
||||||
<summary><strong>Does the information on the page update automatically?</strong></summary>
|
|
||||||
No, a page refresh is required to update the information. Some things do dynamically update where it makes sense, like the clock widget and the relative time showing how long ago something happened.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>How frequently do widgets update?</strong></summary>
|
|
||||||
No requests are made periodically in the background, information is only fetched upon loading the page and then cached. The default cache lifetime is different for each widget and can be configured.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Can I create my own widgets?</strong></summary>
|
|
||||||
|
|
||||||
Yes, there are multiple ways to create custom widgets:
|
|
||||||
* `iframe` widget - allows you to embed things from other websites
|
|
||||||
* `html` widget - allows you to insert your own static HTML
|
|
||||||
* `extension` widget - fetch HTML from a URL
|
|
||||||
* `custom-api` widget - fetch JSON from a URL and render it using custom HTML
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Can I change the title of a widget?</strong></summary>
|
|
||||||
|
|
||||||
Yes, the title of all widgets can be changed by specifying the `title` property in the widget's configuration:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- type: rss
|
|
||||||
title: My custom title
|
|
||||||
|
|
||||||
- type: markets
|
|
||||||
title: My custom title
|
|
||||||
|
|
||||||
- type: videos
|
|
||||||
title: My custom title
|
|
||||||
|
|
||||||
# and so on for all widgets...
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
## Feature requests
|
|
||||||
|
|
||||||
New feature suggestions are always welcome and will be considered, though please keep in mind that some of them may be out of scope for what the project is trying to achieve (or is reasonably capable of). If you have an idea for a new feature and would like to share it, you can do so [here](https://github.com/glanceapp/glance/issues/new?template=feature_request.yml).
|
|
||||||
|
|
||||||
Feature requests are tagged with one of the following:
|
|
||||||
|
|
||||||
* [Roadmap](https://github.com/glanceapp/glance/labels/roadmap) - will be implemented in a future release
|
|
||||||
* [Backlog](https://github.com/glanceapp/glance/labels/backlog) - may be implemented in the future but needs further feedback or interest from the community
|
|
||||||
* [Icebox](https://github.com/glanceapp/glance/labels/icebox) - no plans to implement as it doesn't currently align with the project's goals or capabilities, may be revised at a later date
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
## Contributing guidelines
|
|
||||||
|
|
||||||
* Before working on a new feature it's preferable to submit a feature request first and state that you'd like to implement it yourself
|
|
||||||
* Please don't submit PRs for feature requests that are either in the roadmap<sup>[1]</sup>, backlog<sup>[2]</sup> or icebox<sup>[3]</sup>
|
|
||||||
* Use `dev` for the base branch if you're adding new features or fixing bugs, otherwise use `main`
|
|
||||||
* Avoid introducing new dependencies
|
|
||||||
* Avoid making backwards-incompatible configuration changes
|
|
||||||
* Avoid introducing new colors or hard-coding colors, use the standard `primary`, `positive` and `negative`
|
|
||||||
* For icons, try to use [heroicons](https://heroicons.com/) where applicable
|
|
||||||
* Provide a screenshot of the changes if UI related where possible
|
|
||||||
* No `package.json`
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong><sup>[1] [2] [3]</sup></strong></summary>
|
|
||||||
|
|
||||||
[1] The feature likely already has work put into it that may conflict with your implementation
|
|
||||||
|
|
||||||
[2] The demand, implementation or functionality for this feature is not yet clear
|
|
||||||
|
|
||||||
[3] No plans to add this feature for the time being
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
## Thank you
|
|
||||||
|
|
||||||
To all the people who were generous enough to [sponsor](https://github.com/sponsors/glanceapp) the project and to everyone who has contributed in any way, be it PRs, submitting issues, helping others in the discussions or Discord server, creating guides and tools or just mentioning Glance on social media. Your support is greatly appreciated and helps keep the project going.
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
glance:
|
||||||
|
image: ghcr.io/uhlwoogi/modern-glance:latest
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
# Config directory: glance.yml lives here and is written by the /edit UI.
|
||||||
|
# Copy docs/glance.yml → ./config/glance.yml to get started.
|
||||||
|
- ./config:/app/config
|
||||||
|
# Assets directory: custom CSS, images, icons referenced by server.assets-path.
|
||||||
|
# Set `server: { assets-path: /app/assets }` in glance.yml to enable.
|
||||||
|
- ./assets:/app/assets
|
||||||
|
restart: unless-stopped
|
||||||
|
# Uncomment to inject secrets as environment variables.
|
||||||
|
# Reference them in glance.yml as ${env:MY_SECRET_TOKEN}.
|
||||||
|
# environment:
|
||||||
|
# MY_SECRET_TOKEN: abc123
|
||||||
@@ -226,10 +226,10 @@ JSON response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Calculations can be performed, however all numbers must be converted to floats first if they are not already:
|
Calculations can be performed on either ints or floats. If both numbers are ints, an int will be returned, otherwise a float will be returned. If you try to divide by zero, 0 will be returned. If you provide non-numeric values, `NaN` will be returned.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<div>{{ sub (.JSON.Int "price" | toFloat) (.JSON.Int "discount" | toFloat) }}</div>
|
<div>{{ sub (.JSON.Int "price") (.JSON.Int "discount") }}</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
Output:
|
Output:
|
||||||
@@ -238,7 +238,58 @@ Output:
|
|||||||
<div>90</div>
|
<div>90</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
Other operations include `add`, `mul`, and `div`.
|
Other operations include `add`, `mul`, `div` and `mod`.
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
JSON response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"title": "Exploring the Depths of Quantum Computing",
|
||||||
|
"date": "2023-10-27T10:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "A Beginner's Guide to Sustainable Living",
|
||||||
|
"date": "2023-11-15T14:30:00+01:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "The Art of Baking Sourdough Bread",
|
||||||
|
"date": "2023-12-03T08:45:22-08:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To parse the date and display the relative time (e.g. 2h, 1d, etc), you would use the following:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{ range .JSON.Array "posts" }}
|
||||||
|
<div>{{ .String "title" }}</div>
|
||||||
|
<div {{ .String "date" | parseTime "rfc3339" | toRelativeTime }}></div>
|
||||||
|
{{ end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `parseTime` function takes two arguments: the layout of the date string and the date string itself. The layout can be one of the following: "RFC3339", "RFC3339Nano", "DateTime", "DateOnly", "TimeOnly" or a custom layout in Go's [date format](https://pkg.go.dev/time#pkg-constants).
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div>Exploring the Depths of Quantum Computing</div>
|
||||||
|
<div data-dynamic-relative-time="1698400800"></div>
|
||||||
|
|
||||||
|
<div>A Beginner's Guide to Sustainable Living</div>
|
||||||
|
<div data-dynamic-relative-time="1700055000"></div>
|
||||||
|
|
||||||
|
<div>The Art of Baking Sourdough Bread</div>
|
||||||
|
<div data-dynamic-relative-time="1701621922"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
You don't have to worry about the internal implementation, this will then be dynamically populated by Glance on the client side to show the correct relative time.
|
||||||
|
|
||||||
|
The important thing to notice here is that the return value of `toRelativeTime` must be used as an attribute in an HTML tag, be it a `div`, `li`, `span`, etc.
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
@@ -258,6 +309,101 @@ You can also access the response headers:
|
|||||||
<div>{{ .Response.Header.Get "Content-Type" }}</div>
|
<div>{{ .Response.Header.Get "Content-Type" }}</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
JSON response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name": "Steve", "age": 30}
|
||||||
|
{"name": "Alex", "age": 25}
|
||||||
|
{"name": "John", "age": 35}
|
||||||
|
```
|
||||||
|
|
||||||
|
The above format is "[ndjson](https://docs.mulesoft.com/dataweave/latest/dataweave-formats-ndjson)" or "[JSON Lines](https://jsonlines.org/)", where each line is a separate JSON object. To parse this format, you must first disable the JSON validation check in your config, since by default the response is expected to be a single valid JSON object:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: custom-api
|
||||||
|
skip-json-validation: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, to iterate over each object you can use `.JSONLines`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
{{ range .JSONLines }}
|
||||||
|
<p>{{ .String "name" }} is {{ .Int "age" }} years old</p>
|
||||||
|
{{ end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<p>Steve is 30 years old</p>
|
||||||
|
<p>Alex is 25 years old</p>
|
||||||
|
<p>John is 35 years old</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
For other ways of selecting data from a JSON Lines response, have a look at the docs for [tidwall/gjson](https://github.com/tidwall/gjson/tree/master?tab=readme-ov-file#json-lines). For example, to get an array of all names, you can use the following:
|
||||||
|
|
||||||
|
```html
|
||||||
|
{{ range .JSON.Array "..#.name" }}
|
||||||
|
<p>{{ .String "" }}</p>
|
||||||
|
{{ end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<p>Steve</p>
|
||||||
|
<p>Alex</p>
|
||||||
|
<p>John</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
In some instances, you may need to make two consecutive API calls, where you use the result of the first call in the second call. To achieve this, you can make additional HTTP requests from within the template itself using the following syntax:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: custom-api
|
||||||
|
url: https://api.example.com/get-id-of-something
|
||||||
|
template: |
|
||||||
|
{{ $theID := .JSON.String "id" }}
|
||||||
|
|
||||||
|
{{
|
||||||
|
$something := newRequest (concat "https://api.example.com/something/" $theID)
|
||||||
|
| withParameter "key" "value"
|
||||||
|
| withHeader "Authorization" "Bearer token"
|
||||||
|
| getResponse
|
||||||
|
}}
|
||||||
|
|
||||||
|
{{ $something.JSON.String "title" }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Here, `$theID` gets retrieved from the result of the first API call and used in the second API call. The `newRequest` function creates a new request, and the `getResponse` function executes it. You can also use `withParameter` and `withHeader` to optionally add parameters and headers to the request.
|
||||||
|
|
||||||
|
If you need to make a request to a URL that requires dynamic parameters, you can omit the `url` property in the YAML and run the request entirely from within the template itself:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: custom-api
|
||||||
|
title: Events from the last 24h
|
||||||
|
template: |
|
||||||
|
{{
|
||||||
|
$events := newRequest "https://api.example.com/events"
|
||||||
|
| withParameter "after" (offsetNow "-24h" | formatTime "rfc3339")
|
||||||
|
| getResponse
|
||||||
|
}}
|
||||||
|
|
||||||
|
{{ if eq $events.Response.StatusCode 200 }}
|
||||||
|
{{ range $events.JSON.Array "events" }}
|
||||||
|
<div>{{ .String "title" }}</div>
|
||||||
|
<div {{ .String "date" | parseTime "rfc3339" | toRelativeTime }}></div>
|
||||||
|
{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
<p>Failed to fetch data: {{ $events.Response.Status }}</p>
|
||||||
|
{{ end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
*Note that you need to manually check for the correct status code.*
|
||||||
|
|
||||||
## Functions
|
## Functions
|
||||||
|
|
||||||
The following functions are available on the `JSON` object:
|
The following functions are available on the `JSON` object:
|
||||||
@@ -269,27 +415,60 @@ The following functions are available on the `JSON` object:
|
|||||||
- `Array(key string) []JSON`: Returns the value of the key as an array of `JSON` objects.
|
- `Array(key string) []JSON`: Returns the value of the key as an array of `JSON` objects.
|
||||||
- `Exists(key string) bool`: Returns true if the key exists in the JSON object.
|
- `Exists(key string) bool`: Returns true if the key exists in the JSON object.
|
||||||
|
|
||||||
|
The following functions are available on the `Options` object:
|
||||||
|
|
||||||
|
- `StringOr(key string, default string) string`: Returns the value of the key as a string, or the default value if the key does not exist.
|
||||||
|
- `IntOr(key string, default int) int`: Returns the value of the key as an integer, or the default value if the key does not exist.
|
||||||
|
- `FloatOr(key string, default float) float`: Returns the value of the key as a float, or the default value if the key does not exist.
|
||||||
|
- `BoolOr(key string, default bool) bool`: Returns the value of the key as a boolean, or the default value if the key does not exist.
|
||||||
|
- `JSON(key string) JSON`: Returns the value of the key as a stringified `JSON` object, or throws an error if the key does not exist.
|
||||||
|
|
||||||
The following helper functions provided by Glance are available:
|
The following helper functions provided by Glance are available:
|
||||||
|
|
||||||
- `toFloat(i int) float`: Converts an integer to a float.
|
- `toFloat(i int) float`: Converts an integer to a float.
|
||||||
- `toInt(f float) int`: Converts a float to an integer.
|
- `toInt(f float) int`: Converts a float to an integer.
|
||||||
|
- `toRelativeTime(t time.Time) template.HTMLAttr`: Converts Time to a relative time such as 2h, 1d, etc which dynamically updates. **NOTE:** the value of this function should be used as an attribute in an HTML tag, e.g. `<span {{ toRelativeTime .Time }}></span>`.
|
||||||
|
- `now() time.Time`: Returns the current time.
|
||||||
|
- `offsetNow(offset string) time.Time`: Returns the current time with an offset. The offset can be positive or negative and must be in the format "3h" "-1h" or "2h30m10s".
|
||||||
|
- `duration(str string) time.Duration`: Parses a string such as `1h`, `24h`, `5h30m`, etc into a `time.Duration`.
|
||||||
|
- `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly".
|
||||||
|
- `formatTime(layout string, s string) time.Time`: Formats a `time.Time` into a string. The layout uses the same format as `parseTime`.
|
||||||
|
- `parseLocalTime(layout string, s string) time.Time`: Same as the above, except in the absence of a timezone, it will use the local timezone instead of UTC.
|
||||||
|
- `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`.
|
||||||
- `add(a, b float) float`: Adds two numbers.
|
- `add(a, b float) float`: Adds two numbers.
|
||||||
- `sub(a, b float) float`: Subtracts two numbers.
|
- `sub(a, b float) float`: Subtracts two numbers.
|
||||||
- `mul(a, b float) float`: Multiplies two numbers.
|
- `mul(a, b float) float`: Multiplies two numbers.
|
||||||
- `div(a, b float) float`: Divides two numbers.
|
- `div(a, b float) float`: Divides two numbers.
|
||||||
|
- `mod(a, b int) int`: Remainder after dividing a by b (a % b).
|
||||||
- `formatApproxNumber(n int) string`: Formats a number to be more human-readable, e.g. 1000 -> 1k.
|
- `formatApproxNumber(n int) string`: Formats a number to be more human-readable, e.g. 1000 -> 1k.
|
||||||
- `formatNumber(n float|int) string`: Formats a number with commas, e.g. 1000 -> 1,000.
|
- `formatNumber(n float|int) string`: Formats a number with commas, e.g. 1000 -> 1,000.
|
||||||
|
- `trimPrefix(prefix string, str string) string`: Trims the prefix from a string.
|
||||||
|
- `trimSuffix(suffix string, str string) string`: Trims the suffix from a string.
|
||||||
|
- `trimSpace(str string) string`: Trims whitespace from a string on both ends.
|
||||||
|
- `replaceAll(old string, new string, str string) string`: Replaces all occurrences of a string in a string.
|
||||||
|
- `replaceMatches(pattern string, replacement string, str string) string`: Replaces all occurrences of a regular expression in a string.
|
||||||
|
- `findMatch(pattern string, str string) string`: Finds the first match of a regular expression in a string.
|
||||||
|
- `findSubmatch(pattern string, str string) string`: Finds the first submatch of a regular expression in a string.
|
||||||
|
- `sortByString(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a string key in either ascending or descending order.
|
||||||
|
- `sortByInt(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by an integer key in either ascending or descending order.
|
||||||
|
- `sortByFloat(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a float key in either ascending or descending order.
|
||||||
|
- `sortByTime(key string, layout string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a time key in either ascending or descending order. The format must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants).
|
||||||
|
- `concat(strings ...string) string`: Concatenates multiple strings together.
|
||||||
|
- `unique(key string, arr []JSON) []JSON`: Returns a unique array of JSON objects based on the given key.
|
||||||
|
- `percentChange(current float, previous float) float`: Calculates the percentage change between two numbers.
|
||||||
|
- `startOfDay(t time.Time) time.Time`: Returns the start of the day for a given time.
|
||||||
|
- `endOfDay(t time.Time) time.Time`: Returns the end of the day for a given time.
|
||||||
|
|
||||||
The following helper functions provided by Go's `text/template` are available:
|
The following helper functions provided by Go's `text/template` are available:
|
||||||
|
|
||||||
- `eq(a, b any) bool`: Compares two values for equality.
|
- `eq(a, b any) bool`: Compares two values for equality.
|
||||||
- `ne(a, b any) bool`: Compares two values for inequality.
|
- `ne(a, b any) bool`: Compares two values for inequality.
|
||||||
- `lt(a, b any) bool`: Compares two values for less than.
|
- `lt(a, b any) bool`: Compares two values for less than.
|
||||||
- `lte(a, b any) bool`: Compares two values for less than or equal to.
|
- `le(a, b any) bool`: Compares two values for less than or equal to.
|
||||||
- `gt(a, b any) bool`: Compares two values for greater than.
|
- `gt(a, b any) bool`: Compares two values for greater than.
|
||||||
- `gte(a, b any) bool`: Compares two values for greater than or equal to.
|
- `ge(a, b any) bool`: Compares two values for greater than or equal to.
|
||||||
- `and(a, b bool) bool`: Returns true if both values are true.
|
- `and(args ...bool) bool`: Returns true if **all** arguments are true; accepts two or more boolean values.
|
||||||
- `or(a, b bool) bool`: Returns true if either value is true.
|
- `or(args ...bool) bool`: Returns true if **any** argument is true; accepts two or more boolean values.
|
||||||
- `not(a bool) bool`: Returns the opposite of the value.
|
- `not(a bool) bool`: Returns the opposite of the value.
|
||||||
- `index(a any, b int) any`: Returns the value at the specified index of an array.
|
- `index(a any, b int) any`: Returns the value at the specified index of an array.
|
||||||
- `len(a any) int`: Returns the length of an array.
|
- `len(a any) int`: Returns the length of an array.
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ If you know how to setup an HTTP server and a bit of HTML and CSS you're ready t
|
|||||||
### `Widget-Title`
|
### `Widget-Title`
|
||||||
Used to specify the title of the widget. If not provided, the widget's title will be "Extension".
|
Used to specify the title of the widget. If not provided, the widget's title will be "Extension".
|
||||||
|
|
||||||
|
### `Widget-Title-URL`
|
||||||
|
Used to specify the URL that will be opened when the widget's title is clicked. If the user has specified a `title-url` in their config, it will take precedence over this header.
|
||||||
|
|
||||||
### `Widget-Content-Type`
|
### `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.
|
Used to specify the content type that will be returned by the extension. If not provided, the content will be shown as plain text.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
|
# --- Edit access ---
|
||||||
|
# /edit is locked down by default. Pick ONE of the two blocks below.
|
||||||
|
#
|
||||||
|
# Option A (recommended): require login.
|
||||||
|
# Generate values with:
|
||||||
|
# go run . secret:make
|
||||||
|
# go run . password:hash <your-password>
|
||||||
|
#
|
||||||
|
# auth:
|
||||||
|
# secret-key: <paste base64 secret here>
|
||||||
|
# users:
|
||||||
|
# yourname:
|
||||||
|
# password-hash: <paste bcrypt hash here>
|
||||||
|
#
|
||||||
|
# Option B: open /edit without authentication. ONLY on a trusted network —
|
||||||
|
# anyone who can reach this server can rewrite your config.
|
||||||
|
# (The config key is still named `admin` for backward compatibility.)
|
||||||
|
#
|
||||||
|
admin:
|
||||||
|
allow-without-auth: true
|
||||||
pages:
|
pages:
|
||||||
- name: Home
|
- name: Home
|
||||||
# Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look
|
# Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look
|
||||||
@@ -7,46 +27,33 @@ pages:
|
|||||||
widgets:
|
widgets:
|
||||||
- type: calendar
|
- type: calendar
|
||||||
first-day-of-week: monday
|
first-day-of-week: monday
|
||||||
|
- type: group
|
||||||
|
widgets:
|
||||||
|
- type: hacker-news
|
||||||
|
- size: full
|
||||||
|
widgets:
|
||||||
- type: rss
|
- type: rss
|
||||||
limit: 10
|
limit: 10
|
||||||
collapse-after: 3
|
collapse-after: 3
|
||||||
cache: 12h
|
cache: 12h
|
||||||
feeds:
|
feeds:
|
||||||
- url: https://selfh.st/rss/
|
- url: https://www.techmeme.com/feed.xml
|
||||||
title: selfh.st
|
title: TechMeme
|
||||||
limit: 4
|
- size: small
|
||||||
- url: https://ciechanow.ski/atom.xml
|
|
||||||
- url: https://www.joshwcomeau.com/rss.xml
|
|
||||||
title: Josh Comeau
|
|
||||||
- url: https://samwho.dev/rss.xml
|
|
||||||
- url: https://ishadeed.com/feed.xml
|
|
||||||
title: Ahmad Shadeed
|
|
||||||
|
|
||||||
- type: twitch-channels
|
|
||||||
channels:
|
|
||||||
- theprimeagen
|
|
||||||
- j_blow
|
|
||||||
- piratesoftware
|
|
||||||
- cohhcarnage
|
|
||||||
- christitustech
|
|
||||||
- EJ_SA
|
|
||||||
|
|
||||||
- size: full
|
|
||||||
widgets:
|
widgets:
|
||||||
- type: group
|
- type: markets
|
||||||
widgets:
|
markets:
|
||||||
- type: hacker-news
|
- symbol: AMZN
|
||||||
- type: lobsters
|
name: Amazon.com, Inc.
|
||||||
|
- type: weather
|
||||||
- type: videos
|
location: Houston, US
|
||||||
channels:
|
units: imperial
|
||||||
- UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips
|
hour-format: 12h
|
||||||
- UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling
|
# Optionally hide the location from being displayed in the widget
|
||||||
- UCsBjURrPoezykLs9EqgamOA # Fireship
|
# hide-location: true
|
||||||
- UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee
|
|
||||||
- UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium
|
|
||||||
|
|
||||||
|
hide-location: true
|
||||||
|
show-area-name: false
|
||||||
- type: group
|
- type: group
|
||||||
widgets:
|
widgets:
|
||||||
- type: reddit
|
- type: reddit
|
||||||
@@ -56,42 +63,6 @@ pages:
|
|||||||
subreddit: selfhosted
|
subreddit: selfhosted
|
||||||
show-thumbnails: true
|
show-thumbnails: true
|
||||||
|
|
||||||
- size: small
|
|
||||||
widgets:
|
|
||||||
- type: weather
|
|
||||||
location: London, United Kingdom
|
|
||||||
units: metric # alternatively "imperial"
|
|
||||||
hour-format: 12h # alternatively "24h"
|
|
||||||
# Optionally hide the location from being displayed in the widget
|
|
||||||
# hide-location: true
|
|
||||||
|
|
||||||
- type: markets
|
|
||||||
# The link to go to when clicking on the symbol in the UI,
|
|
||||||
# {SYMBOL} will be substituded with the symbol for each market
|
|
||||||
symbol-link-template: https://www.tradingview.com/symbols/{SYMBOL}/news
|
|
||||||
markets:
|
|
||||||
- symbol: SPY
|
|
||||||
name: S&P 500
|
|
||||||
- symbol: BTC-USD
|
|
||||||
name: Bitcoin
|
|
||||||
- symbol: NVDA
|
|
||||||
name: NVIDIA
|
|
||||||
- symbol: AAPL
|
|
||||||
name: Apple
|
|
||||||
- symbol: MSFT
|
|
||||||
name: Microsoft
|
|
||||||
|
|
||||||
- type: releases
|
|
||||||
cache: 1d
|
|
||||||
# Without authentication the Github API allows for up to 60 requests per hour. You can create a
|
|
||||||
# read-only token from your Github account settings and use it here to increase the limit.
|
|
||||||
# token: ...
|
|
||||||
repositories:
|
|
||||||
- glanceapp/glance
|
|
||||||
- go-gitea/gitea
|
|
||||||
- immich-app/immich
|
|
||||||
- syncthing/syncthing
|
|
||||||
|
|
||||||
# Add more pages here:
|
# Add more pages here:
|
||||||
# - name: Your page name
|
# - name: Your page name
|
||||||
# columns:
|
# columns:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 792 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -53,6 +53,16 @@ theme:
|
|||||||
primary-color: 97 13 80
|
primary-color: 97 13 80
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Gruvbox Dark
|
||||||
|

|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
background-color: 0 0 16
|
||||||
|
primary-color: 43 59 81
|
||||||
|
positive-color: 61 66 44
|
||||||
|
negative-color: 6 96 59
|
||||||
|
```
|
||||||
|
|
||||||
### Kanagawa Dark
|
### Kanagawa Dark
|
||||||

|

|
||||||
```yaml
|
```yaml
|
||||||
@@ -72,6 +82,39 @@ theme:
|
|||||||
negative-color: 209 88 54
|
negative-color: 209 88 54
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Dracula
|
||||||
|

|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
background-color: 231 15 21
|
||||||
|
primary-color: 265 89 79
|
||||||
|
contrast-multiplier: 1.2
|
||||||
|
positive-color: 135 94 66
|
||||||
|
negative-color: 0 100 67
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shades of Purple
|
||||||
|

|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
background-color: 243 33 25
|
||||||
|
contrast-multiplier: 1.2
|
||||||
|
primary-color: 50 100 49
|
||||||
|
positive-color: 98 82 71
|
||||||
|
negative-color: 12 77 52
|
||||||
|
```
|
||||||
|
|
||||||
|
### Neon Pink
|
||||||
|

|
||||||
|
```yaml
|
||||||
|
theme:
|
||||||
|
background-color: 240 27 11
|
||||||
|
contrast-multiplier: 1.5
|
||||||
|
primary-color: 321 100 71
|
||||||
|
positive-color: 165 78 51
|
||||||
|
negative-color: 360 100 71
|
||||||
|
```
|
||||||
|
|
||||||
## Light
|
## Light
|
||||||
|
|
||||||
### Catppuccin Latte
|
### Catppuccin Latte
|
||||||
|
|||||||
@@ -1,32 +1,33 @@
|
|||||||
module github.com/glanceapp/glance
|
module github.com/glanceapp/glance
|
||||||
|
|
||||||
go 1.23.6
|
go 1.24.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/fsnotify/fsnotify v1.8.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/mmcdole/gofeed v1.3.0
|
github.com/mmcdole/gofeed v1.3.0
|
||||||
github.com/shirou/gopsutil/v4 v4.25.1
|
github.com/shirou/gopsutil/v4 v4.25.4
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
golang.org/x/text v0.22.0
|
golang.org/x/crypto v0.38.0
|
||||||
|
golang.org/x/text v0.25.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.10.1 // indirect
|
github.com/PuerkitoBio/goquery v1.10.3 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
github.com/ebitengine/purego v0.8.2 // indirect
|
github.com/ebitengine/purego v0.8.4 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
|
||||||
github.com/mmcdole/goxpp v1.1.1 // 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
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
github.com/tklauser/numcpus v0.9.0 // indirect
|
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/net v0.34.0 // indirect
|
golang.org/x/net v0.40.0 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||||
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||||
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 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/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
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/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
||||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
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 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
|
github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
|
||||||
@@ -32,8 +33,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
|
github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
|
github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
|
||||||
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=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
@@ -45,10 +46,10 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT
|
|||||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
|
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||||
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
|
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||||
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
|
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
|
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
@@ -58,6 +59,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
|||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
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/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
@@ -72,8 +75,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
|||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
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.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/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -95,8 +98,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
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/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -115,8 +118,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
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.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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
// widgetFieldSchema describes one editable field on a widget for the dialog
|
||||||
|
// form generator. Marshaled to JSON and consumed by edit-mode.js.
|
||||||
|
type widgetFieldSchema struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Type string `json:"type"` // string | multiline | number | boolean | select | list-strings | list-objects
|
||||||
|
Help string `json:"help,omitempty"`
|
||||||
|
Required bool `json:"required,omitempty"`
|
||||||
|
Options []string `json:"options,omitempty"`
|
||||||
|
Items []widgetFieldSchema `json:"items,omitempty"` // for list-objects
|
||||||
|
Validator string `json:"validator,omitempty"`
|
||||||
|
Lookup string `json:"lookup,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// widgetSchemas maps widget type to the form fields shown in the dialog
|
||||||
|
// editor. Widgets not listed here fall back to the textarea YAML editor.
|
||||||
|
// Keep field order — that's the order the dialog renders them.
|
||||||
|
var widgetSchemas = map[string][]widgetFieldSchema{
|
||||||
|
"rss": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string", Help: "Override the widget header. Leave blank for the default."},
|
||||||
|
{Key: "feeds", Label: "Feeds", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
|
||||||
|
{Key: "url", Label: "Feed URL", Type: "string", Required: true, Validator: "rss-feed", Help: "Tested when you save."},
|
||||||
|
{Key: "title", Label: "Custom title (optional)", Type: "string", Help: "Defaults to the title from the feed itself."},
|
||||||
|
}},
|
||||||
|
{Key: "limit", Label: "Items to show", Type: "number", Help: "Default: 25"},
|
||||||
|
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||||
|
{Key: "style", Label: "Style", Type: "select", Options: []string{"vertical-list", "horizontal-cards", "horizontal-cards-2", "detailed-list"}},
|
||||||
|
},
|
||||||
|
|
||||||
|
"weather": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "location", Label: "Location", Type: "string", Required: true, Help: "City and country, e.g. London, GB. Start typing to search.", Lookup: "weather-location", Validator: "weather-location"},
|
||||||
|
{Key: "units", Label: "Units", Type: "select", Options: []string{"metric", "imperial"}},
|
||||||
|
{Key: "hour-format", Label: "Hour format", Type: "select", Options: []string{"24h", "12h"}},
|
||||||
|
{Key: "hide-location", Label: "Hide location label", Type: "boolean"},
|
||||||
|
{Key: "show-area-name", Label: "Show area name", Type: "boolean"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"markets": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "markets", Label: "Symbols", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
|
||||||
|
{Key: "symbol", Label: "Ticker symbol", Type: "string", Required: true, Help: "Search a company name or type a symbol like AAPL or BTC-USD.", Lookup: "market-symbol", Validator: "market-symbol"},
|
||||||
|
{Key: "name", Label: "Display name (optional)", Type: "string"},
|
||||||
|
}},
|
||||||
|
{Key: "sort-by", Label: "Sort by", Type: "select", Options: []string{"absolute-change", "relative-change"}},
|
||||||
|
},
|
||||||
|
|
||||||
|
"monitor": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "sites", Label: "Sites", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
|
||||||
|
{Key: "title", Label: "Display name", Type: "string", Required: true},
|
||||||
|
{Key: "url", Label: "URL", Type: "string", Required: true},
|
||||||
|
{Key: "icon", Label: "Icon URL (optional)", Type: "string"},
|
||||||
|
{Key: "alt-status-codes", Label: "Other OK status codes (comma-separated)", Type: "string", Help: "e.g. 401,403 — codes that should still show OK."},
|
||||||
|
}},
|
||||||
|
{Key: "show-failing-only", Label: "Show failing only", Type: "boolean"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"reddit": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "subreddit", Label: "Subreddit", Type: "string", Required: true, Help: "Just the name, no /r/"},
|
||||||
|
{Key: "sort-by", Label: "Sort by", Type: "select", Options: []string{"hot", "new", "top", "rising"}},
|
||||||
|
{Key: "top-period", Label: "Top period (when sort-by is 'top')", Type: "select", Options: []string{"day", "week", "month", "year", "all"}},
|
||||||
|
{Key: "show-thumbnails", Label: "Show thumbnails", Type: "boolean"},
|
||||||
|
{Key: "show-flairs", Label: "Show flairs", Type: "boolean"},
|
||||||
|
{Key: "limit", Label: "Items to show", Type: "number"},
|
||||||
|
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||||
|
{Key: "style", Label: "Style", Type: "select", Options: []string{"vertical-list", "horizontal-cards"}},
|
||||||
|
},
|
||||||
|
|
||||||
|
"hacker-news": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "sort-by", Label: "Sort by", Type: "select", Options: []string{"top", "new", "best"}},
|
||||||
|
{Key: "limit", Label: "Items to show", Type: "number"},
|
||||||
|
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"lobsters": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "sort-by", Label: "Sort by", Type: "select", Options: []string{"hot", "new"}},
|
||||||
|
{Key: "tags", Label: "Tags (filter)", Type: "list-strings"},
|
||||||
|
{Key: "limit", Label: "Items to show", Type: "number"},
|
||||||
|
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"videos": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "channels", Label: "YouTube channel IDs", Type: "list-strings", Required: true, Help: "The UC... ID, not the @handle"},
|
||||||
|
{Key: "limit", Label: "Videos to show", Type: "number"},
|
||||||
|
{Key: "style", Label: "Style", Type: "select", Options: []string{"horizontal-cards", "grid-cards", "vertical-list"}},
|
||||||
|
},
|
||||||
|
|
||||||
|
"twitch-channels": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "channels", Label: "Twitch channel names", Type: "list-strings", Required: true},
|
||||||
|
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"repository": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "repository", Label: "Repository", Type: "string", Required: true, Help: "owner/name, e.g. glanceapp/glance"},
|
||||||
|
{Key: "token", Label: "GitHub token (optional)", Type: "string", Help: "Increases rate limits. Use ${env:VAR} to read from env."},
|
||||||
|
{Key: "pull-requests-limit", Label: "Pull requests to show", Type: "number"},
|
||||||
|
{Key: "issues-limit", Label: "Issues to show", Type: "number"},
|
||||||
|
{Key: "commits-limit", Label: "Commits to show", Type: "number"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"releases": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "repositories", Label: "Repositories", Type: "list-strings", Required: true, Help: "owner/name per line. Add prefixes like docker:, gitlab:, codeberg: for non-GitHub sources."},
|
||||||
|
{Key: "token", Label: "GitHub token (optional)", Type: "string"},
|
||||||
|
{Key: "show-source-icon", Label: "Show source icon", Type: "boolean"},
|
||||||
|
{Key: "limit", Label: "Releases to show", Type: "number"},
|
||||||
|
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"bookmarks": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "groups", Label: "Groups", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
|
||||||
|
{Key: "title", Label: "Group title", Type: "string", Required: true},
|
||||||
|
{Key: "color", Label: "Color (HSL, optional)", Type: "string", Help: "e.g. 200 50 50"},
|
||||||
|
{Key: "links", Label: "Links", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
|
||||||
|
{Key: "title", Label: "Link title", Type: "string", Required: true},
|
||||||
|
{Key: "url", Label: "URL", Type: "string", Required: true},
|
||||||
|
{Key: "icon", Label: "Icon URL (optional)", Type: "string"},
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
|
||||||
|
"clock": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "hour-format", Label: "Hour format", Type: "select", Options: []string{"24h", "12h"}},
|
||||||
|
{Key: "timezones", Label: "Extra timezones to show", Type: "list-objects", Items: []widgetFieldSchema{
|
||||||
|
{Key: "timezone", Label: "Timezone (e.g. Europe/London)", Type: "string", Required: true},
|
||||||
|
{Key: "label", Label: "Display label", Type: "string"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
|
||||||
|
"calendar": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "first-day-of-week", Label: "First day of week", Type: "select", Options: []string{"monday", "sunday"}},
|
||||||
|
{Key: "show-week-numbers", Label: "Show week numbers", Type: "boolean"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"search": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "search-engine", Label: "Search engine", Type: "select", Options: []string{"duckduckgo", "google", "bing", "kagi", "startpage", "perplexity"}},
|
||||||
|
{Key: "new-tab", Label: "Open results in new tab", Type: "boolean"},
|
||||||
|
{Key: "autofocus", Label: "Autofocus on page load", Type: "boolean"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"iframe": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "source", Label: "URL to embed", Type: "string", Required: true},
|
||||||
|
{Key: "height", Label: "Height (CSS)", Type: "string", Help: "e.g. 400px, 50vh"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"html": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "source", Label: "HTML", Type: "multiline", Required: true},
|
||||||
|
},
|
||||||
|
|
||||||
|
"server-stats": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"to-do": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"dns-stats": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "service", Label: "Service", Type: "select", Required: true,
|
||||||
|
Options: []string{"adguard", "pihole", "pihole-v6", "technitium"}},
|
||||||
|
{Key: "url", Label: "Service URL", Type: "string", Required: true,
|
||||||
|
Help: "Base URL of your DNS resolver, e.g. http://pi.hole or http://192.168.1.10"},
|
||||||
|
{Key: "username", Label: "Username (AdGuard / Pi-hole v6)", Type: "string"},
|
||||||
|
{Key: "password", Label: "Password", Type: "string",
|
||||||
|
Help: "Use ${env:DNS_PASSWORD} to read from an env var instead of inlining."},
|
||||||
|
{Key: "token", Label: "API token (Pi-hole v5, Technitium)", Type: "string"},
|
||||||
|
{Key: "allow-insecure", Label: "Allow insecure TLS (self-signed certs)", Type: "boolean"},
|
||||||
|
{Key: "hide-graph", Label: "Hide hourly graph", Type: "boolean"},
|
||||||
|
{Key: "hide-top-domains", Label: "Hide top blocked/queried domains", Type: "boolean"},
|
||||||
|
{Key: "hour-format", Label: "Hour format", Type: "select",
|
||||||
|
Options: []string{"24h", "12h"}},
|
||||||
|
},
|
||||||
|
|
||||||
|
"docker-containers": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "sock-path", Label: "Docker socket path", Type: "string",
|
||||||
|
Help: "Default: /var/run/docker.sock"},
|
||||||
|
{Key: "category", Label: "Category filter", Type: "string",
|
||||||
|
Help: "Show only containers labelled glance.category=<value>"},
|
||||||
|
{Key: "hide-by-default", Label: "Hide containers unless labelled glance.hide=false", Type: "boolean"},
|
||||||
|
{Key: "running-only", Label: "Show running containers only", Type: "boolean"},
|
||||||
|
{Key: "format-container-names", Label: "Format container names", Type: "boolean",
|
||||||
|
Help: "Title-cases names like 'home_assistant' → 'Home Assistant'"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"extension": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "url", Label: "Extension URL", Type: "string", Required: true,
|
||||||
|
Help: "URL of the Glance extension endpoint. See the Extensions docs."},
|
||||||
|
{Key: "fallback-content-type", Label: "Fallback content type", Type: "select",
|
||||||
|
Options: []string{"", "html", "iframe"},
|
||||||
|
Help: "Used when the response has no Widget-Content-Type header."},
|
||||||
|
{Key: "allow-potentially-dangerous-html", Label: "Allow raw HTML output", Type: "boolean",
|
||||||
|
Help: "Only enable for extensions you trust — they can inject scripts otherwise."},
|
||||||
|
},
|
||||||
|
|
||||||
|
"change-detection": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "instance-url", Label: "ChangeDetection.io URL", Type: "string",
|
||||||
|
Help: "Defaults to https://www.changedetection.io. Use your own instance for self-hosted."},
|
||||||
|
{Key: "token", Label: "API token", Type: "string",
|
||||||
|
Help: "From your changedetection.io profile. Use ${env:CD_TOKEN} for env-based config."},
|
||||||
|
{Key: "watches", Label: "Watch UUIDs (optional)", Type: "list-strings",
|
||||||
|
Help: "Filter to specific watches. Leave empty to show all."},
|
||||||
|
{Key: "limit", Label: "Items to show", Type: "number"},
|
||||||
|
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"custom-api": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "url", Label: "API URL", Type: "string", Required: true},
|
||||||
|
{Key: "method", Label: "HTTP method", Type: "select",
|
||||||
|
Options: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}},
|
||||||
|
{Key: "body-type", Label: "Body type (when POST/PUT/PATCH)", Type: "select",
|
||||||
|
Options: []string{"", "json", "string"}},
|
||||||
|
{Key: "body", Label: "Request body", Type: "multiline",
|
||||||
|
Help: "Optional. Sent as the request body."},
|
||||||
|
{Key: "template", Label: "Output template (Go html/template)", Type: "multiline", Required: true,
|
||||||
|
Help: "Renders the response. See the Custom API docs for available helpers like {{ .JSON.String \"path.to.field\" }}."},
|
||||||
|
{Key: "frameless", Label: "Hide widget frame", Type: "boolean"},
|
||||||
|
},
|
||||||
|
|
||||||
|
"group": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "widgets", Label: "Widgets in group", Type: "list-of-widgets", Required: true,
|
||||||
|
Help: "Each tab inside the group. Nested groups and split-columns aren't allowed."},
|
||||||
|
},
|
||||||
|
|
||||||
|
"split-column": {
|
||||||
|
{Key: "title", Label: "Custom title", Type: "string"},
|
||||||
|
{Key: "max-columns", Label: "Maximum columns (≥2)", Type: "number"},
|
||||||
|
{Key: "widgets", Label: "Widgets shown in the split", Type: "list-of-widgets", Required: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mmcdole/gofeed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// admin-validate.go — server-side validation and lookup helpers used by the
|
||||||
|
// edit-mode dialog. Each kind has both:
|
||||||
|
// - validate(value): is this exact value usable?
|
||||||
|
// - lookup(query): suggestions for typing-as-you-go
|
||||||
|
// Where one of those isn't applicable (e.g. there's no "search" for an
|
||||||
|
// arbitrary RSS URL), only the relevant function is implemented.
|
||||||
|
|
||||||
|
type validationSuggestion struct {
|
||||||
|
Value string `json:"value"` // the string to put into the input
|
||||||
|
Display string `json:"display"` // human-readable label for the dropdown
|
||||||
|
Hint string `json:"hint,omitempty"` // small caption (e.g. "AAPL — Apple Inc.")
|
||||||
|
Extra map[string]string `json:"extra,omitempty"` // sibling field values to auto-fill (e.g. {"name": "Apple Inc."})
|
||||||
|
}
|
||||||
|
|
||||||
|
type validationResult struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Hint string `json:"hint,omitempty"` // shown next to a successful field
|
||||||
|
Suggestions []validationSuggestion `json:"suggestions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var validateClient = &http.Client{Timeout: 8 * time.Second}
|
||||||
|
|
||||||
|
func ctxWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(context.Background(), d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpGetJSON(rawURL string, into interface{}) error {
|
||||||
|
ctx, cancel := ctxWithTimeout(8 * time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Yahoo's undocumented endpoints reject the default Go UA. Use a browser-y
|
||||||
|
// string so search/quote actually return data.
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
|
||||||
|
req.Header.Set("Accept", "application/json,text/plain,*/*")
|
||||||
|
resp, err := validateClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return json.NewDecoder(resp.Body).Decode(into)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- weather-location (Open-Meteo geocoding) ----------
|
||||||
|
|
||||||
|
type openMeteoGeo struct {
|
||||||
|
Results []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Admin1 string `json:"admin1"`
|
||||||
|
CountryCode string `json:"country_code"`
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func geocodeWeatherLocation(query string) ([]validationSuggestion, error) {
|
||||||
|
q := strings.TrimSpace(query)
|
||||||
|
if q == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
endpoint := "https://geocoding-api.open-meteo.com/v1/search?count=8&format=json&name=" + url.QueryEscape(q)
|
||||||
|
var data openMeteoGeo
|
||||||
|
if err := httpGetJSON(endpoint, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]validationSuggestion, 0, len(data.Results))
|
||||||
|
for _, r := range data.Results {
|
||||||
|
// Glance's weather widget accepts strings like "London, GB". We use that
|
||||||
|
// canonical form as the suggestion value so saving works without changes.
|
||||||
|
v := r.Name
|
||||||
|
if r.CountryCode != "" {
|
||||||
|
v = v + ", " + r.CountryCode
|
||||||
|
}
|
||||||
|
display := r.Name
|
||||||
|
if r.Admin1 != "" {
|
||||||
|
display += ", " + r.Admin1
|
||||||
|
}
|
||||||
|
if r.Country != "" {
|
||||||
|
display += ", " + r.Country
|
||||||
|
}
|
||||||
|
out = append(out, validationSuggestion{
|
||||||
|
Value: v,
|
||||||
|
Display: display,
|
||||||
|
Hint: fmt.Sprintf("%.2f, %.2f", r.Latitude, r.Longitude),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateWeatherLocation(value string) validationResult {
|
||||||
|
v := strings.TrimSpace(value)
|
||||||
|
if v == "" {
|
||||||
|
return validationResult{Valid: false, Error: "Location is required."}
|
||||||
|
}
|
||||||
|
// Try to verify against Open-Meteo, but never block the save on failure
|
||||||
|
// — the user might know better than us, and the weather widget will
|
||||||
|
// surface a clear error at update time if it really can't geocode.
|
||||||
|
searchQuery := v
|
||||||
|
if comma := strings.IndexByte(v, ','); comma > 0 {
|
||||||
|
searchQuery = strings.TrimSpace(v[:comma])
|
||||||
|
}
|
||||||
|
suggestions, err := geocodeWeatherLocation(searchQuery)
|
||||||
|
if err != nil || len(suggestions) == 0 {
|
||||||
|
return validationResult{Valid: true, Hint: "couldn't verify — will be tested when the widget updates"}
|
||||||
|
}
|
||||||
|
return validationResult{Valid: true, Hint: suggestions[0].Display}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- market-symbol (Yahoo Finance) ----------
|
||||||
|
|
||||||
|
type yahooSearchResp struct {
|
||||||
|
Quotes []struct {
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
ShortName string `json:"shortname"`
|
||||||
|
LongName string `json:"longname"`
|
||||||
|
QuoteType string `json:"quoteType"`
|
||||||
|
Exchange string `json:"exchDisp"`
|
||||||
|
} `json:"quotes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type yahooChartResp struct {
|
||||||
|
Chart struct {
|
||||||
|
Result []struct {
|
||||||
|
Meta struct {
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
LongName string `json:"longName"`
|
||||||
|
ShortName string `json:"shortName"`
|
||||||
|
ExchangeName string `json:"exchangeName"`
|
||||||
|
FullExchangeName string `json:"fullExchangeName"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||||
|
} `json:"meta"`
|
||||||
|
} `json:"result"`
|
||||||
|
Error *struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"error"`
|
||||||
|
} `json:"chart"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupMarketSymbol(query string) ([]validationSuggestion, error) {
|
||||||
|
q := strings.TrimSpace(query)
|
||||||
|
if q == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// Try query1 first; fall back to query2 since one or the other is usually up.
|
||||||
|
var data yahooSearchResp
|
||||||
|
q1 := "https://query1.finance.yahoo.com/v1/finance/search?quotesCount=8&newsCount=0&q=" + url.QueryEscape(q)
|
||||||
|
if err := httpGetJSON(q1, &data); err != nil || len(data.Quotes) == 0 {
|
||||||
|
q2 := "https://query2.finance.yahoo.com/v1/finance/search?quotesCount=8&newsCount=0&q=" + url.QueryEscape(q)
|
||||||
|
if err2 := httpGetJSON(q2, &data); err2 != nil {
|
||||||
|
return nil, err2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]validationSuggestion, 0, len(data.Quotes))
|
||||||
|
for _, qr := range data.Quotes {
|
||||||
|
name := qr.LongName
|
||||||
|
if name == "" {
|
||||||
|
name = qr.ShortName
|
||||||
|
}
|
||||||
|
if qr.Symbol == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
display := qr.Symbol
|
||||||
|
if name != "" {
|
||||||
|
display = qr.Symbol + " — " + name
|
||||||
|
}
|
||||||
|
hint := qr.QuoteType
|
||||||
|
if qr.Exchange != "" {
|
||||||
|
hint = strings.TrimSpace(strings.Join([]string{qr.QuoteType, qr.Exchange}, " · "))
|
||||||
|
}
|
||||||
|
extra := map[string]string{}
|
||||||
|
if name != "" {
|
||||||
|
extra["name"] = name
|
||||||
|
}
|
||||||
|
out = append(out, validationSuggestion{
|
||||||
|
Value: qr.Symbol,
|
||||||
|
Display: display,
|
||||||
|
Hint: hint,
|
||||||
|
Extra: extra,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMarketSymbol(value string) validationResult {
|
||||||
|
sym := strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
if sym == "" {
|
||||||
|
return validationResult{Valid: false, Error: "Symbol is required."}
|
||||||
|
}
|
||||||
|
// v8 chart endpoint is the most reliable Yahoo entry point — it serves
|
||||||
|
// public data and tends to ignore the auth cookies the v7 quote API
|
||||||
|
// started requiring. We just need to confirm the symbol resolves to a
|
||||||
|
// real instrument; we don't care about price.
|
||||||
|
endpoint := "https://query1.finance.yahoo.com/v8/finance/chart/" + url.PathEscape(sym) + "?range=1d&interval=1d"
|
||||||
|
var data yahooChartResp
|
||||||
|
if err := httpGetJSON(endpoint, &data); err != nil {
|
||||||
|
return validationResult{Valid: true, Hint: "couldn't verify (Yahoo unreachable)"}
|
||||||
|
}
|
||||||
|
if data.Chart.Error != nil && data.Chart.Error.Code != "" {
|
||||||
|
return validationResult{Valid: false, Error: "Yahoo: " + data.Chart.Error.Description}
|
||||||
|
}
|
||||||
|
if len(data.Chart.Result) == 0 {
|
||||||
|
return validationResult{Valid: false, Error: "No data returned for " + sym + "."}
|
||||||
|
}
|
||||||
|
m := data.Chart.Result[0].Meta
|
||||||
|
name := m.LongName
|
||||||
|
if name == "" {
|
||||||
|
name = m.ShortName
|
||||||
|
}
|
||||||
|
hint := name
|
||||||
|
if m.FullExchangeName != "" && name != "" {
|
||||||
|
hint = name + " · " + m.FullExchangeName
|
||||||
|
}
|
||||||
|
return validationResult{Valid: true, Hint: hint}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- rss-feed (gofeed) ----------
|
||||||
|
|
||||||
|
func validateRSSFeed(value string) validationResult {
|
||||||
|
u := strings.TrimSpace(value)
|
||||||
|
if u == "" {
|
||||||
|
return validationResult{Valid: false, Error: "URL is required."}
|
||||||
|
}
|
||||||
|
if _, err := url.ParseRequestURI(u); err != nil {
|
||||||
|
return validationResult{Valid: false, Error: "Doesn't look like a URL."}
|
||||||
|
}
|
||||||
|
ctx, cancel := ctxWithTimeout(8 * time.Second)
|
||||||
|
defer cancel()
|
||||||
|
parser := gofeed.NewParser()
|
||||||
|
parser.Client = validateClient
|
||||||
|
feed, err := parser.ParseURLWithContext(u, ctx)
|
||||||
|
if err != nil {
|
||||||
|
return validationResult{Valid: false, Error: "Feed parse failed: " + err.Error()}
|
||||||
|
}
|
||||||
|
hint := feed.Title
|
||||||
|
if feed.Items != nil {
|
||||||
|
hint = fmt.Sprintf("%s · %d items", feed.Title, len(feed.Items))
|
||||||
|
}
|
||||||
|
return validationResult{Valid: true, Hint: hint}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- HTTP handlers ----------
|
||||||
|
|
||||||
|
func (a *application) handleAdminValidate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kind := r.PathValue("kind")
|
||||||
|
var body struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result validationResult
|
||||||
|
switch kind {
|
||||||
|
case "weather-location":
|
||||||
|
result = validateWeatherLocation(body.Value)
|
||||||
|
case "market-symbol":
|
||||||
|
result = validateMarketSymbol(body.Value)
|
||||||
|
case "rss-feed":
|
||||||
|
result = validateRSSFeed(body.Value)
|
||||||
|
default:
|
||||||
|
http.Error(w, "unknown validator: "+kind, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminLookup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kind := r.PathValue("kind")
|
||||||
|
var body struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
suggestions []validationSuggestion
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
switch kind {
|
||||||
|
case "weather-location":
|
||||||
|
suggestions, err = geocodeWeatherLocation(body.Query)
|
||||||
|
case "market-symbol":
|
||||||
|
suggestions, err = lookupMarketSymbol(body.Query)
|
||||||
|
default:
|
||||||
|
http.Error(w, "unknown lookup: "+kind, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// Lookup failures shouldn't be hard errors — return empty suggestions
|
||||||
|
// so the UI just shows nothing rather than alarming the user.
|
||||||
|
suggestions = nil
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(struct {
|
||||||
|
Suggestions []validationSuggestion `json:"suggestions"`
|
||||||
|
}{Suggestions: suggestions})
|
||||||
|
}
|
||||||
@@ -0,0 +1,913 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
adminIndexTemplate = mustParseTemplate("admin.html", "document.html", "footer.html")
|
||||||
|
adminPageTemplate = mustParseTemplate("admin-page.html", "document.html", "footer.html")
|
||||||
|
adminWidgetTemplate = mustParseTemplate("admin-widget.html", "document.html", "footer.html")
|
||||||
|
adminPageSettingsTemplate = mustParseTemplate("admin-page-settings.html", "document.html", "footer.html")
|
||||||
|
adminSiteSettingsTemplate = mustParseTemplate("admin-site-settings.html", "document.html", "footer.html")
|
||||||
|
adminThemeSettingsTemplate = mustParseTemplate("admin-theme-settings.html", "document.html", "footer.html")
|
||||||
|
adminThemePresetTemplate = mustParseTemplate("admin-theme-preset.html", "document.html", "footer.html")
|
||||||
|
)
|
||||||
|
|
||||||
|
// allWidgetTypes mirrors the switch in newWidget(). Aliases ("stocks") omitted.
|
||||||
|
var allWidgetTypes = []string{
|
||||||
|
"bookmarks", "calendar", "change-detection", "clock", "custom-api",
|
||||||
|
"dns-stats", "docker-containers", "extension", "group", "hacker-news",
|
||||||
|
"html", "iframe", "lobsters", "markets", "monitor", "reddit",
|
||||||
|
"releases", "repository", "rss", "search", "server-stats",
|
||||||
|
"split-column", "to-do", "twitch-channels", "twitch-top-games",
|
||||||
|
"videos", "weather",
|
||||||
|
}
|
||||||
|
|
||||||
|
// widgetFieldReferences caches the marshaled YAML of an empty instance of
|
||||||
|
// each widget type, used to show users what fields are available when
|
||||||
|
// editing. Computed once at startup.
|
||||||
|
var widgetFieldReferences = func() map[string]string {
|
||||||
|
out := make(map[string]string, len(allWidgetTypes))
|
||||||
|
for _, t := range allWidgetTypes {
|
||||||
|
w, err := newWidget(t)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, err := yaml.Marshal(w)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[t] = string(b)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}()
|
||||||
|
|
||||||
|
// widgetDocsAnchors maps a widget type to its anchor in upstream Glance docs.
|
||||||
|
// Anchors are GitHub-rendered from the headings in docs/configuration.md.
|
||||||
|
var widgetDocsAnchors = map[string]string{
|
||||||
|
"bookmarks": "bookmarks",
|
||||||
|
"calendar": "calendar",
|
||||||
|
"calendar-legacy": "calendar-legacy",
|
||||||
|
"change-detection": "changedetectionio",
|
||||||
|
"clock": "clock",
|
||||||
|
"custom-api": "custom-api",
|
||||||
|
"dns-stats": "dns-stats",
|
||||||
|
"docker-containers": "docker-containers",
|
||||||
|
"extension": "extension",
|
||||||
|
"group": "group",
|
||||||
|
"hacker-news": "hacker-news",
|
||||||
|
"lobsters": "lobsters",
|
||||||
|
"markets": "markets",
|
||||||
|
"monitor": "monitor",
|
||||||
|
"reddit": "reddit",
|
||||||
|
"releases": "releases",
|
||||||
|
"repository": "repository",
|
||||||
|
"rss": "rss",
|
||||||
|
"search": "search-widget",
|
||||||
|
"server-stats": "server-stats",
|
||||||
|
"split-column": "split-column",
|
||||||
|
"to-do": "todo",
|
||||||
|
"twitch-channels": "twitch-channels",
|
||||||
|
"twitch-top-games": "twitch-top-games",
|
||||||
|
"videos": "videos",
|
||||||
|
"weather": "weather",
|
||||||
|
}
|
||||||
|
|
||||||
|
func widgetDocsURL(widgetType string) string {
|
||||||
|
anchor, ok := widgetDocsAnchors[widgetType]
|
||||||
|
if !ok {
|
||||||
|
return "https://github.com/glanceapp/glance/blob/main/docs/configuration.md#widgets"
|
||||||
|
}
|
||||||
|
return "https://github.com/glanceapp/glance/blob/main/docs/configuration.md#" + anchor
|
||||||
|
}
|
||||||
|
|
||||||
|
// widgetStarters provides hand-curated minimal-but-meaningful YAML for each
|
||||||
|
// widget type, drawn from the upstream docs. Used to pre-fill the editor
|
||||||
|
// when adding a new widget so users see a working shape they can adapt.
|
||||||
|
var widgetStarters = map[string]string{
|
||||||
|
"bookmarks": `type: bookmarks
|
||||||
|
groups:
|
||||||
|
- title: Sites
|
||||||
|
links:
|
||||||
|
- title: Glance
|
||||||
|
url: https://github.com/glanceapp/glance
|
||||||
|
`,
|
||||||
|
"calendar": "type: calendar\n",
|
||||||
|
"calendar-legacy": "type: calendar-legacy\n",
|
||||||
|
"change-detection": `type: change-detection
|
||||||
|
instance-url: https://changedetection.example.com/
|
||||||
|
token: ${CHANGE_DETECTION_TOKEN}
|
||||||
|
`,
|
||||||
|
"clock": "type: clock\n",
|
||||||
|
"custom-api": `type: custom-api
|
||||||
|
url: https://api.example.com/data
|
||||||
|
template: |
|
||||||
|
<p>Replace with a Go template that renders the JSON response.</p>
|
||||||
|
`,
|
||||||
|
"dns-stats": `type: dns-stats
|
||||||
|
service: pihole
|
||||||
|
url: http://pi.hole
|
||||||
|
allow-insecure: true
|
||||||
|
username: admin
|
||||||
|
password: ${DNS_PASSWORD}
|
||||||
|
`,
|
||||||
|
"docker-containers": "type: docker-containers\n",
|
||||||
|
"extension": `type: extension
|
||||||
|
url: https://example.com/extension
|
||||||
|
`,
|
||||||
|
"group": `type: group
|
||||||
|
widgets:
|
||||||
|
- type: hacker-news
|
||||||
|
- type: lobsters
|
||||||
|
`,
|
||||||
|
"hacker-news": "type: hacker-news\n",
|
||||||
|
"html": `type: html
|
||||||
|
source: |
|
||||||
|
<p>Hello from a custom HTML widget.</p>
|
||||||
|
`,
|
||||||
|
"iframe": `type: iframe
|
||||||
|
source: https://example.com
|
||||||
|
`,
|
||||||
|
"lobsters": "type: lobsters\n",
|
||||||
|
"markets": `type: markets
|
||||||
|
markets:
|
||||||
|
- symbol: SPY
|
||||||
|
name: S&P 500
|
||||||
|
- symbol: BTC-USD
|
||||||
|
name: Bitcoin
|
||||||
|
`,
|
||||||
|
"monitor": `type: monitor
|
||||||
|
title: Services
|
||||||
|
sites:
|
||||||
|
- title: Example
|
||||||
|
url: https://example.com
|
||||||
|
`,
|
||||||
|
"reddit": `type: reddit
|
||||||
|
subreddit: technology
|
||||||
|
`,
|
||||||
|
"releases": `type: releases
|
||||||
|
repositories:
|
||||||
|
- glanceapp/glance
|
||||||
|
`,
|
||||||
|
"repository": `type: repository
|
||||||
|
repository: glanceapp/glance
|
||||||
|
`,
|
||||||
|
"rss": `type: rss
|
||||||
|
feeds:
|
||||||
|
- url: https://www.theverge.com/rss/index.xml
|
||||||
|
title: The Verge
|
||||||
|
`,
|
||||||
|
"search": "type: search\n",
|
||||||
|
"server-stats": "type: server-stats\n",
|
||||||
|
"split-column": `type: split-column
|
||||||
|
widgets:
|
||||||
|
- type: clock
|
||||||
|
- type: search
|
||||||
|
`,
|
||||||
|
"to-do": "type: to-do\n",
|
||||||
|
"twitch-channels": `type: twitch-channels
|
||||||
|
channels:
|
||||||
|
- shroud
|
||||||
|
`,
|
||||||
|
"twitch-top-games": "type: twitch-top-games\n",
|
||||||
|
"videos": `type: videos
|
||||||
|
channels:
|
||||||
|
- UCXuqSBlHAE6Xw-yeJA0Tunw
|
||||||
|
`,
|
||||||
|
"weather": `type: weather
|
||||||
|
location: London, GB
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
func widgetStarterYAML(widgetType string) string {
|
||||||
|
if s, ok := widgetStarters[widgetType]; ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "type: " + widgetType + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminWidgetView struct {
|
||||||
|
ColumnIndex int
|
||||||
|
WidgetIndex int
|
||||||
|
ID uint64
|
||||||
|
Title string
|
||||||
|
Type string
|
||||||
|
IsFirst bool
|
||||||
|
IsLast bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminColumnView struct {
|
||||||
|
Index int
|
||||||
|
Size string
|
||||||
|
Widgets []adminWidgetView
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminPageSummary struct {
|
||||||
|
Title string
|
||||||
|
Slug string
|
||||||
|
WidgetCount int
|
||||||
|
IsFirst bool
|
||||||
|
IsLast bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminBackupSummary struct {
|
||||||
|
N int
|
||||||
|
Time string // human-readable, e.g. "2 minutes ago"
|
||||||
|
SizeKB int
|
||||||
|
Present bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminPageDetail struct {
|
||||||
|
Title string
|
||||||
|
Slug string
|
||||||
|
HeadWidgets []adminWidgetView
|
||||||
|
Columns []adminColumnView
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminPageSettings struct {
|
||||||
|
Name string
|
||||||
|
Slug string
|
||||||
|
Width string
|
||||||
|
DesktopNavigationWidth string
|
||||||
|
ShowMobileHeader bool
|
||||||
|
HideDesktopNavigation bool
|
||||||
|
CenterVertically bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminTemplateData struct {
|
||||||
|
App *application
|
||||||
|
Request templateRequestData
|
||||||
|
|
||||||
|
Pages []adminPageSummary
|
||||||
|
Page *adminPageDetail
|
||||||
|
Widget *adminWidgetView
|
||||||
|
WidgetTypes []string
|
||||||
|
WidgetYAML string
|
||||||
|
LoadError string
|
||||||
|
FieldReference string
|
||||||
|
WidgetExample string
|
||||||
|
DocsURL string
|
||||||
|
IsNew bool
|
||||||
|
ErrorMessage string
|
||||||
|
|
||||||
|
PageSettings *adminPageSettings
|
||||||
|
IsFirstPage bool
|
||||||
|
IsLastPage bool
|
||||||
|
|
||||||
|
SiteSettings *adminSiteSettings
|
||||||
|
ThemeSettings *adminThemeSettings
|
||||||
|
PresetForm *adminPresetFormData
|
||||||
|
|
||||||
|
Backups []adminBackupSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminThemeSettings struct {
|
||||||
|
BackgroundColorHex string
|
||||||
|
PrimaryColorHex string
|
||||||
|
PositiveColorHex string
|
||||||
|
NegativeColorHex string
|
||||||
|
Light bool
|
||||||
|
DisablePicker bool
|
||||||
|
ContrastMultiplier float32
|
||||||
|
TextSaturationMultiplier float32
|
||||||
|
CustomCSSFile string
|
||||||
|
Presets []adminPresetItem
|
||||||
|
CatalogThemes []catalogTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminPresetItem struct {
|
||||||
|
Key string
|
||||||
|
BackgroundColorHex string
|
||||||
|
PrimaryColorHex string
|
||||||
|
PositiveColorHex string
|
||||||
|
NegativeColorHex string
|
||||||
|
Light bool
|
||||||
|
ContrastMultiplier float32
|
||||||
|
TextSaturationMultiplier float32
|
||||||
|
PreviewHTML template.HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminPresetFormData struct {
|
||||||
|
Key string
|
||||||
|
BackgroundColorHex string
|
||||||
|
PrimaryColorHex string
|
||||||
|
PositiveColorHex string
|
||||||
|
NegativeColorHex string
|
||||||
|
Light bool
|
||||||
|
ContrastMultiplier float32
|
||||||
|
TextSaturationMultiplier float32
|
||||||
|
IsNew bool
|
||||||
|
ErrorMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
type catalogTheme struct {
|
||||||
|
Name string
|
||||||
|
Key string
|
||||||
|
PreviewHTML template.HTML
|
||||||
|
BgHex string
|
||||||
|
PrimaryHex string
|
||||||
|
PositiveHex string
|
||||||
|
NegativeHex string
|
||||||
|
Light bool
|
||||||
|
CM float32
|
||||||
|
TSM float32
|
||||||
|
}
|
||||||
|
|
||||||
|
var builtinThemeCatalog []catalogTheme
|
||||||
|
|
||||||
|
func parseHSLStr(s string) *hslColorField {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
matches := hslColorFieldPattern.FindStringSubmatch(s)
|
||||||
|
if len(matches) != 4 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
h, _ := strconv.ParseFloat(matches[1], 64)
|
||||||
|
sat, _ := strconv.ParseFloat(matches[2], 64)
|
||||||
|
l, _ := strconv.ParseFloat(matches[3], 64)
|
||||||
|
return &hslColorField{H: h, S: sat, L: l}
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeHex(c *hslColorField) string {
|
||||||
|
if c == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.ToHex()
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
type rawEntry struct {
|
||||||
|
name, key, bg, primary, positive, negative string
|
||||||
|
cm, tsm float32
|
||||||
|
light bool
|
||||||
|
}
|
||||||
|
entries := []rawEntry{
|
||||||
|
{"Teal City", "teal-city", "225 14 15", "157 47 65", "", "", 1.1, 0, false},
|
||||||
|
{"Catppuccin Frappe", "catppuccin-frappe", "229 19 23", "222 74 74", "96 44 68", "359 68 71", 1.2, 0, false},
|
||||||
|
{"Catppuccin Macchiato", "catppuccin-macchiato", "232 23 18", "220 83 75", "105 48 72", "351 74 73", 1.2, 0, false},
|
||||||
|
{"Catppuccin Mocha", "catppuccin-mocha", "240 21 15", "217 92 83", "115 54 76", "347 70 65", 1.2, 0, false},
|
||||||
|
{"Camouflage", "camouflage", "186 21 20", "97 13 80", "", "", 1.2, 0, false},
|
||||||
|
{"Gruvbox Dark", "gruvbox-dark", "0 0 16", "43 59 81", "61 66 44", "6 96 59", 0, 0, false},
|
||||||
|
{"Kanagawa Dark", "kanagawa-dark", "240 13 14", "51 33 68", "", "358 100 68", 1.2, 0, false},
|
||||||
|
{"Tucan", "tucan", "50 1 6", "24 97 58", "", "209 88 54", 0, 0, false},
|
||||||
|
{"Dracula", "dracula", "231 15 21", "265 89 79", "135 94 66", "0 100 67", 1.2, 0, false},
|
||||||
|
{"Shades of Purple", "shades-of-purple", "243 33 25", "50 100 49", "98 82 71", "12 77 52", 1.2, 0, false},
|
||||||
|
{"Neon Pink", "neon-pink", "240 27 11", "321 100 71", "165 78 51", "360 100 71", 1.5, 0, false},
|
||||||
|
{"Catppuccin Latte", "catppuccin-latte", "220 23 95", "220 91 54", "109 58 40", "347 87 44", 1.0, 0, true},
|
||||||
|
{"Peachy", "peachy", "28 40 77", "155 100 20", "", "0 100 60", 1.1, 0.5, true},
|
||||||
|
{"Zebra", "zebra", "0 0 95", "0 0 10", "", "0 90 50", 0, 0, true},
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
p := themeProperties{
|
||||||
|
BackgroundColor: parseHSLStr(e.bg),
|
||||||
|
PrimaryColor: parseHSLStr(e.primary),
|
||||||
|
PositiveColor: parseHSLStr(e.positive),
|
||||||
|
NegativeColor: parseHSLStr(e.negative),
|
||||||
|
ContrastMultiplier: e.cm,
|
||||||
|
TextSaturationMultiplier: e.tsm,
|
||||||
|
Light: e.light,
|
||||||
|
Key: e.key,
|
||||||
|
}
|
||||||
|
_ = p.init()
|
||||||
|
builtinThemeCatalog = append(builtinThemeCatalog, catalogTheme{
|
||||||
|
Name: e.name,
|
||||||
|
Key: e.key,
|
||||||
|
PreviewHTML: p.PreviewHTML,
|
||||||
|
BgHex: safeHex(p.BackgroundColor),
|
||||||
|
PrimaryHex: safeHex(p.PrimaryColor),
|
||||||
|
PositiveHex: safeHex(p.PositiveColor),
|
||||||
|
NegativeHex: safeHex(p.NegativeColor),
|
||||||
|
Light: e.light,
|
||||||
|
CM: e.cm,
|
||||||
|
TSM: e.tsm,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminSiteSettings struct {
|
||||||
|
AppName string
|
||||||
|
LogoText string
|
||||||
|
LogoURL string
|
||||||
|
FaviconURL string
|
||||||
|
AppIconURL string
|
||||||
|
AppBackgroundColor string
|
||||||
|
HideFooter bool
|
||||||
|
CustomFooter string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pages := a.freshPagesFromDisk()
|
||||||
|
last := len(pages) - 1
|
||||||
|
summaries := make([]adminPageSummary, 0, len(pages))
|
||||||
|
for p := range pages {
|
||||||
|
page := &pages[p]
|
||||||
|
count := len(page.HeadWidgets)
|
||||||
|
for c := range page.Columns {
|
||||||
|
count += len(page.Columns[c].Widgets)
|
||||||
|
}
|
||||||
|
summaries = append(summaries, adminPageSummary{
|
||||||
|
Title: page.Title,
|
||||||
|
Slug: page.Slug,
|
||||||
|
WidgetCount: count,
|
||||||
|
IsFirst: p == 0,
|
||||||
|
IsLast: p == last,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
data := adminTemplateData{App: a, Pages: summaries, Backups: a.collectBackups()}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
renderAdminTemplate(w, adminIndexTemplate, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) collectBackups() []adminBackupSummary {
|
||||||
|
out := make([]adminBackupSummary, 0, maxBackups)
|
||||||
|
now := time.Now()
|
||||||
|
for n := 1; n <= maxBackups; n++ {
|
||||||
|
info, err := os.Stat(backupPath(a.ConfigPath, n))
|
||||||
|
if err != nil {
|
||||||
|
out = append(out, adminBackupSummary{N: n, Present: false})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, adminBackupSummary{
|
||||||
|
N: n,
|
||||||
|
Time: humanizeDuration(now.Sub(info.ModTime())) + " ago",
|
||||||
|
SizeKB: int(info.Size() / 1024),
|
||||||
|
Present: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanizeDuration(d time.Duration) string {
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
return fmt.Sprintf("%dh", int(d.Hours()))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pages := a.freshPagesFromDisk()
|
||||||
|
page, _, exists := findPageBySlug(pages, r.PathValue("page"))
|
||||||
|
if !exists {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := adminPageDetail{Title: page.Title, Slug: page.Slug}
|
||||||
|
|
||||||
|
for i, widget := range page.HeadWidgets {
|
||||||
|
detail.HeadWidgets = append(detail.HeadWidgets, adminWidgetView{
|
||||||
|
ColumnIndex: -1,
|
||||||
|
WidgetIndex: i,
|
||||||
|
ID: widget.GetID(),
|
||||||
|
Title: widget.GetTitle(),
|
||||||
|
Type: widget.GetType(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for c := range page.Columns {
|
||||||
|
col := &page.Columns[c]
|
||||||
|
view := adminColumnView{Index: c, Size: col.Size}
|
||||||
|
last := len(col.Widgets) - 1
|
||||||
|
for w, widget := range col.Widgets {
|
||||||
|
view.Widgets = append(view.Widgets, adminWidgetView{
|
||||||
|
ColumnIndex: c,
|
||||||
|
WidgetIndex: w,
|
||||||
|
ID: widget.GetID(),
|
||||||
|
Title: widget.GetTitle(),
|
||||||
|
Type: widget.GetType(),
|
||||||
|
IsFirst: w == 0,
|
||||||
|
IsLast: w == last,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
detail.Columns = append(detail.Columns, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := adminTemplateData{App: a, Page: &detail, WidgetTypes: allWidgetTypes}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
renderAdminTemplate(w, adminPageTemplate, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminWidget(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pages := a.freshPagesFromDisk()
|
||||||
|
page, pageIdx, exists := findPageBySlug(pages, r.PathValue("page"))
|
||||||
|
if !exists {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
col, err := strconv.Atoi(r.PathValue("col"))
|
||||||
|
if err != nil {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx, err := strconv.Atoi(r.PathValue("idx"))
|
||||||
|
if err != nil {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var widget widget
|
||||||
|
if col == -1 {
|
||||||
|
if idx < 0 || idx >= len(page.HeadWidgets) {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
widget = page.HeadWidgets[idx]
|
||||||
|
} else {
|
||||||
|
if col < 0 || col >= len(page.Columns) {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
column := &page.Columns[col]
|
||||||
|
if idx < 0 || idx >= len(column.Widgets) {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
widget = column.Widgets[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
yamlText, loadErr := loadWidgetYAML(a.ConfigPath, pageIdx, col, idx)
|
||||||
|
|
||||||
|
a.renderWidgetEditor(w, r, editorRenderInput{
|
||||||
|
pageTitle: page.Title,
|
||||||
|
pageSlug: page.Slug,
|
||||||
|
col: col,
|
||||||
|
idx: idx,
|
||||||
|
widgetType: widget.GetType(),
|
||||||
|
widgetTitle: widget.GetTitle(),
|
||||||
|
yamlText: yamlText,
|
||||||
|
loadError: loadErr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexOf(c *hslColorField) string {
|
||||||
|
if c == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.ToHex()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) freshPresetsFromDisk() []adminPresetItem {
|
||||||
|
contents, _, err := parseYAMLIncludes(a.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cfg, err := newConfigFromYAML(contents)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var keys []string
|
||||||
|
for k := range cfg.Theme.Presets.Items() {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
items := make([]adminPresetItem, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
p, _ := cfg.Theme.Presets.Get(key)
|
||||||
|
p.Key = key
|
||||||
|
_ = p.init()
|
||||||
|
items = append(items, adminPresetItem{
|
||||||
|
Key: key,
|
||||||
|
BackgroundColorHex: safeHex(p.BackgroundColor),
|
||||||
|
PrimaryColorHex: safeHex(p.PrimaryColor),
|
||||||
|
PositiveColorHex: safeHex(p.PositiveColor),
|
||||||
|
NegativeColorHex: safeHex(p.NegativeColor),
|
||||||
|
Light: p.Light,
|
||||||
|
ContrastMultiplier: p.ContrastMultiplier,
|
||||||
|
TextSaturationMultiplier: p.TextSaturationMultiplier,
|
||||||
|
PreviewHTML: p.PreviewHTML,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminThemeSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t := a.Config.Theme
|
||||||
|
settings := &adminThemeSettings{
|
||||||
|
BackgroundColorHex: hexOf(t.BackgroundColor),
|
||||||
|
PrimaryColorHex: hexOf(t.PrimaryColor),
|
||||||
|
PositiveColorHex: hexOf(t.PositiveColor),
|
||||||
|
NegativeColorHex: hexOf(t.NegativeColor),
|
||||||
|
Light: t.Light,
|
||||||
|
DisablePicker: t.DisablePicker,
|
||||||
|
ContrastMultiplier: t.ContrastMultiplier,
|
||||||
|
TextSaturationMultiplier: t.TextSaturationMultiplier,
|
||||||
|
CustomCSSFile: t.CustomCSSFile,
|
||||||
|
Presets: a.freshPresetsFromDisk(),
|
||||||
|
CatalogThemes: builtinThemeCatalog,
|
||||||
|
}
|
||||||
|
data := adminTemplateData{App: a, ThemeSettings: settings}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
renderAdminTemplate(w, adminThemeSettingsTemplate, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminThemePreset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := r.PathValue("key")
|
||||||
|
var form adminPresetFormData
|
||||||
|
|
||||||
|
if key == "" || key == "new" {
|
||||||
|
form.IsNew = true
|
||||||
|
q := r.URL.Query()
|
||||||
|
form.Key = q.Get("name")
|
||||||
|
form.BackgroundColorHex = q.Get("bg")
|
||||||
|
form.PrimaryColorHex = q.Get("primary")
|
||||||
|
form.PositiveColorHex = q.Get("positive")
|
||||||
|
form.NegativeColorHex = q.Get("negative")
|
||||||
|
form.Light = q.Get("light") == "true"
|
||||||
|
if cm := q.Get("cm"); cm != "" {
|
||||||
|
if v, err := strconv.ParseFloat(cm, 32); err == nil {
|
||||||
|
form.ContrastMultiplier = float32(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tsm := q.Get("tsm"); tsm != "" {
|
||||||
|
if v, err := strconv.ParseFloat(tsm, 32); err == nil {
|
||||||
|
form.TextSaturationMultiplier = float32(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
presets := a.freshPresetsFromDisk()
|
||||||
|
found := false
|
||||||
|
for _, p := range presets {
|
||||||
|
if p.Key == key {
|
||||||
|
form = adminPresetFormData{
|
||||||
|
Key: p.Key,
|
||||||
|
BackgroundColorHex: p.BackgroundColorHex,
|
||||||
|
PrimaryColorHex: p.PrimaryColorHex,
|
||||||
|
PositiveColorHex: p.PositiveColorHex,
|
||||||
|
NegativeColorHex: p.NegativeColorHex,
|
||||||
|
Light: p.Light,
|
||||||
|
ContrastMultiplier: p.ContrastMultiplier,
|
||||||
|
TextSaturationMultiplier: p.TextSaturationMultiplier,
|
||||||
|
IsNew: false,
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := adminTemplateData{App: a, PresetForm: &form}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
renderAdminTemplate(w, adminThemePresetTemplate, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminSiteSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := a.Config.Branding
|
||||||
|
settings := &adminSiteSettings{
|
||||||
|
AppName: b.AppName,
|
||||||
|
LogoText: b.LogoText,
|
||||||
|
LogoURL: b.LogoURL,
|
||||||
|
FaviconURL: b.FaviconURL,
|
||||||
|
AppIconURL: b.AppIconURL,
|
||||||
|
AppBackgroundColor: b.AppBackgroundColor,
|
||||||
|
HideFooter: b.HideFooter,
|
||||||
|
CustomFooter: string(b.CustomFooter),
|
||||||
|
}
|
||||||
|
data := adminTemplateData{App: a, SiteSettings: settings}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
renderAdminTemplate(w, adminSiteSettingsTemplate, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminPageSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pages := a.freshPagesFromDisk()
|
||||||
|
page, idx, exists := findPageBySlug(pages, r.PathValue("page"))
|
||||||
|
if !exists {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings := &adminPageSettings{
|
||||||
|
Name: page.Title,
|
||||||
|
Slug: page.Slug,
|
||||||
|
Width: page.Width,
|
||||||
|
DesktopNavigationWidth: page.DesktopNavigationWidth,
|
||||||
|
ShowMobileHeader: page.ShowMobileHeader,
|
||||||
|
HideDesktopNavigation: page.HideDesktopNavigation,
|
||||||
|
CenterVertically: page.CenterVertically,
|
||||||
|
}
|
||||||
|
data := adminTemplateData{
|
||||||
|
App: a,
|
||||||
|
Page: &adminPageDetail{Title: page.Title, Slug: page.Slug},
|
||||||
|
PageSettings: settings,
|
||||||
|
IsFirstPage: idx == 0,
|
||||||
|
IsLastPage: idx == len(pages)-1,
|
||||||
|
}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
renderAdminTemplate(w, adminPageSettingsTemplate, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAdminNewWidget(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAccessAllowed(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pages := a.freshPagesFromDisk()
|
||||||
|
page, _, exists := findPageBySlug(pages, r.PathValue("page"))
|
||||||
|
if !exists {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
col, err := strconv.Atoi(r.PathValue("col"))
|
||||||
|
if err != nil || col < 0 || col >= len(page.Columns) {
|
||||||
|
a.handleNotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
widgetType := r.URL.Query().Get("type")
|
||||||
|
if _, err := newWidget(widgetType); err != nil {
|
||||||
|
adminError(w, http.StatusBadRequest, "unknown widget type: "+widgetType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.renderWidgetEditor(w, r, editorRenderInput{
|
||||||
|
pageTitle: page.Title,
|
||||||
|
pageSlug: page.Slug,
|
||||||
|
isNew: true,
|
||||||
|
col: col,
|
||||||
|
widgetType: widgetType,
|
||||||
|
yamlText: widgetStarterYAML(widgetType),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type editorRenderInput struct {
|
||||||
|
pageTitle string
|
||||||
|
pageSlug string
|
||||||
|
isNew bool
|
||||||
|
col int
|
||||||
|
idx int
|
||||||
|
widgetType string
|
||||||
|
widgetTitle string
|
||||||
|
yamlText string
|
||||||
|
loadError string
|
||||||
|
errorMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) renderWidgetEditor(w http.ResponseWriter, r *http.Request, in editorRenderInput) {
|
||||||
|
view := adminWidgetView{
|
||||||
|
ColumnIndex: in.col,
|
||||||
|
WidgetIndex: in.idx,
|
||||||
|
Title: in.widgetTitle,
|
||||||
|
Type: in.widgetType,
|
||||||
|
}
|
||||||
|
detail := adminPageDetail{Title: in.pageTitle, Slug: in.pageSlug}
|
||||||
|
|
||||||
|
data := adminTemplateData{
|
||||||
|
App: a,
|
||||||
|
Page: &detail,
|
||||||
|
Widget: &view,
|
||||||
|
IsNew: in.isNew,
|
||||||
|
WidgetYAML: in.yamlText,
|
||||||
|
LoadError: in.loadError,
|
||||||
|
ErrorMessage: in.errorMessage,
|
||||||
|
FieldReference: widgetFieldReferences[in.widgetType],
|
||||||
|
WidgetExample: widgetStarters[in.widgetType],
|
||||||
|
DocsURL: widgetDocsURL(in.widgetType),
|
||||||
|
}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
renderAdminTemplate(w, adminWidgetTemplate, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// widgetTypeFromYAML extracts just the `type:` field from a YAML mapping.
|
||||||
|
// Used when re-rendering the editor after a save error so docs/reference
|
||||||
|
// match what the user is actually editing.
|
||||||
|
func widgetTypeFromYAML(yamlText string) string {
|
||||||
|
var probe struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
}
|
||||||
|
_ = yaml.Unmarshal([]byte(yamlText), &probe)
|
||||||
|
return probe.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
// freshPagesFromDisk reads the on-disk config and returns the parsed pages
|
||||||
|
// with slugs auto-generated. Admin views use this instead of a.Config.Pages
|
||||||
|
// because the fsnotify watcher debounces 500ms after a save, and during that
|
||||||
|
// window a.Config still reflects the pre-save state. Falls back to a copy
|
||||||
|
// of the cached config on any error.
|
||||||
|
func (a *application) freshPagesFromDisk() []page {
|
||||||
|
contents, _, err := parseYAMLIncludes(a.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return a.Config.Pages
|
||||||
|
}
|
||||||
|
cfg, err := newConfigFromYAML(contents)
|
||||||
|
if err != nil {
|
||||||
|
return a.Config.Pages
|
||||||
|
}
|
||||||
|
for p := range cfg.Pages {
|
||||||
|
if cfg.Pages[p].Slug == "" {
|
||||||
|
cfg.Pages[p].Slug = titleToSlug(cfg.Pages[p].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg.Pages
|
||||||
|
}
|
||||||
|
|
||||||
|
func findPageBySlug(pages []page, slug string) (*page, int, bool) {
|
||||||
|
for i := range pages {
|
||||||
|
if pages[i].Slug == slug {
|
||||||
|
return &pages[i], i, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, -1, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// freshPageIndexBySlug looks up the on-disk page index by slug. Mutating
|
||||||
|
// handlers must use this rather than a.pageIndexBySlug because the cached
|
||||||
|
// a.Config can lag the file by up to ~500ms after a save.
|
||||||
|
func (a *application) freshPageIndexBySlug(slug string) (int, bool) {
|
||||||
|
_, idx, ok := findPageBySlug(a.freshPagesFromDisk(), slug)
|
||||||
|
return idx, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadWidgetYAML reads the on-disk config and returns the marshaled YAML for
|
||||||
|
// one widget. Errors are returned as a string so the template can show them
|
||||||
|
// without preventing the page from rendering.
|
||||||
|
func loadWidgetYAML(path string, pageIdx, col, idx int) (string, string) {
|
||||||
|
editor, err := loadConfigEditor(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
seq, pos, err := editor.widgetSlot(pageIdx, col, idx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
out, err := yaml.Marshal(seq.Content[pos])
|
||||||
|
if err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
return string(out), ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminAccessAllowed enforces that admin endpoints either require auth, or
|
||||||
|
// have been explicitly opted into without auth via config.admin.allow-without-auth.
|
||||||
|
// Returns false if the response has been written and the caller should bail.
|
||||||
|
func (a *application) adminAccessAllowed(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if !a.RequiresAuth && !a.Config.Admin.AllowWithoutAuth {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`<!doctype html><meta charset=utf-8><title>Editing disabled</title>` +
|
||||||
|
`<body style="font-family:sans-serif;padding:2rem;max-width:50rem;line-height:1.5">` +
|
||||||
|
`<h1>Editing is disabled</h1>` +
|
||||||
|
`<p>The edit UI requires authentication. Either:</p>` +
|
||||||
|
`<ul>` +
|
||||||
|
`<li>Configure <code>auth.users</code> in your config (recommended), or</li>` +
|
||||||
|
`<li>Set <code>admin.allow-without-auth: true</code> in your config if you accept that anyone reachable on the network can edit it. (The legacy <code>admin</code> key still works for this option.)</li>` +
|
||||||
|
`</ul>` +
|
||||||
|
`</body>`))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.handleUnauthorizedResponse(w, r, redirectToLogin) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAdminTemplate(w http.ResponseWriter, t *template.Template, data adminTemplateData) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := t.Execute(&buf, data); err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(buf.Bytes())
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
mathrand "math/rand/v2"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const AUTH_SESSION_COOKIE_NAME = "session_token"
|
||||||
|
const AUTH_RATE_LIMIT_WINDOW = 5 * time.Minute
|
||||||
|
const AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5
|
||||||
|
|
||||||
|
const AUTH_TOKEN_SECRET_LENGTH = 32
|
||||||
|
const AUTH_USERNAME_HASH_LENGTH = 32
|
||||||
|
const AUTH_SECRET_KEY_LENGTH = AUTH_TOKEN_SECRET_LENGTH + AUTH_USERNAME_HASH_LENGTH
|
||||||
|
const AUTH_TIMESTAMP_LENGTH = 4 // uint32
|
||||||
|
const AUTH_TOKEN_DATA_LENGTH = AUTH_USERNAME_HASH_LENGTH + AUTH_TIMESTAMP_LENGTH
|
||||||
|
|
||||||
|
// How long the token will be valid for
|
||||||
|
const AUTH_TOKEN_VALID_PERIOD = 14 * 24 * time.Hour // 14 days
|
||||||
|
// How long the token has left before it should be regenerated
|
||||||
|
const AUTH_TOKEN_REGEN_BEFORE = 7 * 24 * time.Hour // 7 days
|
||||||
|
|
||||||
|
var loginPageTemplate = mustParseTemplate("login.html", "document.html", "footer.html")
|
||||||
|
|
||||||
|
type doWhenUnauthorized int
|
||||||
|
|
||||||
|
const (
|
||||||
|
redirectToLogin doWhenUnauthorized = iota
|
||||||
|
showUnauthorizedJSON
|
||||||
|
)
|
||||||
|
|
||||||
|
type failedAuthAttempt struct {
|
||||||
|
attempts int
|
||||||
|
first time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSessionToken(username string, secret []byte, now time.Time) (string, error) {
|
||||||
|
if len(secret) != AUTH_SECRET_KEY_LENGTH {
|
||||||
|
return "", fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameHash, err := computeUsernameHash(username, secret)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make([]byte, AUTH_TOKEN_DATA_LENGTH)
|
||||||
|
copy(data, usernameHash)
|
||||||
|
expires := now.Add(AUTH_TOKEN_VALID_PERIOD).Unix()
|
||||||
|
binary.LittleEndian.PutUint32(data[AUTH_USERNAME_HASH_LENGTH:], uint32(expires))
|
||||||
|
|
||||||
|
h := hmac.New(sha256.New, secret[0:AUTH_TOKEN_SECRET_LENGTH])
|
||||||
|
h.Write(data)
|
||||||
|
|
||||||
|
signature := h.Sum(nil)
|
||||||
|
encodedToken := base64.StdEncoding.EncodeToString(append(data, signature...))
|
||||||
|
// encodedToken ends up being (hashed username + expiration timestamp + signature) encoded as base64
|
||||||
|
|
||||||
|
return encodedToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeUsernameHash(username string, secret []byte) ([]byte, error) {
|
||||||
|
if len(secret) != AUTH_SECRET_KEY_LENGTH {
|
||||||
|
return nil, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := hmac.New(sha256.New, secret[AUTH_TOKEN_SECRET_LENGTH:])
|
||||||
|
h.Write([]byte(username))
|
||||||
|
|
||||||
|
return h.Sum(nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifySessionToken(token string, secretBytes []byte, now time.Time) ([]byte, bool, error) {
|
||||||
|
tokenBytes, err := base64.StdEncoding.DecodeString(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tokenBytes) != AUTH_TOKEN_DATA_LENGTH+32 {
|
||||||
|
return nil, false, fmt.Errorf("token length is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
|
||||||
|
return nil, false, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameHashBytes := tokenBytes[0:AUTH_USERNAME_HASH_LENGTH]
|
||||||
|
timestampBytes := tokenBytes[AUTH_USERNAME_HASH_LENGTH : AUTH_USERNAME_HASH_LENGTH+AUTH_TIMESTAMP_LENGTH]
|
||||||
|
providedSignatureBytes := tokenBytes[AUTH_TOKEN_DATA_LENGTH:]
|
||||||
|
|
||||||
|
h := hmac.New(sha256.New, secretBytes[0:32])
|
||||||
|
h.Write(tokenBytes[0:AUTH_TOKEN_DATA_LENGTH])
|
||||||
|
expectedSignatureBytes := h.Sum(nil)
|
||||||
|
|
||||||
|
if !hmac.Equal(expectedSignatureBytes, providedSignatureBytes) {
|
||||||
|
return nil, false, fmt.Errorf("signature does not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresTimestamp := int64(binary.LittleEndian.Uint32(timestampBytes))
|
||||||
|
if now.Unix() > expiresTimestamp {
|
||||||
|
return nil, false, fmt.Errorf("token has expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
return usernameHashBytes,
|
||||||
|
// True if the token should be regenerated
|
||||||
|
time.Unix(expiresTimestamp, 0).Add(-AUTH_TOKEN_REGEN_BEFORE).Before(now),
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAuthSecretKey(length int) (string, error) {
|
||||||
|
key := make([]byte, length)
|
||||||
|
_, err := rand.Read(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(key), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Content-Type") != "application/json" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
waitOnFailure := 1*time.Second - time.Duration(mathrand.IntN(500))*time.Millisecond
|
||||||
|
|
||||||
|
ip := a.addressOfRequest(r)
|
||||||
|
|
||||||
|
a.authAttemptsMu.Lock()
|
||||||
|
exceededRateLimit, retryAfter := func() (bool, int) {
|
||||||
|
attempt, exists := a.failedAuthAttempts[ip]
|
||||||
|
if !exists {
|
||||||
|
a.failedAuthAttempts[ip] = &failedAuthAttempt{
|
||||||
|
attempts: 1,
|
||||||
|
first: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(attempt.first)
|
||||||
|
if elapsed < AUTH_RATE_LIMIT_WINDOW && attempt.attempts >= AUTH_RATE_LIMIT_MAX_ATTEMPTS {
|
||||||
|
return true, max(1, int(AUTH_RATE_LIMIT_WINDOW.Seconds()-elapsed.Seconds()))
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt.attempts++
|
||||||
|
return false, 0
|
||||||
|
}()
|
||||||
|
|
||||||
|
if exceededRateLimit {
|
||||||
|
a.authAttemptsMu.Unlock()
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// Clean up old failed attempts
|
||||||
|
for ipOfAttempt := range a.failedAuthAttempts {
|
||||||
|
if time.Since(a.failedAuthAttempts[ipOfAttempt].first) > AUTH_RATE_LIMIT_WINDOW {
|
||||||
|
delete(a.failedAuthAttempts, ipOfAttempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.authAttemptsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var creds struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(body, &creds)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logAuthFailure := func() {
|
||||||
|
log.Printf(
|
||||||
|
"Failed login attempt for user '%s' from %s",
|
||||||
|
creds.Username, ip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(creds.Username) == 0 || len(creds.Password) == 0 {
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(creds.Username) > 50 || len(creds.Password) > 100 {
|
||||||
|
logAuthFailure()
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, exists := a.Config.Auth.Users[creds.Username]
|
||||||
|
if !exists {
|
||||||
|
logAuthFailure()
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword(u.PasswordHash, []byte(creds.Password)); err != nil {
|
||||||
|
logAuthFailure()
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := generateSessionToken(creds.Username, a.authSecretKey, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Could not compute session token during login attempt: %v", err)
|
||||||
|
time.Sleep(waitOnFailure)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.setAuthSessionCookie(w, r, token, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
|
||||||
|
|
||||||
|
a.authAttemptsMu.Lock()
|
||||||
|
delete(a.failedAuthAttempts, ip)
|
||||||
|
a.authAttemptsMu.Unlock()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) isAuthorized(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if !a.RequiresAuth {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := r.Cookie(AUTH_SESSION_COOKIE_NAME)
|
||||||
|
if err != nil || token.Value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameHash, shouldRegenerate, err := verifySessionToken(token.Value, a.authSecretKey, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
username, exists := a.usernameHashToUsername[string(usernameHash)]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
_, exists = a.Config.Auth.Users[username]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldRegenerate {
|
||||||
|
newToken, err := generateSessionToken(username, a.authSecretKey, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Could not compute session token during regeneration: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
a.setAuthSessionCookie(w, r, newToken, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles sending the appropriate response for an unauthorized request and returns true if the request was unauthorized
|
||||||
|
func (a *application) handleUnauthorizedResponse(w http.ResponseWriter, r *http.Request, fallback doWhenUnauthorized) bool {
|
||||||
|
if a.isAuthorized(w, r) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch fallback {
|
||||||
|
case redirectToLogin:
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
|
||||||
|
case showUnauthorizedJSON:
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte(`{"error": "Unauthorized"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maybe this should be a POST request instead?
|
||||||
|
func (a *application) handleLogoutRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.setAuthSessionCookie(w, r, "", time.Now().Add(-1*time.Hour))
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) setAuthSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: AUTH_SESSION_COOKIE_NAME,
|
||||||
|
Value: token,
|
||||||
|
Expires: expires,
|
||||||
|
Secure: strings.ToLower(r.Header.Get("X-Forwarded-Proto")) == "https",
|
||||||
|
Path: a.Config.Server.BaseURL + "/",
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
HttpOnly: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) handleLoginPageRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.isAuthorized(w, r) {
|
||||||
|
http.Redirect(w, r, a.Config.Server.BaseURL+"/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &templateData{
|
||||||
|
App: a,
|
||||||
|
}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
|
||||||
|
var responseBytes bytes.Buffer
|
||||||
|
err := loginPageTemplate.Execute(&responseBytes, data)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write(responseBytes.Bytes())
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthTokenGenerationAndVerification(t *testing.T) {
|
||||||
|
secret, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate secret key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretBytes, err := base64.StdEncoding.DecodeString(secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to decode secret key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
|
||||||
|
t.Fatalf("Secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
username := "admin"
|
||||||
|
|
||||||
|
token, err := generateSessionToken(username, secretBytes, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate session token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameHashBytes, shouldRegen, err := verifySessionToken(token, secretBytes, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to verify session token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldRegen {
|
||||||
|
t.Fatal("Token should not need to be regenerated immediately after generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
computedUsernameHash, err := computeUsernameHash(username, secretBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to compute username hash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(usernameHashBytes, computedUsernameHash) {
|
||||||
|
t.Fatal("Username hash does not match the expected value")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test token regeneration
|
||||||
|
timeRightAfterRegenPeriod := now.Add(AUTH_TOKEN_VALID_PERIOD - AUTH_TOKEN_REGEN_BEFORE + 2*time.Second)
|
||||||
|
_, shouldRegen, err = verifySessionToken(token, secretBytes, timeRightAfterRegenPeriod)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Token verification should not fail during regeneration period, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !shouldRegen {
|
||||||
|
t.Fatal("Token should have been marked for regeneration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test token expiration
|
||||||
|
_, _, err = verifySessionToken(token, secretBytes, now.Add(AUTH_TOKEN_VALID_PERIOD+2*time.Second))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected token verification to fail after token expiration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test tampered token
|
||||||
|
decodedToken, err := base64.StdEncoding.DecodeString(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to decode token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If any of the bytes are off by 1, the token should be considered invalid
|
||||||
|
for i := range len(decodedToken) {
|
||||||
|
tampered := make([]byte, len(decodedToken))
|
||||||
|
copy(tampered, decodedToken)
|
||||||
|
tampered[i] += 1
|
||||||
|
|
||||||
|
_, _, err = verifySessionToken(base64.StdEncoding.EncodeToString(tampered), secretBytes, now)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Expected token verification to fail for tampered token at index %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,23 +5,41 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type cliIntent uint8
|
type cliIntent uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
cliIntentServe cliIntent = iota
|
cliIntentVersionPrint cliIntent = iota
|
||||||
cliIntentConfigValidate = iota
|
cliIntentServe
|
||||||
cliIntentConfigPrint = iota
|
cliIntentConfigValidate
|
||||||
cliIntentDiagnose = iota
|
cliIntentConfigPrint
|
||||||
|
cliIntentDiagnose
|
||||||
|
cliIntentSensorsPrint
|
||||||
|
cliIntentMountpointInfo
|
||||||
|
cliIntentSecretMake
|
||||||
|
cliIntentPasswordHash
|
||||||
)
|
)
|
||||||
|
|
||||||
type cliOptions struct {
|
type cliOptions struct {
|
||||||
intent cliIntent
|
intent cliIntent
|
||||||
configPath string
|
configPath string
|
||||||
|
args []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseCliOptions() (*cliOptions, error) {
|
func parseCliOptions() (*cliOptions, error) {
|
||||||
|
var args []string
|
||||||
|
|
||||||
|
args = os.Args[1:]
|
||||||
|
if len(args) == 1 && (args[0] == "--version" || args[0] == "-v" || args[0] == "version") {
|
||||||
|
return &cliOptions{
|
||||||
|
intent: cliIntentVersionPrint,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
flags := flag.NewFlagSet("", flag.ExitOnError)
|
flags := flag.NewFlagSet("", flag.ExitOnError)
|
||||||
flags.Usage = func() {
|
flags.Usage = func() {
|
||||||
fmt.Println("Usage: glance [options] command")
|
fmt.Println("Usage: glance [options] command")
|
||||||
@@ -32,8 +50,13 @@ func parseCliOptions() (*cliOptions, error) {
|
|||||||
fmt.Println("\nCommands:")
|
fmt.Println("\nCommands:")
|
||||||
fmt.Println(" config:validate Validate the config file")
|
fmt.Println(" config:validate Validate the config file")
|
||||||
fmt.Println(" config:print Print the parsed config file with embedded includes")
|
fmt.Println(" config:print Print the parsed config file with embedded includes")
|
||||||
|
fmt.Println(" password:hash <pwd> Hash a password")
|
||||||
|
fmt.Println(" secret:make Generate a random secret key")
|
||||||
|
fmt.Println(" sensors:print List all sensors")
|
||||||
|
fmt.Println(" mountpoint:info Print information about a given mountpoint path")
|
||||||
fmt.Println(" diagnose Run diagnostic checks")
|
fmt.Println(" diagnose Run diagnostic checks")
|
||||||
}
|
}
|
||||||
|
|
||||||
configPath := flags.String("config", "glance.yml", "Set config path")
|
configPath := flags.String("config", "glance.yml", "Set config path")
|
||||||
err := flags.Parse(os.Args[1:])
|
err := flags.Parse(os.Args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -41,7 +64,7 @@ func parseCliOptions() (*cliOptions, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var intent cliIntent
|
var intent cliIntent
|
||||||
var args = flags.Args()
|
args = flags.Args()
|
||||||
unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
|
unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
|
||||||
|
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
@@ -51,8 +74,24 @@ func parseCliOptions() (*cliOptions, error) {
|
|||||||
intent = cliIntentConfigValidate
|
intent = cliIntentConfigValidate
|
||||||
} else if args[0] == "config:print" {
|
} else if args[0] == "config:print" {
|
||||||
intent = cliIntentConfigPrint
|
intent = cliIntentConfigPrint
|
||||||
|
} else if args[0] == "sensors:print" {
|
||||||
|
intent = cliIntentSensorsPrint
|
||||||
} else if args[0] == "diagnose" {
|
} else if args[0] == "diagnose" {
|
||||||
intent = cliIntentDiagnose
|
intent = cliIntentDiagnose
|
||||||
|
} else if args[0] == "secret:make" {
|
||||||
|
intent = cliIntentSecretMake
|
||||||
|
} else {
|
||||||
|
return nil, unknownCommandErr
|
||||||
|
}
|
||||||
|
} else if len(args) == 2 {
|
||||||
|
if args[0] == "password:hash" {
|
||||||
|
intent = cliIntentPasswordHash
|
||||||
|
} else {
|
||||||
|
return nil, unknownCommandErr
|
||||||
|
}
|
||||||
|
} else if len(args) == 2 {
|
||||||
|
if args[0] == "mountpoint:info" {
|
||||||
|
intent = cliIntentMountpointInfo
|
||||||
} else {
|
} else {
|
||||||
return nil, unknownCommandErr
|
return nil, unknownCommandErr
|
||||||
}
|
}
|
||||||
@@ -63,5 +102,54 @@ func parseCliOptions() (*cliOptions, error) {
|
|||||||
return &cliOptions{
|
return &cliOptions{
|
||||||
intent: intent,
|
intent: intent,
|
||||||
configPath: *configPath,
|
configPath: *configPath,
|
||||||
|
args: args,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cliSensorsPrint() int {
|
||||||
|
tempSensors, err := sensors.SensorsTemperatures()
|
||||||
|
if err != nil {
|
||||||
|
if warns, ok := err.(*sensors.Warnings); ok {
|
||||||
|
fmt.Printf("Could not retrieve information for some sensors (%v):\n", err)
|
||||||
|
for _, w := range warns.List {
|
||||||
|
fmt.Printf(" - %v\n", w)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Failed to retrieve sensor information: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tempSensors) == 0 {
|
||||||
|
fmt.Println("No sensors found")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Sensors found:")
|
||||||
|
for _, sensor := range tempSensors {
|
||||||
|
fmt.Printf(" %s: %.1f°C\n", sensor.SensorKey, sensor.Temperature)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func cliMountpointInfo(requestedPath string) int {
|
||||||
|
usage, err := disk.Usage(requestedPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to retrieve info for path %s: %v\n", requestedPath, err)
|
||||||
|
if warns, ok := err.(*disk.Warnings); ok {
|
||||||
|
for _, w := range warns.List {
|
||||||
|
fmt.Printf(" - %v\n", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Path:", usage.Path)
|
||||||
|
fmt.Println("FS type:", ternary(usage.Fstype == "", "unknown", usage.Fstype))
|
||||||
|
fmt.Printf("Used percent: %.1f%%\n", usage.UsedPercent)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package glance
|
|||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -13,7 +14,7 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
|
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?([\d\.]+)(?: |,)+([\d\.]+)%?(?: |,)+([\d\.]+)%?\)?$`)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
hslHueMax = 360
|
hslHueMax = 360
|
||||||
@@ -22,13 +23,27 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type hslColorField struct {
|
type hslColorField struct {
|
||||||
Hue uint16
|
H float64
|
||||||
Saturation uint8
|
S float64
|
||||||
Lightness uint8
|
L float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *hslColorField) String() string {
|
func (c *hslColorField) String() string {
|
||||||
return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness)
|
return fmt.Sprintf("hsl(%.1f, %.1f%%, %.1f%%)", c.H, c.S, c.L)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *hslColorField) ToHex() string {
|
||||||
|
return hslToHex(c.H, c.S, c.L)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c1 *hslColorField) SameAs(c2 *hslColorField) bool {
|
||||||
|
if c1 == nil && c2 == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if c1 == nil || c2 == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return c1.H == c2.H && c1.S == c2.S && c1.L == c2.L
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
||||||
@@ -44,7 +59,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
return fmt.Errorf("invalid HSL color format: %s", value)
|
return fmt.Errorf("invalid HSL color format: %s", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
hue, err := strconv.ParseUint(matches[1], 10, 16)
|
hue, err := strconv.ParseFloat(matches[1], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -53,7 +68,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax)
|
return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax)
|
||||||
}
|
}
|
||||||
|
|
||||||
saturation, err := strconv.ParseUint(matches[2], 10, 8)
|
saturation, err := strconv.ParseFloat(matches[2], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -62,7 +77,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax)
|
return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax)
|
||||||
}
|
}
|
||||||
|
|
||||||
lightness, err := strconv.ParseUint(matches[3], 10, 8)
|
lightness, err := strconv.ParseFloat(matches[3], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -71,9 +86,9 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
|
return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Hue = uint16(hue)
|
c.H = hue
|
||||||
c.Saturation = uint8(saturation)
|
c.S = saturation
|
||||||
c.Lightness = uint8(lightness)
|
c.L = lightness
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -115,32 +130,25 @@ func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type customIconField struct {
|
type customIconField struct {
|
||||||
URL string
|
URL template.URL
|
||||||
IsFlatIcon bool
|
AutoInvert bool
|
||||||
// TODO: along with whether the icon is flat, we also need to know
|
|
||||||
// whether the icon is black or white by default in order to properly
|
|
||||||
// invert the color based on the theme being light or dark
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCustomIconField(value string) customIconField {
|
func newCustomIconField(value string) customIconField {
|
||||||
|
const autoInvertPrefix = "auto-invert "
|
||||||
field := customIconField{}
|
field := customIconField{}
|
||||||
|
|
||||||
|
if strings.HasPrefix(value, autoInvertPrefix) {
|
||||||
|
field.AutoInvert = true
|
||||||
|
value = strings.TrimPrefix(value, autoInvertPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
prefix, icon, found := strings.Cut(value, ":")
|
prefix, icon, found := strings.Cut(value, ":")
|
||||||
if !found {
|
if !found {
|
||||||
field.URL = value
|
field.URL = template.URL(value)
|
||||||
return field
|
return field
|
||||||
}
|
}
|
||||||
|
|
||||||
switch prefix {
|
|
||||||
case "si":
|
|
||||||
field.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg"
|
|
||||||
field.IsFlatIcon = true
|
|
||||||
case "di", "sh":
|
|
||||||
// syntax: di:<icon_name>[.svg|.png]
|
|
||||||
// syntax: sh:<icon_name>[.svg|.png]
|
|
||||||
// if the icon name is specified without extension, it is assumed to be wanting the SVG icon
|
|
||||||
// otherwise, specify the extension of either .svg or .png to use either of the CDN offerings
|
|
||||||
// any other extension will be interpreted as .svg
|
|
||||||
basename, ext, found := strings.Cut(icon, ".")
|
basename, ext, found := strings.Cut(icon, ".")
|
||||||
if !found {
|
if !found {
|
||||||
ext = "svg"
|
ext = "svg"
|
||||||
@@ -151,13 +159,19 @@ func newCustomIconField(value string) customIconField {
|
|||||||
ext = "svg"
|
ext = "svg"
|
||||||
}
|
}
|
||||||
|
|
||||||
if prefix == "di" {
|
switch prefix {
|
||||||
field.URL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext
|
case "si":
|
||||||
} else {
|
field.AutoInvert = true
|
||||||
field.URL = "https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext
|
field.URL = template.URL("https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + basename + ".svg")
|
||||||
}
|
case "di":
|
||||||
|
field.URL = template.URL("https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext)
|
||||||
|
case "mdi":
|
||||||
|
field.AutoInvert = true
|
||||||
|
field.URL = template.URL("https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/" + basename + ".svg")
|
||||||
|
case "sh":
|
||||||
|
field.URL = template.URL("https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext)
|
||||||
default:
|
default:
|
||||||
field.URL = value
|
field.URL = template.URL(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return field
|
return field
|
||||||
@@ -219,3 +233,58 @@ func (p *proxyOptionsField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type queryParametersField map[string][]string
|
||||||
|
|
||||||
|
func (q *queryParametersField) UnmarshalYAML(node *yaml.Node) error {
|
||||||
|
var decoded map[string]any
|
||||||
|
|
||||||
|
if err := node.Decode(&decoded); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*q = make(queryParametersField)
|
||||||
|
|
||||||
|
// TODO: refactor the duplication in the switch cases if any more types get added
|
||||||
|
for key, value := range decoded {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
(*q)[key] = []string{v}
|
||||||
|
case int, int8, int16, int32, int64, float32, float64:
|
||||||
|
(*q)[key] = []string{fmt.Sprintf("%v", v)}
|
||||||
|
case bool:
|
||||||
|
(*q)[key] = []string{fmt.Sprintf("%t", v)}
|
||||||
|
case []string:
|
||||||
|
(*q)[key] = append((*q)[key], v...)
|
||||||
|
case []any:
|
||||||
|
for _, item := range v {
|
||||||
|
switch item := item.(type) {
|
||||||
|
case string:
|
||||||
|
(*q)[key] = append((*q)[key], item)
|
||||||
|
case int, int8, int16, int32, int64, float32, float64:
|
||||||
|
(*q)[key] = append((*q)[key], fmt.Sprintf("%v", item))
|
||||||
|
case bool:
|
||||||
|
(*q)[key] = append((*q)[key], fmt.Sprintf("%t", item))
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid query parameter value type: %T", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid query parameter value type: %T", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *queryParametersField) toQueryString() string {
|
||||||
|
query := url.Values{}
|
||||||
|
|
||||||
|
for key, values := range *q {
|
||||||
|
for _, value := range values {
|
||||||
|
query.Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package glance
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"iter"
|
||||||
"log"
|
"log"
|
||||||
"maps"
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
@@ -17,28 +19,38 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT = 20
|
||||||
|
|
||||||
|
const (
|
||||||
|
configVarTypeEnv = "env"
|
||||||
|
configVarTypeSecret = "secret"
|
||||||
|
configVarTypeFileFromEnv = "readFileFromEnv"
|
||||||
|
)
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
Server struct {
|
Server struct {
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
Port uint16 `yaml:"port"`
|
Port uint16 `yaml:"port"`
|
||||||
|
Proxied bool `yaml:"proxied"`
|
||||||
AssetsPath string `yaml:"assets-path"`
|
AssetsPath string `yaml:"assets-path"`
|
||||||
BaseURL string `yaml:"base-url"`
|
BaseURL string `yaml:"base-url"`
|
||||||
StartedAt time.Time `yaml:"-"` // used in custom css file
|
|
||||||
} `yaml:"server"`
|
} `yaml:"server"`
|
||||||
|
|
||||||
|
Auth struct {
|
||||||
|
SecretKey string `yaml:"secret-key"`
|
||||||
|
Users map[string]*user `yaml:"users"`
|
||||||
|
} `yaml:"auth"`
|
||||||
|
|
||||||
Document struct {
|
Document struct {
|
||||||
Head template.HTML `yaml:"head"`
|
Head template.HTML `yaml:"head"`
|
||||||
} `yaml:"document"`
|
} `yaml:"document"`
|
||||||
|
|
||||||
Theme struct {
|
Theme struct {
|
||||||
BackgroundColor *hslColorField `yaml:"background-color"`
|
themeProperties `yaml:",inline"`
|
||||||
PrimaryColor *hslColorField `yaml:"primary-color"`
|
|
||||||
PositiveColor *hslColorField `yaml:"positive-color"`
|
|
||||||
NegativeColor *hslColorField `yaml:"negative-color"`
|
|
||||||
Light bool `yaml:"light"`
|
|
||||||
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
|
||||||
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
|
||||||
CustomCSSFile string `yaml:"custom-css-file"`
|
CustomCSSFile string `yaml:"custom-css-file"`
|
||||||
|
|
||||||
|
DisablePicker bool `yaml:"disable-picker"`
|
||||||
|
Presets orderedYAMLMap[string, *themeProperties] `yaml:"presets"`
|
||||||
} `yaml:"theme"`
|
} `yaml:"theme"`
|
||||||
|
|
||||||
Branding struct {
|
Branding struct {
|
||||||
@@ -47,19 +59,36 @@ type config struct {
|
|||||||
LogoText string `yaml:"logo-text"`
|
LogoText string `yaml:"logo-text"`
|
||||||
LogoURL string `yaml:"logo-url"`
|
LogoURL string `yaml:"logo-url"`
|
||||||
FaviconURL string `yaml:"favicon-url"`
|
FaviconURL string `yaml:"favicon-url"`
|
||||||
|
FaviconType string `yaml:"-"`
|
||||||
|
AppName string `yaml:"app-name"`
|
||||||
|
AppIconURL string `yaml:"app-icon-url"`
|
||||||
|
AppBackgroundColor string `yaml:"app-background-color"`
|
||||||
} `yaml:"branding"`
|
} `yaml:"branding"`
|
||||||
|
|
||||||
|
Admin struct {
|
||||||
|
// AllowWithoutAuth permits access to /admin even when auth.users is empty.
|
||||||
|
// Off by default because admin can rewrite the config file.
|
||||||
|
AllowWithoutAuth bool `yaml:"allow-without-auth"`
|
||||||
|
} `yaml:"admin"`
|
||||||
|
|
||||||
Pages []page `yaml:"pages"`
|
Pages []page `yaml:"pages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type user struct {
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
PasswordHashString string `yaml:"password-hash"`
|
||||||
|
PasswordHash []byte `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
type page struct {
|
type page struct {
|
||||||
Title string `yaml:"name"`
|
Title string `yaml:"name"`
|
||||||
Slug string `yaml:"slug"`
|
Slug string `yaml:"slug"`
|
||||||
Width string `yaml:"width"`
|
Width string `yaml:"width"`
|
||||||
|
DesktopNavigationWidth string `yaml:"desktop-navigation-width"`
|
||||||
ShowMobileHeader bool `yaml:"show-mobile-header"`
|
ShowMobileHeader bool `yaml:"show-mobile-header"`
|
||||||
ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"`
|
|
||||||
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
||||||
CenterVertically bool `yaml:"center-vertically"`
|
CenterVertically bool `yaml:"center-vertically"`
|
||||||
|
HeadWidgets widgets `yaml:"head-widgets"`
|
||||||
Columns []struct {
|
Columns []struct {
|
||||||
Size string `yaml:"size"`
|
Size string `yaml:"size"`
|
||||||
Widgets widgets `yaml:"widgets"`
|
Widgets widgets `yaml:"widgets"`
|
||||||
@@ -69,7 +98,7 @@ type page struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newConfigFromYAML(contents []byte) (*config, error) {
|
func newConfigFromYAML(contents []byte) (*config, error) {
|
||||||
contents, err := parseConfigEnvVariables(contents)
|
contents, err := parseConfigVariables(contents)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -87,6 +116,12 @@ func newConfigFromYAML(contents []byte) (*config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for p := range config.Pages {
|
for p := range config.Pages {
|
||||||
|
for w := range config.Pages[p].HeadWidgets {
|
||||||
|
if err := config.Pages[p].HeadWidgets[w].initialize(); err != nil {
|
||||||
|
return nil, formatWidgetInitError(err, config.Pages[p].HeadWidgets[w])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for c := range config.Pages[p].Columns {
|
for c := range config.Pages[p].Columns {
|
||||||
for w := range config.Pages[p].Columns[c].Widgets {
|
for w := range config.Pages[p].Columns[c].Widgets {
|
||||||
if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil {
|
if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil {
|
||||||
@@ -99,23 +134,33 @@ func newConfigFromYAML(contents []byte) (*config, error) {
|
|||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: change the pattern so that it doesn't match commented out lines
|
var envVariableNamePattern = regexp.MustCompile(`^[A-Z0-9_]+$`)
|
||||||
var configEnvVariablePattern = regexp.MustCompile(`(^|.)\$\{([A-Z0-9_]+)\}`)
|
var configVariablePattern = regexp.MustCompile(`(^|.)\$\{(?:([a-zA-Z]+):)?([a-zA-Z0-9_-]+)\}`)
|
||||||
|
|
||||||
func parseConfigEnvVariables(contents []byte) ([]byte, error) {
|
// Parses variables defined in the config such as:
|
||||||
|
// ${API_KEY} - gets replaced with the value of the API_KEY environment variable
|
||||||
|
// \${API_KEY} - escaped, gets used as is without the \ in the config
|
||||||
|
// ${secret:api_key} - value gets loaded from /run/secrets/api_key
|
||||||
|
// ${readFileFromEnv:PATH_TO_SECRET} - value gets loaded from the file path specified in the environment variable PATH_TO_SECRET
|
||||||
|
//
|
||||||
|
// TODO: don't match against commented out sections, not sure exactly how since
|
||||||
|
// variables can be placed anywhere and used to modify the YAML structure itself
|
||||||
|
func parseConfigVariables(contents []byte) ([]byte, error) {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
replaced := configEnvVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte {
|
replaced := configVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := configEnvVariablePattern.FindSubmatch(match)
|
groups := configVariablePattern.FindSubmatch(match)
|
||||||
if len(groups) != 3 {
|
if len(groups) != 4 {
|
||||||
|
// we can't handle this match, this shouldn't happen unless the number of groups
|
||||||
|
// in the regex has been changed without updating the below code
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
|
|
||||||
prefix, key := string(groups[1]), string(groups[2])
|
prefix := string(groups[1])
|
||||||
if prefix == `\` {
|
if prefix == `\` {
|
||||||
if len(match) >= 2 {
|
if len(match) >= 2 {
|
||||||
return match[1:]
|
return match[1:]
|
||||||
@@ -124,13 +169,20 @@ func parseConfigEnvVariables(contents []byte) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
value, found := os.LookupEnv(key)
|
typeAsString, variableName := string(groups[2]), string(groups[3])
|
||||||
if !found {
|
variableType := ternary(typeAsString == "", configVarTypeEnv, typeAsString)
|
||||||
err = fmt.Errorf("environment variable %s not found", key)
|
|
||||||
|
parsedValue, returnOriginal, localErr := parseConfigVariableOfType(variableType, variableName)
|
||||||
|
if localErr != nil {
|
||||||
|
err = fmt.Errorf("parsing variable: %v", localErr)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return []byte(prefix + value)
|
if returnOriginal {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
return []byte(prefix + parsedValue)
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,33 +192,90 @@ func parseConfigEnvVariables(contents []byte) ([]byte, error) {
|
|||||||
return replaced, nil
|
return replaced, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the bool return value is true, it indicates that the caller should use the original value
|
||||||
|
func parseConfigVariableOfType(variableType, variableName string) (string, bool, error) {
|
||||||
|
switch variableType {
|
||||||
|
case configVarTypeEnv:
|
||||||
|
if !envVariableNamePattern.MatchString(variableName) {
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
v, found := os.LookupEnv(variableName)
|
||||||
|
if !found {
|
||||||
|
return "", false, fmt.Errorf("environment variable %s not found", variableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return v, false, nil
|
||||||
|
case configVarTypeSecret:
|
||||||
|
secretPath := filepath.Join("/run/secrets", variableName)
|
||||||
|
secret, err := os.ReadFile(secretPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("reading secret file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(string(secret)), false, nil
|
||||||
|
case configVarTypeFileFromEnv:
|
||||||
|
if !envVariableNamePattern.MatchString(variableName) {
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath, found := os.LookupEnv(variableName)
|
||||||
|
if !found {
|
||||||
|
return "", false, fmt.Errorf("readFileFromEnv: environment variable %s not found", variableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filepath.IsAbs(filePath) {
|
||||||
|
return "", false, fmt.Errorf("readFileFromEnv: file path %s is not absolute", filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileContents, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("readFileFromEnv: reading file from %s: %v", variableName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(string(fileContents)), false, nil
|
||||||
|
default:
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func formatWidgetInitError(err error, w widget) error {
|
func formatWidgetInitError(err error, w widget) error {
|
||||||
return fmt.Errorf("%s widget: %v", w.GetType(), err)
|
return fmt.Errorf("%s widget: %v", w.GetType(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`)
|
var configIncludePattern = regexp.MustCompile(`(?m)^([ \t]*)(?:-[ \t]*)?(?:!|\$)include:[ \t]*(.+)$`)
|
||||||
|
|
||||||
func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
|
func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
|
||||||
|
return recursiveParseYAMLIncludes(mainFilePath, nil, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recursiveParseYAMLIncludes(mainFilePath string, includes map[string]struct{}, depth int) ([]byte, map[string]struct{}, error) {
|
||||||
|
if depth > CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT {
|
||||||
|
return nil, nil, fmt.Errorf("recursion depth limit of %d reached", CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
mainFileContents, err := os.ReadFile(mainFilePath)
|
mainFileContents, err := os.ReadFile(mainFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("reading main YAML file: %w", err)
|
return nil, nil, fmt.Errorf("reading %s: %w", mainFilePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mainFileAbsPath, err := filepath.Abs(mainFilePath)
|
mainFileAbsPath, err := filepath.Abs(mainFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("getting absolute path of main YAML file: %w", err)
|
return nil, nil, fmt.Errorf("getting absolute path of %s: %w", mainFilePath, err)
|
||||||
}
|
}
|
||||||
mainFileDir := filepath.Dir(mainFileAbsPath)
|
mainFileDir := filepath.Dir(mainFileAbsPath)
|
||||||
|
|
||||||
includes := make(map[string]struct{})
|
if includes == nil {
|
||||||
|
includes = make(map[string]struct{})
|
||||||
|
}
|
||||||
var includesLastErr error
|
var includesLastErr error
|
||||||
|
|
||||||
mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
|
mainFileContents = configIncludePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
|
||||||
if includesLastErr != nil {
|
if includesLastErr != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
matches := includePattern.FindSubmatch(match)
|
matches := configIncludePattern.FindSubmatch(match)
|
||||||
if len(matches) != 3 {
|
if len(matches) != 3 {
|
||||||
includesLastErr = fmt.Errorf("invalid include match: %v", matches)
|
includesLastErr = fmt.Errorf("invalid include match: %v", matches)
|
||||||
return nil
|
return nil
|
||||||
@@ -181,13 +290,14 @@ func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error)
|
|||||||
var fileContents []byte
|
var fileContents []byte
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
fileContents, err = os.ReadFile(includeFilePath)
|
includes[includeFilePath] = struct{}{}
|
||||||
|
|
||||||
|
fileContents, includes, err = recursiveParseYAMLIncludes(includeFilePath, includes, depth+1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
includesLastErr = fmt.Errorf("reading included file %s: %w", includeFilePath, err)
|
includesLastErr = err
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
includes[includeFilePath] = struct{}{}
|
|
||||||
return []byte(prefixStringLines(indent, string(fileContents)))
|
return []byte(prefixStringLines(indent, string(fileContents)))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -242,7 +352,7 @@ func configFilesWatcher(
|
|||||||
// needed for lastContents and lastIncludes because they get updated in multiple goroutines
|
// needed for lastContents and lastIncludes because they get updated in multiple goroutines
|
||||||
mu := sync.Mutex{}
|
mu := sync.Mutex{}
|
||||||
|
|
||||||
checkForContentChangesBeforeCallback := func() {
|
parseAndCompareBeforeCallback := func() {
|
||||||
currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath)
|
currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
onErr(fmt.Errorf("parsing main file contents for comparison: %w", err))
|
onErr(fmt.Errorf("parsing main file contents for comparison: %w", err))
|
||||||
@@ -268,15 +378,22 @@ func configFilesWatcher(
|
|||||||
|
|
||||||
const debounceDuration = 500 * time.Millisecond
|
const debounceDuration = 500 * time.Millisecond
|
||||||
var debounceTimer *time.Timer
|
var debounceTimer *time.Timer
|
||||||
debouncedCallback := func() {
|
debouncedParseAndCompareBeforeCallback := func() {
|
||||||
if debounceTimer != nil {
|
if debounceTimer != nil {
|
||||||
debounceTimer.Stop()
|
debounceTimer.Stop()
|
||||||
debounceTimer.Reset(debounceDuration)
|
debounceTimer.Reset(debounceDuration)
|
||||||
} else {
|
} else {
|
||||||
debounceTimer = time.AfterFunc(debounceDuration, checkForContentChangesBeforeCallback)
|
debounceTimer = time.AfterFunc(debounceDuration, parseAndCompareBeforeCallback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteLastInclude := func(filePath string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
fileAbsPath, _ := filepath.Abs(filePath)
|
||||||
|
delete(lastIncludes, fileAbsPath)
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -285,16 +402,33 @@ func configFilesWatcher(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if event.Has(fsnotify.Write) {
|
if event.Has(fsnotify.Write) {
|
||||||
debouncedCallback()
|
debouncedParseAndCompareBeforeCallback()
|
||||||
} else if event.Has(fsnotify.Remove) {
|
} else if event.Has(fsnotify.Rename) {
|
||||||
func() {
|
// on linux the file will no longer be watched after a rename, on windows
|
||||||
mu.Lock()
|
// it will continue to be watched with the new name but we have no access to
|
||||||
defer mu.Unlock()
|
// the new name in this event in order to stop watching it manually and match the
|
||||||
fileAbsPath, _ := filepath.Abs(event.Name)
|
// behavior in linux, may lead to weird unintended behaviors on windows as we're
|
||||||
delete(lastIncludes, fileAbsPath)
|
// only handling renames from linux's perspective
|
||||||
}()
|
// see https://github.com/fsnotify/fsnotify/issues/255
|
||||||
|
|
||||||
debouncedCallback()
|
// remove the old file from our manually tracked includes, calling
|
||||||
|
// debouncedParseAndCompareBeforeCallback will re-add it if it's still
|
||||||
|
// required after it triggers
|
||||||
|
deleteLastInclude(event.Name)
|
||||||
|
|
||||||
|
// wait for file to maybe get created again
|
||||||
|
// see https://github.com/glanceapp/glance/pull/358
|
||||||
|
for range 10 {
|
||||||
|
if _, err := os.Stat(event.Name); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
debouncedParseAndCompareBeforeCallback()
|
||||||
|
} else if event.Has(fsnotify.Remove) {
|
||||||
|
deleteLastInclude(event.Name)
|
||||||
|
debouncedParseAndCompareBeforeCallback()
|
||||||
}
|
}
|
||||||
case err, isOpen := <-watcher.Errors:
|
case err, isOpen := <-watcher.Errors:
|
||||||
if !isOpen {
|
if !isOpen {
|
||||||
@@ -316,11 +450,39 @@ func configFilesWatcher(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Refactor, we currently validate in two different places, this being
|
||||||
|
// one of them, which doesn't modify the data and only checks for logical errors
|
||||||
|
// and then again when creating the application which does modify the data and do
|
||||||
|
// further validation. Would be better if validation was done in a single place.
|
||||||
func isConfigStateValid(config *config) error {
|
func isConfigStateValid(config *config) error {
|
||||||
if len(config.Pages) == 0 {
|
if len(config.Pages) == 0 {
|
||||||
return fmt.Errorf("no pages configured")
|
return fmt.Errorf("no pages configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(config.Auth.Users) > 0 && config.Auth.SecretKey == "" {
|
||||||
|
return fmt.Errorf("secret-key must be set when users are configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
for username := range config.Auth.Users {
|
||||||
|
if username == "" {
|
||||||
|
return fmt.Errorf("user has no name")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(username) < 3 {
|
||||||
|
return errors.New("usernames must be at least 3 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := config.Auth.Users[username]
|
||||||
|
|
||||||
|
if user.Password == "" {
|
||||||
|
if user.PasswordHashString == "" {
|
||||||
|
return fmt.Errorf("user %s must have a password or a password-hash set", username)
|
||||||
|
}
|
||||||
|
} else if len(user.Password) < 6 {
|
||||||
|
return fmt.Errorf("the password for %s must be at least 6 characters", username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if config.Server.AssetsPath != "" {
|
if config.Server.AssetsPath != "" {
|
||||||
if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) {
|
if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) {
|
||||||
return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath)
|
return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath)
|
||||||
@@ -328,36 +490,46 @@ func isConfigStateValid(config *config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i := range config.Pages {
|
for i := range config.Pages {
|
||||||
if config.Pages[i].Title == "" {
|
page := &config.Pages[i]
|
||||||
|
|
||||||
|
if page.Title == "" {
|
||||||
return fmt.Errorf("page %d has no name", i+1)
|
return fmt.Errorf("page %d has no name", i+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") {
|
if page.Width != "" && (page.Width != "wide" && page.Width != "slim" && page.Width != "default") {
|
||||||
return fmt.Errorf("page %d: width can only be either wide or slim", i+1)
|
return fmt.Errorf("page %d: width can only be either wide or slim", i+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(config.Pages[i].Columns) == 0 {
|
if page.DesktopNavigationWidth != "" {
|
||||||
|
if page.DesktopNavigationWidth != "wide" && page.DesktopNavigationWidth != "slim" && page.DesktopNavigationWidth != "default" {
|
||||||
|
return fmt.Errorf("page %d: desktop-navigation-width can only be either wide or slim", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(page.Columns) == 0 {
|
||||||
return fmt.Errorf("page %d has no columns", i+1)
|
return fmt.Errorf("page %d has no columns", i+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Pages[i].Width == "slim" {
|
if page.Width == "slim" {
|
||||||
if len(config.Pages[i].Columns) > 2 {
|
if len(page.Columns) > 2 {
|
||||||
return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1)
|
return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if len(config.Pages[i].Columns) > 3 {
|
if len(page.Columns) > 3 {
|
||||||
return fmt.Errorf("page %d has more than 3 columns", i+1)
|
return fmt.Errorf("page %d has more than 3 columns", i+1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
columnSizesCount := make(map[string]int)
|
columnSizesCount := make(map[string]int)
|
||||||
|
|
||||||
for j := range config.Pages[i].Columns {
|
for j := range page.Columns {
|
||||||
if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" {
|
column := &page.Columns[j]
|
||||||
|
|
||||||
|
if column.Size != "small" && column.Size != "full" {
|
||||||
return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1)
|
return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
columnSizesCount[config.Pages[i].Columns[j].Size]++
|
columnSizesCount[page.Columns[j].Size]++
|
||||||
}
|
}
|
||||||
|
|
||||||
full := columnSizesCount["full"]
|
full := columnSizesCount["full"]
|
||||||
@@ -369,3 +541,103 @@ func isConfigStateValid(config *config) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read-only way to store ordered maps from a YAML structure
|
||||||
|
type orderedYAMLMap[K comparable, V any] struct {
|
||||||
|
keys []K
|
||||||
|
data map[K]V
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOrderedYAMLMap[K comparable, V any](keys []K, values []V) (*orderedYAMLMap[K, V], error) {
|
||||||
|
if len(keys) != len(values) {
|
||||||
|
return nil, fmt.Errorf("keys and values must have the same length")
|
||||||
|
}
|
||||||
|
|
||||||
|
om := &orderedYAMLMap[K, V]{
|
||||||
|
keys: make([]K, len(keys)),
|
||||||
|
data: make(map[K]V, len(keys)),
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(om.keys, keys)
|
||||||
|
|
||||||
|
for i := range keys {
|
||||||
|
om.data[keys[i]] = values[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return om, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (om *orderedYAMLMap[K, V]) Items() iter.Seq2[K, V] {
|
||||||
|
return func(yield func(K, V) bool) {
|
||||||
|
for _, key := range om.keys {
|
||||||
|
value, ok := om.data[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !yield(key, value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (om *orderedYAMLMap[K, V]) Get(key K) (V, bool) {
|
||||||
|
value, ok := om.data[key]
|
||||||
|
return value, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *orderedYAMLMap[K, V]) Merge(other *orderedYAMLMap[K, V]) *orderedYAMLMap[K, V] {
|
||||||
|
merged := &orderedYAMLMap[K, V]{
|
||||||
|
keys: make([]K, 0, len(self.keys)+len(other.keys)),
|
||||||
|
data: make(map[K]V, len(self.data)+len(other.data)),
|
||||||
|
}
|
||||||
|
|
||||||
|
merged.keys = append(merged.keys, self.keys...)
|
||||||
|
maps.Copy(merged.data, self.data)
|
||||||
|
|
||||||
|
for _, key := range other.keys {
|
||||||
|
if _, exists := self.data[key]; !exists {
|
||||||
|
merged.keys = append(merged.keys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maps.Copy(merged.data, other.data)
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func (om *orderedYAMLMap[K, V]) UnmarshalYAML(node *yaml.Node) error {
|
||||||
|
if node.Kind != yaml.MappingNode {
|
||||||
|
return fmt.Errorf("orderedMap: expected mapping node, got %d", node.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(node.Content)%2 != 0 {
|
||||||
|
return fmt.Errorf("orderedMap: expected even number of content items, got %d", len(node.Content))
|
||||||
|
}
|
||||||
|
|
||||||
|
om.keys = make([]K, len(node.Content)/2)
|
||||||
|
om.data = make(map[K]V, len(node.Content)/2)
|
||||||
|
|
||||||
|
for i := 0; i < len(node.Content); i += 2 {
|
||||||
|
keyNode := node.Content[i]
|
||||||
|
valueNode := node.Content[i+1]
|
||||||
|
|
||||||
|
var key K
|
||||||
|
if err := keyNode.Decode(&key); err != nil {
|
||||||
|
return fmt.Errorf("orderedMap: decoding key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := om.data[key]; ok {
|
||||||
|
return fmt.Errorf("orderedMap: duplicate key %v", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
var value V
|
||||||
|
if err := valueNode.Decode(&value); err != nil {
|
||||||
|
return fmt.Errorf("orderedMap: decoding value: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
(*om).keys[i/2] = key
|
||||||
|
(*om).data[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const httpTestRequestTimeout = 10 * time.Second
|
const httpTestRequestTimeout = 15 * time.Second
|
||||||
|
|
||||||
var diagnosticSteps = []diagnosticStep{
|
var diagnosticSteps = []diagnosticStep{
|
||||||
{
|
{
|
||||||
@@ -75,13 +75,17 @@ var diagnosticSteps = []diagnosticStep{
|
|||||||
{
|
{
|
||||||
name: "fetch data from Reddit API",
|
name: "fetch data from Reddit API",
|
||||||
fn: func() (string, error) {
|
fn: func() (string, error) {
|
||||||
return testHttpRequest("GET", "https://www.reddit.com/search.json", 200)
|
return testHttpRequestWithHeaders("GET", "https://www.reddit.com/search.json", map[string]string{
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0",
|
||||||
|
}, 200)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "fetch data from Yahoo finance API",
|
name: "fetch data from Yahoo finance API",
|
||||||
fn: func() (string, error) {
|
fn: func() (string, error) {
|
||||||
return testHttpRequest("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", 200)
|
return testHttpRequestWithHeaders("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", map[string]string{
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0",
|
||||||
|
}, 200)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -103,7 +107,7 @@ func runDiagnostic() {
|
|||||||
fmt.Println("Glance version: " + buildVersion)
|
fmt.Println("Glance version: " + buildVersion)
|
||||||
fmt.Println("Go version: " + runtime.Version())
|
fmt.Println("Go version: " + runtime.Version())
|
||||||
fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU())
|
fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU())
|
||||||
fmt.Println("In Docker container: " + boolToString(isRunningInsideDockerContainer(), "yes", "no"))
|
fmt.Println("In Docker container: " + ternary(isRunningInsideDockerContainer(), "yes", "no"))
|
||||||
|
|
||||||
fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds()))
|
fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds()))
|
||||||
|
|
||||||
@@ -129,7 +133,7 @@ func runDiagnostic() {
|
|||||||
|
|
||||||
fmt.Printf(
|
fmt.Printf(
|
||||||
"%s %s %s| %dms\n",
|
"%s %s %s| %dms\n",
|
||||||
boolToString(step.err == nil, "✓ Can", "✗ Can't"),
|
ternary(step.err == nil, "✓ Can", "✗ Can't"),
|
||||||
step.name,
|
step.name,
|
||||||
extraInfo,
|
extraInfo,
|
||||||
step.elapsed.Milliseconds(),
|
step.elapsed.Milliseconds(),
|
||||||
@@ -163,7 +167,7 @@ func testHttpRequestWithHeaders(method, url string, headers map[string]string, e
|
|||||||
request.Header.Add(key, value)
|
request.Header.Add(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := http.DefaultClient.Do(request)
|
response, err := defaultHTTPClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
package glance
|
package glance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +26,19 @@ var _templateFS embed.FS
|
|||||||
var staticFS, _ = fs.Sub(_staticFS, "static")
|
var staticFS, _ = fs.Sub(_staticFS, "static")
|
||||||
var templateFS, _ = fs.Sub(_templateFS, "templates")
|
var templateFS, _ = fs.Sub(_templateFS, "templates")
|
||||||
|
|
||||||
|
func readAllFromStaticFS(path string) ([]byte, error) {
|
||||||
|
// For some reason fs.FS only works with forward slashes, so in case we're
|
||||||
|
// running on Windows or pass paths with backslashes we need to replace them.
|
||||||
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
|
|
||||||
|
file, err := staticFS.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return io.ReadAll(file)
|
||||||
|
}
|
||||||
|
|
||||||
var staticFSHash = func() string {
|
var staticFSHash = func() string {
|
||||||
hash, err := computeFSHash(staticFS)
|
hash, err := computeFSHash(staticFS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -60,3 +79,73 @@ func computeFSHash(files fs.FS) (string, error) {
|
|||||||
|
|
||||||
return hex.EncodeToString(hash.Sum(nil))[:10], nil
|
return hex.EncodeToString(hash.Sum(nil))[:10], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cssImportPattern = regexp.MustCompile(`(?m)^@import "(.*?)";$`)
|
||||||
|
var cssSingleLineCommentPattern = regexp.MustCompile(`(?m)^\s*\/\*.*?\*\/$`)
|
||||||
|
|
||||||
|
// Yes, we bundle at runtime, give comptime pls
|
||||||
|
var bundledCSSContents = func() []byte {
|
||||||
|
const mainFilePath = "css/main.css"
|
||||||
|
|
||||||
|
var recursiveParseImports func(path string, depth int) ([]byte, error)
|
||||||
|
recursiveParseImports = func(path string, depth int) ([]byte, error) {
|
||||||
|
if depth > 20 {
|
||||||
|
return nil, errors.New("maximum import depth reached, is one of your imports circular?")
|
||||||
|
}
|
||||||
|
|
||||||
|
mainFileContents, err := readAllFromStaticFS(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize line endings, otherwise the \r's make the regex not match
|
||||||
|
mainFileContents = bytes.ReplaceAll(mainFileContents, []byte("\r\n"), []byte("\n"))
|
||||||
|
|
||||||
|
mainFileDir := filepath.Dir(path)
|
||||||
|
var importLastErr error
|
||||||
|
|
||||||
|
parsed := cssImportPattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
|
||||||
|
if importLastErr != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := cssImportPattern.FindSubmatch(match)
|
||||||
|
if len(matches) != 2 {
|
||||||
|
importLastErr = fmt.Errorf(
|
||||||
|
"import didn't return expected number of capture groups: %s, expected 2, got %d",
|
||||||
|
match, len(matches),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
importFilePath := filepath.Join(mainFileDir, string(matches[1]))
|
||||||
|
importContents, err := recursiveParseImports(importFilePath, depth+1)
|
||||||
|
if err != nil {
|
||||||
|
importLastErr = err
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return importContents
|
||||||
|
})
|
||||||
|
|
||||||
|
if importLastErr != nil {
|
||||||
|
return nil, importLastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
contents, err := recursiveParseImports(mainFilePath, 0)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("building CSS bundle: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// We could strip a bunch more unnecessary characters, but the biggest
|
||||||
|
// win comes from removing the whitespace at the beginning of lines
|
||||||
|
// since that's at least 4 bytes per property, which yielded a ~20% reduction.
|
||||||
|
contents = cssSingleLineCommentPattern.ReplaceAll(contents, nil)
|
||||||
|
contents = whitespaceAtBeginningOfLinePattern.ReplaceAll(contents, nil)
|
||||||
|
contents = bytes.ReplaceAll(contents, []byte("\n"), []byte(""))
|
||||||
|
|
||||||
|
return contents
|
||||||
|
}()
|
||||||
|
|||||||
@@ -3,50 +3,153 @@ package glance
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
pageTemplate = mustParseTemplate("page.html", "document.html")
|
pageTemplate = mustParseTemplate("page.html", "document.html", "footer.html")
|
||||||
pageContentTemplate = mustParseTemplate("page-content.html")
|
pageContentTemplate = mustParseTemplate("page-content.html")
|
||||||
pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
|
manifestTemplate = mustParseTemplate("manifest.json")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const STATIC_ASSETS_CACHE_DURATION = 24 * time.Hour
|
||||||
|
|
||||||
|
var reservedPageSlugs = []string{"login", "logout"}
|
||||||
|
|
||||||
type application struct {
|
type application struct {
|
||||||
Version string
|
Version string
|
||||||
|
CreatedAt time.Time
|
||||||
Config config
|
Config config
|
||||||
ParsedThemeStyle template.HTML
|
ConfigPath string
|
||||||
|
|
||||||
|
parsedManifest []byte
|
||||||
|
|
||||||
slugToPage map[string]*page
|
slugToPage map[string]*page
|
||||||
widgetByID map[uint64]widget
|
widgetByID map[uint64]widget
|
||||||
|
|
||||||
|
RequiresAuth bool
|
||||||
|
authSecretKey []byte
|
||||||
|
usernameHashToUsername map[string]string
|
||||||
|
authAttemptsMu sync.Mutex
|
||||||
|
failedAuthAttempts map[string]*failedAuthAttempt
|
||||||
}
|
}
|
||||||
|
|
||||||
func newApplication(config *config) (*application, error) {
|
func newApplication(c *config, configPath string) (*application, error) {
|
||||||
app := &application{
|
app := &application{
|
||||||
Version: buildVersion,
|
Version: buildVersion,
|
||||||
Config: *config,
|
CreatedAt: time.Now(),
|
||||||
|
Config: *c,
|
||||||
|
ConfigPath: configPath,
|
||||||
slugToPage: make(map[string]*page),
|
slugToPage: make(map[string]*page),
|
||||||
widgetByID: make(map[uint64]widget),
|
widgetByID: make(map[uint64]widget),
|
||||||
}
|
}
|
||||||
|
config := &app.Config
|
||||||
|
|
||||||
|
//
|
||||||
|
// Init auth
|
||||||
|
//
|
||||||
|
|
||||||
|
if len(config.Auth.Users) > 0 {
|
||||||
|
secretBytes, err := base64.StdEncoding.DecodeString(config.Auth.SecretKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decoding secret-key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
|
||||||
|
return nil, fmt.Errorf("secret-key must be exactly %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.usernameHashToUsername = make(map[string]string)
|
||||||
|
app.failedAuthAttempts = make(map[string]*failedAuthAttempt)
|
||||||
|
app.RequiresAuth = true
|
||||||
|
|
||||||
|
for username := range config.Auth.Users {
|
||||||
|
user := config.Auth.Users[username]
|
||||||
|
usernameHash, err := computeUsernameHash(username, secretBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("computing username hash for user %s: %v", username, err)
|
||||||
|
}
|
||||||
|
app.usernameHashToUsername[string(usernameHash)] = username
|
||||||
|
|
||||||
|
if user.PasswordHashString != "" {
|
||||||
|
user.PasswordHash = []byte(user.PasswordHashString)
|
||||||
|
user.PasswordHashString = ""
|
||||||
|
} else {
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hashing password for user %s: %v", username, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Password = ""
|
||||||
|
user.PasswordHash = hashedPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.authSecretKey = secretBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Init themes
|
||||||
|
//
|
||||||
|
|
||||||
|
if !config.Theme.DisablePicker {
|
||||||
|
themeKeys := make([]string, 0, 2)
|
||||||
|
themeProps := make([]*themeProperties, 0, 2)
|
||||||
|
|
||||||
|
defaultDarkTheme, ok := config.Theme.Presets.Get("default-dark")
|
||||||
|
if ok && !config.Theme.SameAs(defaultDarkTheme) || !config.Theme.SameAs(&themeProperties{}) {
|
||||||
|
themeKeys = append(themeKeys, "default-dark")
|
||||||
|
themeProps = append(themeProps, &themeProperties{})
|
||||||
|
}
|
||||||
|
|
||||||
|
themeKeys = append(themeKeys, "default-light")
|
||||||
|
themeProps = append(themeProps, &themeProperties{
|
||||||
|
Light: true,
|
||||||
|
BackgroundColor: &hslColorField{240, 13, 95},
|
||||||
|
PrimaryColor: &hslColorField{230, 100, 30},
|
||||||
|
NegativeColor: &hslColorField{0, 70, 50},
|
||||||
|
ContrastMultiplier: 1.3,
|
||||||
|
TextSaturationMultiplier: 0.5,
|
||||||
|
})
|
||||||
|
|
||||||
|
themePresets, err := newOrderedYAMLMap(themeKeys, themeProps)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating theme presets: %v", err)
|
||||||
|
}
|
||||||
|
config.Theme.Presets = *themePresets.Merge(&config.Theme.Presets)
|
||||||
|
|
||||||
|
for key, properties := range config.Theme.Presets.Items() {
|
||||||
|
properties.Key = key
|
||||||
|
if err := properties.init(); err != nil {
|
||||||
|
return nil, fmt.Errorf("initializing preset theme %s: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Theme.Key = "default"
|
||||||
|
if err := config.Theme.init(); err != nil {
|
||||||
|
return nil, fmt.Errorf("initializing default theme: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Init pages
|
||||||
|
//
|
||||||
|
|
||||||
app.slugToPage[""] = &config.Pages[0]
|
app.slugToPage[""] = &config.Pages[0]
|
||||||
|
|
||||||
providers := &widgetProviders{
|
providers := &widgetProviders{
|
||||||
assetResolver: app.AssetPath,
|
assetResolver: app.StaticAssetPath,
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
app.ParsedThemeStyle, err = executeTemplateToHTML(pageThemeStyleTemplate, &app.Config.Theme)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing theme style: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for p := range config.Pages {
|
for p := range config.Pages {
|
||||||
@@ -57,8 +160,26 @@ func newApplication(config *config) (*application, error) {
|
|||||||
page.Slug = titleToSlug(page.Title)
|
page.Slug = titleToSlug(page.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if slices.Contains(reservedPageSlugs, page.Slug) {
|
||||||
|
return nil, fmt.Errorf("page slug \"%s\" is reserved", page.Slug)
|
||||||
|
}
|
||||||
|
|
||||||
app.slugToPage[page.Slug] = page
|
app.slugToPage[page.Slug] = page
|
||||||
|
|
||||||
|
if page.Width == "default" {
|
||||||
|
page.Width = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if page.DesktopNavigationWidth == "" && page.DesktopNavigationWidth != "default" {
|
||||||
|
page.DesktopNavigationWidth = page.Width
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range page.HeadWidgets {
|
||||||
|
widget := page.HeadWidgets[i]
|
||||||
|
app.widgetByID[widget.GetID()] = widget
|
||||||
|
widget.setProviders(providers)
|
||||||
|
}
|
||||||
|
|
||||||
for c := range page.Columns {
|
for c := range page.Columns {
|
||||||
column := &page.Columns[c]
|
column := &page.Columns[c]
|
||||||
|
|
||||||
@@ -68,25 +189,45 @@ func newApplication(config *config) (*application, error) {
|
|||||||
|
|
||||||
for w := range column.Widgets {
|
for w := range column.Widgets {
|
||||||
widget := column.Widgets[w]
|
widget := column.Widgets[w]
|
||||||
app.widgetByID[widget.id()] = widget
|
app.widgetByID[widget.GetID()] = widget
|
||||||
|
|
||||||
widget.setProviders(providers)
|
widget.setProviders(providers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
config = &app.Config
|
|
||||||
|
|
||||||
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
|
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
|
||||||
config.Theme.CustomCSSFile = app.transformUserDefinedAssetPath(config.Theme.CustomCSSFile)
|
config.Theme.CustomCSSFile = app.resolveUserDefinedAssetPath(config.Theme.CustomCSSFile)
|
||||||
|
config.Branding.LogoURL = app.resolveUserDefinedAssetPath(config.Branding.LogoURL)
|
||||||
|
|
||||||
if config.Branding.FaviconURL == "" {
|
config.Branding.FaviconURL = ternary(
|
||||||
config.Branding.FaviconURL = app.AssetPath("favicon.png")
|
config.Branding.FaviconURL == "",
|
||||||
} else {
|
app.StaticAssetPath("favicon.svg"),
|
||||||
config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL)
|
app.resolveUserDefinedAssetPath(config.Branding.FaviconURL),
|
||||||
|
)
|
||||||
|
|
||||||
|
config.Branding.FaviconType = ternary(
|
||||||
|
strings.HasSuffix(config.Branding.FaviconURL, ".svg"),
|
||||||
|
"image/svg+xml",
|
||||||
|
"image/png",
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.Branding.AppName == "" {
|
||||||
|
config.Branding.AppName = "Glance"
|
||||||
}
|
}
|
||||||
|
|
||||||
config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL)
|
if config.Branding.AppIconURL == "" {
|
||||||
|
config.Branding.AppIconURL = app.StaticAssetPath("app-icon.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Branding.AppBackgroundColor == "" {
|
||||||
|
config.Branding.AppBackgroundColor = config.Theme.BackgroundColorAsHex
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := executeTemplateToString(manifestTemplate, templateData{App: app})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing manifest.json: %v", err)
|
||||||
|
}
|
||||||
|
app.parsedManifest = []byte(manifest)
|
||||||
|
|
||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
@@ -97,6 +238,20 @@ func (p *page) updateOutdatedWidgets() {
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
context := context.Background()
|
context := context.Background()
|
||||||
|
|
||||||
|
for w := range p.HeadWidgets {
|
||||||
|
widget := p.HeadWidgets[w]
|
||||||
|
|
||||||
|
if !widget.requiresUpdate(&now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
widget.update(context)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
for c := range p.Columns {
|
for c := range p.Columns {
|
||||||
for w := range p.Columns[c].Widgets {
|
for w := range p.Columns[c].Widgets {
|
||||||
widget := p.Columns[c].Widgets[w]
|
widget := p.Columns[c].Widgets[w]
|
||||||
@@ -116,7 +271,7 @@ func (p *page) updateOutdatedWidgets() {
|
|||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *application) transformUserDefinedAssetPath(path string) string {
|
func (a *application) resolveUserDefinedAssetPath(path string) string {
|
||||||
if strings.HasPrefix(path, "/assets/") {
|
if strings.HasPrefix(path, "/assets/") {
|
||||||
return a.Config.Server.BaseURL + path
|
return a.Config.Server.BaseURL + path
|
||||||
}
|
}
|
||||||
@@ -124,26 +279,51 @@ func (a *application) transformUserDefinedAssetPath(path string) string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
type pageTemplateData struct {
|
type templateRequestData struct {
|
||||||
|
Theme *themeProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
type templateData struct {
|
||||||
App *application
|
App *application
|
||||||
Page *page
|
Page *page
|
||||||
|
Request templateRequestData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) populateTemplateRequestData(data *templateRequestData, r *http.Request) {
|
||||||
|
theme := &a.Config.Theme.themeProperties
|
||||||
|
|
||||||
|
if !a.Config.Theme.DisablePicker {
|
||||||
|
selectedTheme, err := r.Cookie("theme")
|
||||||
|
if err == nil {
|
||||||
|
preset, exists := a.Config.Theme.Presets.Get(selectedTheme.Value)
|
||||||
|
if exists {
|
||||||
|
theme = preset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.Theme = theme
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) {
|
func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
page, exists := a.slugToPage[r.PathValue("page")]
|
page, exists := a.slugToPage[r.PathValue("page")]
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
a.handleNotFound(w, r)
|
a.handleNotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pageData := pageTemplateData{
|
if a.handleUnauthorizedResponse(w, r, redirectToLogin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := templateData{
|
||||||
Page: page,
|
Page: page,
|
||||||
App: a,
|
App: a,
|
||||||
}
|
}
|
||||||
|
a.populateTemplateRequestData(&data.Request, r)
|
||||||
|
|
||||||
var responseBytes bytes.Buffer
|
var responseBytes bytes.Buffer
|
||||||
err := pageTemplate.Execute(&responseBytes, pageData)
|
err := pageTemplate.Execute(&responseBytes, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte(err.Error()))
|
w.Write([]byte(err.Error()))
|
||||||
@@ -155,13 +335,16 @@ func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) {
|
func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
page, exists := a.slugToPage[r.PathValue("page")]
|
page, exists := a.slugToPage[r.PathValue("page")]
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
a.handleNotFound(w, r)
|
a.handleNotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pageData := pageTemplateData{
|
if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pageData := templateData{
|
||||||
Page: page,
|
Page: page,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +368,35 @@ func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Re
|
|||||||
w.Write(responseBytes.Bytes())
|
w.Write(responseBytes.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *application) addressOfRequest(r *http.Request) string {
|
||||||
|
remoteAddrWithoutPort := func() string {
|
||||||
|
for i := len(r.RemoteAddr) - 1; i >= 0; i-- {
|
||||||
|
if r.RemoteAddr[i] == ':' {
|
||||||
|
return r.RemoteAddr[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.Config.Server.Proxied {
|
||||||
|
return remoteAddrWithoutPort()
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should probably be configurable or look for multiple headers, not just this one
|
||||||
|
forwardedFor := r.Header.Get("X-Forwarded-For")
|
||||||
|
if forwardedFor == "" {
|
||||||
|
return remoteAddrWithoutPort()
|
||||||
|
}
|
||||||
|
|
||||||
|
ips := strings.Split(forwardedFor, ",")
|
||||||
|
if len(ips) == 0 || ips[0] == "" {
|
||||||
|
return remoteAddrWithoutPort()
|
||||||
|
}
|
||||||
|
|
||||||
|
return ips[0]
|
||||||
|
}
|
||||||
|
|
||||||
func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
|
func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
|
||||||
// TODO: add proper not found page
|
// TODO: add proper not found page
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
@@ -192,47 +404,122 @@ func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) {
|
func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
widgetValue := r.PathValue("widget")
|
// TODO: this requires a rework of the widget update logic so that rather
|
||||||
|
// than locking the entire page we lock individual widgets
|
||||||
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
|
|
||||||
widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
|
// widgetValue := r.PathValue("widget")
|
||||||
if err != nil {
|
|
||||||
a.handleNotFound(w, r)
|
// widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
|
||||||
return
|
// if err != nil {
|
||||||
|
// a.handleNotFound(w, r)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
// widget, exists := a.widgetByID[widgetID]
|
||||||
|
|
||||||
|
// if !exists {
|
||||||
|
// a.handleNotFound(w, r)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
// widget.handleRequest(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
widget, exists := a.widgetByID[widgetID]
|
func (a *application) StaticAssetPath(asset string) string {
|
||||||
|
|
||||||
if !exists {
|
|
||||||
a.handleNotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
widget.handleRequest(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *application) AssetPath(asset string) string {
|
|
||||||
return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset
|
return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *application) VersionedAssetPath(asset string) string {
|
||||||
|
return a.Config.Server.BaseURL + asset +
|
||||||
|
"?v=" + strconv.FormatInt(a.CreatedAt.Unix(), 10)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *application) server() (func() error, func() error) {
|
func (a *application) server() (func() error, func() error) {
|
||||||
// TODO: add gzip support, static files must have their gzipped contents cached
|
|
||||||
// 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)
|
||||||
|
|
||||||
|
if !a.Config.Theme.DisablePicker {
|
||||||
|
mux.HandleFunc("POST /api/set-theme/{key}", a.handleThemeChangeRequest)
|
||||||
|
}
|
||||||
|
|
||||||
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
|
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
|
||||||
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /edit", a.handleAdminIndex)
|
||||||
|
mux.HandleFunc("GET /edit/pages/{page}", a.handleAdminPage)
|
||||||
|
mux.HandleFunc("GET /edit/pages/{page}/settings", a.handleAdminPageSettings)
|
||||||
|
mux.HandleFunc("GET /edit/site-settings", a.handleAdminSiteSettings)
|
||||||
|
mux.HandleFunc("GET /edit/theme-settings", a.handleAdminThemeSettings)
|
||||||
|
mux.HandleFunc("GET /edit/theme-settings/presets/new", a.handleAdminThemePreset)
|
||||||
|
mux.HandleFunc("GET /edit/theme-settings/presets/{key}", a.handleAdminThemePreset)
|
||||||
|
mux.HandleFunc("GET /edit/pages/{page}/widgets/{col}/{idx}", a.handleAdminWidget)
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /edit/api/pages", a.handleAdminAddPage)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/delete", a.handleAdminDeletePage)
|
||||||
|
mux.HandleFunc("GET /edit/pages/{page}/widgets/{col}/new", a.handleAdminNewWidget)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/new", a.handleAdminCreateWidget)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/{idx}", a.handleAdminEditWidget)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/{idx}/delete", a.handleAdminDeleteWidget)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/{idx}/move", a.handleAdminMoveWidget)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/layout", a.handleAdminLayout)
|
||||||
|
mux.HandleFunc("GET /edit/api/pages/{page}/widgets/{col}/{idx}/fields", a.handleAdminGetFields)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/{idx}/fields", a.handleAdminUpdateFields)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/create", a.handleAdminCreateFromFields)
|
||||||
|
mux.HandleFunc("GET /edit/api/widget-schemas", a.handleAdminWidgetSchemas)
|
||||||
|
mux.HandleFunc("POST /edit/api/validate/{kind}", a.handleAdminValidate)
|
||||||
|
mux.HandleFunc("POST /edit/api/lookup/{kind}", a.handleAdminLookup)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/columns", a.handleAdminAddColumn)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/columns/{col}/delete", a.handleAdminDeleteColumn)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/columns/{col}/move", a.handleAdminMoveColumn)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/columns/{col}/size", a.handleAdminColumnSize)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/fields", a.handleAdminUpdatePageFields)
|
||||||
|
mux.HandleFunc("POST /edit/api/pages/{page}/move", a.handleAdminMovePage)
|
||||||
|
mux.HandleFunc("POST /edit/api/restore", a.handleAdminRestore)
|
||||||
|
mux.HandleFunc("POST /edit/api/restore/{n}", a.handleAdminRestoreFromBackup)
|
||||||
|
mux.HandleFunc("POST /edit/api/site-settings", a.handleAdminUpdateSiteSettings)
|
||||||
|
mux.HandleFunc("POST /edit/api/theme-settings", a.handleAdminUpdateTheme)
|
||||||
|
mux.HandleFunc("POST /edit/api/theme-presets", a.handleAdminCreateOrUpdatePreset)
|
||||||
|
mux.HandleFunc("POST /edit/api/theme-presets/{key}", a.handleAdminCreateOrUpdatePreset)
|
||||||
|
mux.HandleFunc("POST /edit/api/theme-presets/{key}/delete", a.handleAdminDeletePreset)
|
||||||
|
|
||||||
|
if a.RequiresAuth {
|
||||||
|
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
||||||
|
mux.HandleFunc("GET /logout", a.handleLogoutRequest)
|
||||||
|
mux.HandleFunc("POST /api/authenticate", a.handleAuthenticationAttempt)
|
||||||
|
}
|
||||||
|
|
||||||
mux.Handle(
|
mux.Handle(
|
||||||
fmt.Sprintf("GET /static/%s/{path...}", staticFSHash),
|
fmt.Sprintf("GET /static/%s/{path...}", staticFSHash),
|
||||||
http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)),
|
http.StripPrefix(
|
||||||
|
"/static/"+staticFSHash,
|
||||||
|
fileServerWithCache(http.FS(staticFS), STATIC_ASSETS_CACHE_DURATION),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assetCacheControlValue := fmt.Sprintf(
|
||||||
|
"public, max-age=%d",
|
||||||
|
int(STATIC_ASSETS_CACHE_DURATION.Seconds()),
|
||||||
|
)
|
||||||
|
|
||||||
|
mux.HandleFunc(fmt.Sprintf("GET /static/%s/css/bundle.css", staticFSHash), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Add("Cache-Control", assetCacheControlValue)
|
||||||
|
w.Header().Add("Content-Type", "text/css; charset=utf-8")
|
||||||
|
w.Write(bundledCSSContents)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /manifest.json", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Add("Cache-Control", assetCacheControlValue)
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
w.Write(a.parsedManifest)
|
||||||
|
})
|
||||||
|
|
||||||
var absAssetsPath string
|
var absAssetsPath string
|
||||||
if a.Config.Server.AssetsPath != "" {
|
if a.Config.Server.AssetsPath != "" {
|
||||||
absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
|
absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
|
||||||
@@ -246,7 +533,6 @@ func (a *application) server() (func() error, func() error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
start := func() error {
|
start := func() error {
|
||||||
a.Config.Server.StartedAt = time.Now()
|
|
||||||
log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n",
|
log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n",
|
||||||
a.Config.Server.Host,
|
a.Config.Server.Host,
|
||||||
a.Config.Server.Port,
|
a.Config.Server.Port,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
var buildVersion = "dev"
|
var buildVersion = "dev"
|
||||||
@@ -18,6 +20,8 @@ func Main() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch options.intent {
|
switch options.intent {
|
||||||
|
case cliIntentVersionPrint:
|
||||||
|
fmt.Println(buildVersion)
|
||||||
case cliIntentServe:
|
case cliIntentServe:
|
||||||
// remove in v0.10.0
|
// remove in v0.10.0
|
||||||
if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) {
|
if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) {
|
||||||
@@ -47,14 +51,49 @@ func Main() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(string(contents))
|
fmt.Println(string(contents))
|
||||||
|
case cliIntentSensorsPrint:
|
||||||
|
return cliSensorsPrint()
|
||||||
|
case cliIntentMountpointInfo:
|
||||||
|
return cliMountpointInfo(options.args[1])
|
||||||
case cliIntentDiagnose:
|
case cliIntentDiagnose:
|
||||||
runDiagnostic()
|
runDiagnostic()
|
||||||
|
case cliIntentSecretMake:
|
||||||
|
key, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to make secret key: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(key)
|
||||||
|
case cliIntentPasswordHash:
|
||||||
|
password := options.args[1]
|
||||||
|
|
||||||
|
if password == "" {
|
||||||
|
fmt.Println("Password cannot be empty")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(password) < 6 {
|
||||||
|
fmt.Println("Password must be at least 6 characters long")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to hash password: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(string(hashedPassword))
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveApp(configPath string) error {
|
func serveApp(configPath string) error {
|
||||||
|
// TODO: refactor if this gets any more complex, the current implementation is
|
||||||
|
// difficult to reason about due to all of the callbacks and simultaneous operations,
|
||||||
|
// use a single goroutine and a channel to initiate synchronous changes to the server
|
||||||
exitChannel := make(chan struct{})
|
exitChannel := make(chan struct{})
|
||||||
hadValidConfigOnStartup := false
|
hadValidConfigOnStartup := false
|
||||||
var stopServer func() error
|
var stopServer func() error
|
||||||
@@ -73,16 +112,23 @@ func serveApp(configPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
} else if !hadValidConfigOnStartup {
|
|
||||||
hadValidConfigOnStartup = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := newApplication(config)
|
app, err := newApplication(config, configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to create application: %v", err)
|
log.Printf("Failed to create application: %v", err)
|
||||||
|
|
||||||
|
if !hadValidConfigOnStartup {
|
||||||
|
close(exitChannel)
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !hadValidConfigOnStartup {
|
||||||
|
hadValidConfigOnStartup = true
|
||||||
|
}
|
||||||
|
|
||||||
if stopServer != nil {
|
if stopServer != nil {
|
||||||
if err := stopServer(); err != nil {
|
if err := stopServer(); err != nil {
|
||||||
log.Printf("Error while trying to stop server: %v", err)
|
log.Printf("Error while trying to stop server: %v", err)
|
||||||
@@ -119,7 +165,7 @@ func serveApp(configPath string) error {
|
|||||||
return fmt.Errorf("validating config file: %w", err)
|
return fmt.Errorf("validating config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := newApplication(config)
|
app, err := newApplication(config, configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating application: %w", err)
|
return fmt.Errorf("creating application: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,590 @@
|
|||||||
|
/* Edit mode — in-place dashboard editing UI. Hidden unless body[data-edit-mode]. */
|
||||||
|
|
||||||
|
#edit-mode-toggle {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
#edit-mode-toggle.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-edit-mode] .widget {
|
||||||
|
position: relative;
|
||||||
|
outline: 1px dashed var(--color-text-subdue);
|
||||||
|
outline-offset: 4px;
|
||||||
|
transition: outline-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-edit-mode] .widget:hover {
|
||||||
|
outline-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-edit-mode] .widget.sortable-ghost {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-edit-mode] .widget.sortable-drag {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-mode-handles {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 10;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--color-popover-background);
|
||||||
|
border: 1px solid var(--color-popover-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-edit-mode] .edit-mode-handles {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-handle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-handle:hover {
|
||||||
|
background: var(--color-widget-background-highlight);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-handle-drag {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-handle-drag:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-handle-delete:hover {
|
||||||
|
color: var(--color-negative);
|
||||||
|
}
|
||||||
|
|
||||||
|
#edit-mode-status {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: var(--color-popover-background);
|
||||||
|
border: 1px solid var(--color-popover-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: var(--font-size-h5);
|
||||||
|
color: var(--color-text-base);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-edit-mode] #edit-mode-status {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#edit-mode-status.saving {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#edit-mode-status.error {
|
||||||
|
color: var(--color-negative);
|
||||||
|
border-color: var(--color-negative);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-add-widget {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: var(--widget-gap);
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--color-text-subdue);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-h4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-add-widget:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Dialog ---------- */
|
||||||
|
|
||||||
|
.edit-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog {
|
||||||
|
background: var(--color-popover-background);
|
||||||
|
border: 1px solid var(--color-popover-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog-narrow {
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--color-widget-content-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-h2);
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog-close:hover {
|
||||||
|
background: var(--color-widget-background-highlight);
|
||||||
|
color: var(--color-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog-body {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-top: 1px solid var(--color-widget-content-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog-actions button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--color-popover-border);
|
||||||
|
background: var(--color-widget-background);
|
||||||
|
color: var(--color-text-base);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-h4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog-actions .edit-dialog-save {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-background);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog-actions button:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog-error {
|
||||||
|
margin: 0 1.25rem 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: hsla(0, 70%, 50%, 0.15);
|
||||||
|
border: 1px solid var(--color-negative);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-negative);
|
||||||
|
font-size: var(--font-size-h5);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Form fields ---------- */
|
||||||
|
|
||||||
|
.edit-field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-label {
|
||||||
|
font-size: var(--font-size-h5);
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-required {
|
||||||
|
color: var(--color-negative);
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-help {
|
||||||
|
font-size: var(--font-size-h6);
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-input {
|
||||||
|
background: var(--color-widget-background);
|
||||||
|
border: 1px solid var(--color-widget-content-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-h4);
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.edit-input {
|
||||||
|
resize: vertical;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: var(--font-size-h5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-h4);
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-checkbox input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- List editors ---------- */
|
||||||
|
|
||||||
|
.edit-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-list-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-list-strings .edit-list-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-list-strings .edit-list-item .edit-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-list-object {
|
||||||
|
border: 1px solid var(--color-widget-content-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-list-object .edit-list-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: var(--font-size-h6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-list-remove {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-popover-border);
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-list-remove:hover {
|
||||||
|
color: var(--color-negative);
|
||||||
|
border-color: var(--color-negative);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-list-add {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--color-text-subdue);
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-h5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-list-add:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Type picker ---------- */
|
||||||
|
|
||||||
|
.edit-type-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-type-option {
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
background: var(--color-widget-background);
|
||||||
|
border: 1px solid var(--color-widget-content-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-h5);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-type-option:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: var(--color-widget-background-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Autocomplete ---------- */
|
||||||
|
|
||||||
|
.edit-autocomplete {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-autocomplete-list {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 2px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--color-popover-background);
|
||||||
|
border: 1px solid var(--color-popover-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
max-height: 18rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-autocomplete-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--color-widget-content-border);
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-autocomplete-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-autocomplete-item:hover,
|
||||||
|
.edit-autocomplete-item:focus {
|
||||||
|
background: var(--color-widget-background-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-autocomplete-display {
|
||||||
|
font-size: var(--font-size-h5);
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-autocomplete-hint {
|
||||||
|
font-size: var(--font-size-h6);
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Per-field validation state ---------- */
|
||||||
|
|
||||||
|
.edit-validate-state {
|
||||||
|
font-size: var(--font-size-h6);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
min-height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-validate-checking {
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-validate-ok {
|
||||||
|
color: var(--color-positive);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-validate-error {
|
||||||
|
color: var(--color-negative);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-input-invalid {
|
||||||
|
border-color: var(--color-negative);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Column controls ---------- */
|
||||||
|
|
||||||
|
.edit-column-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: var(--color-popover-background);
|
||||||
|
border: 1px solid var(--color-popover-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: var(--font-size-h5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-column-label {
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-column-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-column-size {
|
||||||
|
background: var(--color-widget-background);
|
||||||
|
border: 1px solid var(--color-widget-content-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-h6);
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-column-move,
|
||||||
|
.edit-column-delete {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-popover-border);
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-h6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-column-move:hover,
|
||||||
|
.edit-column-delete:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-column-move:disabled,
|
||||||
|
.edit-column-delete:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-column-delete:hover:not(:disabled) {
|
||||||
|
color: var(--color-negative);
|
||||||
|
border-color: var(--color-negative);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-add-column {
|
||||||
|
display: block;
|
||||||
|
margin: var(--widget-gap) auto 0;
|
||||||
|
padding: 0.6rem 1.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--color-text-subdue);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-h4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-add-column:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Nested widget editor (group / split-column) ---------- */
|
||||||
|
|
||||||
|
.edit-nested-widget {
|
||||||
|
border: 1px solid var(--color-widget-content-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.6rem;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-nested-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-nested-type {
|
||||||
|
background: var(--color-widget-background);
|
||||||
|
border: 1px solid var(--color-widget-content-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-h5);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-nested-body > .edit-field:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@container widget (max-width: 550px) {
|
||||||
|
.forum-post-autohide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
.login-bounds {
|
||||||
|
max-width: 500px;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
transition: border-color .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input input {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 5.2rem;
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input-icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
margin-top: -0.1rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input input[type="password"] {
|
||||||
|
letter-spacing: 0.3rem;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input input[type="password"]::placeholder {
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:hover {
|
||||||
|
border-color: var(--color-progress-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus-within {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
transition-duration: .7s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
padding: 1rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-text-subdue);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-text-paragraph);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-h4);
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all .3s, margin-top 0s;
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:not(:disabled) {
|
||||||
|
box-shadow: 0 0 10px 1px var(--color-separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error-message:not(:empty) + .login-button {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:focus, .login-button:hover {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:disabled {
|
||||||
|
border-color: var(--color-separator);
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button svg {
|
||||||
|
width: 1.7rem;
|
||||||
|
height: 1.7rem;
|
||||||
|
transition: transform .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:not(:disabled):hover svg, .login-button:not(:disabled):focus svg {
|
||||||
|
transform: translateX(.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-entrance {
|
||||||
|
animation: fieldReveal 0.7s backwards;
|
||||||
|
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-entrance:nth-child(1) { animation-delay: .1s; }
|
||||||
|
.animate-entrance:nth-child(2) { animation-delay: .2s; }
|
||||||
|
.animate-entrance:nth-child(4) { animation-delay: .3s; }
|
||||||
|
|
||||||
|
@keyframes fieldReveal {
|
||||||
|
from {
|
||||||
|
opacity: 0.0001;
|
||||||
|
transform: translateY(4rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error-message {
|
||||||
|
color: var(--color-negative);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
padding: 1.3rem calc(var(--widget-content-horizontal-padding) + 1px);
|
||||||
|
position: relative;
|
||||||
|
margin-top: 2rem;
|
||||||
|
animation: errorMessageEntrance 0.4s backwards cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes errorMessageEntrance {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error-message:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error-message::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: var(--color-negative);
|
||||||
|
opacity: 0.05;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
animation-delay: .4s;
|
||||||
|
animation-duration: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-password-visibility {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-size: 10px;
|
||||||
|
|
||||||
|
--scheme: ;
|
||||||
|
--bgh: 240;
|
||||||
|
--bgs: 8%;
|
||||||
|
--bgl: 9%;
|
||||||
|
--bghs: var(--bgh), var(--bgs);
|
||||||
|
--cm: 1;
|
||||||
|
--tsm: 1;
|
||||||
|
|
||||||
|
--widget-gap: 23px;
|
||||||
|
--widget-content-vertical-padding: 15px;
|
||||||
|
--widget-content-horizontal-padding: 17px;
|
||||||
|
--widget-content-padding: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
|
||||||
|
--content-bounds-padding: 15px;
|
||||||
|
--border-radius: 5px;
|
||||||
|
--mobile-navigation-height: 50px;
|
||||||
|
|
||||||
|
--color-primary: hsl(43, 50%, 70%);
|
||||||
|
--color-positive: var(--color-primary);
|
||||||
|
--color-negative: hsl(0, 70%, 70%);
|
||||||
|
--color-background: hsl(var(--bghs), var(--bgl));
|
||||||
|
--color-widget-background-hsl-values: var(--bghs), calc(var(--bgl) + 1%);
|
||||||
|
--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-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%)));
|
||||||
|
--color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 3%));
|
||||||
|
--color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 12%)));
|
||||||
|
--color-progress-border: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 10% * var(--cm))));
|
||||||
|
--color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 26% * var(--cm))));
|
||||||
|
--color-vertical-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 28% * var(--cm))));
|
||||||
|
--color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm))));
|
||||||
|
|
||||||
|
--ths: var(--bgh), calc(var(--bgs) * var(--tsm));
|
||||||
|
--color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
|
||||||
|
--color-text-paragraph: hsl(var(--ths), calc(var(--scheme) var(--cm) * 73%));
|
||||||
|
--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-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
|
||||||
|
|
||||||
|
--font-size-h1: 1.7rem;
|
||||||
|
--font-size-h2: 1.6rem;
|
||||||
|
--font-size-h3: 1.5rem;
|
||||||
|
--font-size-h4: 1.4rem;
|
||||||
|
--font-size-base: 1.3rem;
|
||||||
|
--font-size-h5: 1.2rem;
|
||||||
|
--font-size-h6: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Do not change the order of the below imports unless you know what you're doing */
|
||||||
|
|
||||||
|
@import "site.css";
|
||||||
|
@import "widgets.css";
|
||||||
|
@import "popover.css";
|
||||||
|
@import "utils.css";
|
||||||
|
@import "mobile.css";
|
||||||
|
@import "edit-mode.css";
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
@media (max-width: 1190px) {
|
||||||
|
.header-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-column-small .size-title-dynamic {
|
||||||
|
font-size: var(--font-size-h3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-column-small {
|
||||||
|
width: 100%;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-column {
|
||||||
|
display: none;
|
||||||
|
animation: columnEntrance .0s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-columns-transitioned .page-column {
|
||||||
|
animation-duration: .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes columnEntrance {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scaleX(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-offset {
|
||||||
|
height: var(--mobile-navigation-height);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
transform: translateY(calc(100% - var(--mobile-navigation-height)));
|
||||||
|
left: var(--content-bounds-padding);
|
||||||
|
right: var(--content-bounds-padding);
|
||||||
|
z-index: 11;
|
||||||
|
background-color: var(--color-widget-background);
|
||||||
|
border: 1px solid var(--color-widget-content-border);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
|
transition: transform .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-actions > * {
|
||||||
|
padding-block: 1.1rem;
|
||||||
|
padding-inline: var(--content-bounds-padding);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 50ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-actions > *:active {
|
||||||
|
background-color: var(--color-widget-background-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation:has(.mobile-navigation-page-links-input:checked) .hamburger-icon {
|
||||||
|
--spacing: 7px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation:has(.mobile-navigation-page-links-input:checked) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-page-links {
|
||||||
|
border-top: 1px solid var(--color-widget-content-border);
|
||||||
|
border-bottom: 1px solid var(--color-widget-content-border);
|
||||||
|
padding: 20px var(--content-bounds-padding);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
gap: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-icons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:has(.mobile-navigation-input[value="0"]:checked) .page-columns > :nth-child(1),
|
||||||
|
body:has(.mobile-navigation-input[value="1"]:checked) .page-columns > :nth-child(2),
|
||||||
|
body:has(.mobile-navigation-input[value="2"]:checked) .page-columns > :nth-child(3) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-label {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 50px;
|
||||||
|
height: var(--mobile-navigation-height);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: var(--mobile-navigation-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-pill {
|
||||||
|
display: block;
|
||||||
|
background: var(--color-text-base);
|
||||||
|
height: 10px;
|
||||||
|
width: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: width .3s, background-color .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-label:hover > .mobile-navigation-pill {
|
||||||
|
background-color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-label:hover {
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-input:checked + .mobile-navigation-pill {
|
||||||
|
background: var(--color-primary);
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation-input, .mobile-navigation-page-links-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-icon {
|
||||||
|
--spacing: 4px;
|
||||||
|
width: 1em;
|
||||||
|
height: 1px;
|
||||||
|
background-color: currentColor;
|
||||||
|
transition: color .3s, box-shadow .3s;
|
||||||
|
box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle-button.container-expanded {
|
||||||
|
bottom: var(--mobile-navigation-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-grid + .expand-toggle-button.container-expanded {
|
||||||
|
/* hides content that peeks through the rounded borders of the mobile navigation */
|
||||||
|
box-shadow: 0 var(--border-radius) 0 0 var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-rain::before {
|
||||||
|
background-size: 7px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios .search-input {
|
||||||
|
/* so that iOS Safari does not zoom the page when the input is focused */
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1190px) and (display-mode: standalone) {
|
||||||
|
:root {
|
||||||
|
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios .body-content {
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle-button.container-expanded {
|
||||||
|
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-offset {
|
||||||
|
height: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.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) {
|
||||||
|
:root {
|
||||||
|
font-size: 9.4px;
|
||||||
|
--widget-gap: 15px;
|
||||||
|
--widget-content-vertical-padding: 10px;
|
||||||
|
--widget-content-horizontal-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 {
|
||||||
|
line-clamp: 3;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
.popover-container, [data-popover-html] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-container {
|
||||||
|
--triangle-size: 10px;
|
||||||
|
--triangle-offset: 50%;
|
||||||
|
--triangle-margin: calc(var(--triangle-size) + 3px);
|
||||||
|
--entrance-y-offset: 8px;
|
||||||
|
--entrance-direction: calc(var(--entrance-y-offset) * -1);
|
||||||
|
|
||||||
|
z-index: 20;
|
||||||
|
position: absolute;
|
||||||
|
padding-top: var(--triangle-margin);
|
||||||
|
padding-inline: var(--content-bounds-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-container.position-above {
|
||||||
|
--entrance-direction: var(--entrance-y-offset);
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: var(--triangle-margin);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-frame {
|
||||||
|
--shadow-properties: 0 15px 20px -10px;
|
||||||
|
--shadow-color: hsla(var(--bghs), calc(var(--bgl) * 0.2), 0.5);
|
||||||
|
position: relative;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--color-popover-background);
|
||||||
|
border: 1px solid var(--color-popover-border);
|
||||||
|
border-radius: 5px;
|
||||||
|
animation: popoverFrameEntrance 0.3s backwards cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
box-shadow: var(--shadow-properties) var(--shadow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-frame::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: var(--triangle-size);
|
||||||
|
height: var(--triangle-size);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
background-color: var(--color-popover-background);
|
||||||
|
border-top-left-radius: 2px;
|
||||||
|
border-left: 1px solid var(--color-popover-border);
|
||||||
|
border-top: 1px solid var(--color-popover-border);
|
||||||
|
left: calc(var(--triangle-offset) - (var(--triangle-size) / 2));
|
||||||
|
top: calc(var(--triangle-size) / 2 * -1 - 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-container.position-above .popover-frame::before {
|
||||||
|
transform: rotate(-135deg);
|
||||||
|
top: auto;
|
||||||
|
bottom: calc(var(--triangle-size) / 2 * -1 - 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-container.position-above .popover-frame {
|
||||||
|
--shadow-properties: 0 10px 20px -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popoverFrameEntrance {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(var(--entrance-direction));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
:root[data-scheme=light] {
|
||||||
|
--scheme: 100% -;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
animation: pageContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-column-small .size-title-dynamic {
|
||||||
|
font-size: var(--font-size-h4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-column-full .size-title-dynamic {
|
||||||
|
font-size: var(--font-size-h3);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-text-subdue);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
background: var(--color-background);
|
||||||
|
height: 5px;
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 0.1rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--color-separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
img, svg {
|
||||||
|
display: block;
|
||||||
|
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 {
|
||||||
|
scrollbar-color: var(--color-text-subdue) transparent;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, .body-content {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5 {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
background-color: var(--color-background);
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-column-small {
|
||||||
|
width: 300px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-column-full {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-columns {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--widget-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pageContentEntrance {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-loading-container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: loadingContainerEntrance 200ms backwards;
|
||||||
|
animation-delay: 150ms;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-loading-container > .loading-icon {
|
||||||
|
translate: 0 -250%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loadingContainerEntrance {
|
||||||
|
from {
|
||||||
|
/* Using 0.001 instead of 0 fixes a random 1s freeze on Chrome on page load when all */
|
||||||
|
/* elements have opacity 0 and are animated in. I don't want to be a web dev anymore. */
|
||||||
|
opacity: 0.001;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-icon {
|
||||||
|
min-width: 1.5em;
|
||||||
|
width: 1.5em;
|
||||||
|
height: 1.5em;
|
||||||
|
border: 0.25em solid hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 12%)));
|
||||||
|
border-top-color: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 40%)));
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: loadingIconSpin 800ms infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loadingIconSpin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-icon {
|
||||||
|
width: 0.7rem;
|
||||||
|
height: 0.7rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-icon-major {
|
||||||
|
background: var(--color-negative);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-icon-minor {
|
||||||
|
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 {
|
||||||
|
max-width: 1600px;
|
||||||
|
width: 100%;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 0 var(--content-bounds-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-bounds-wide {
|
||||||
|
max-width: 1920px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-bounds-slim {
|
||||||
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page.center-vertically {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-container {
|
||||||
|
margin-top: calc(var(--widget-gap) / 2);
|
||||||
|
--header-height: 45px;
|
||||||
|
--header-items-gap: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
height: var(--header-height);
|
||||||
|
gap: var(--header-items-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: var(--header-height);
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
border-right: 1px solid var(--color-widget-content-border);
|
||||||
|
padding-right: var(--widget-content-horizontal-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo:has(img, svg) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo img {
|
||||||
|
max-height: 2.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
overflow-x: auto;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
gap: var(--header-items-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav .nav-item {
|
||||||
|
line-height: var(--header-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding-bottom: calc(var(--widget-gap) * 1.5);
|
||||||
|
padding-top: calc(var(--widget-gap) / 2);
|
||||||
|
animation: loadingContainerEntrance 200ms backwards;
|
||||||
|
animation-delay: 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-navigation, .mobile-reachability-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: color .3s, border-color .3s;
|
||||||
|
font-size: var(--font-size-h3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:not(.nav-item-current):hover {
|
||||||
|
border-bottom-color: var(--color-text-subdue);
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.nav-item-current {
|
||||||
|
border-bottom-color: var(--color-primary);
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
stroke: var(--color-text-subdue);
|
||||||
|
transition: stroke .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button:hover, .logout-button:focus {
|
||||||
|
stroke: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-choices {
|
||||||
|
--presets-per-row: 2;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--presets-per-row), 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-choices:has(> :nth-child(3)) {
|
||||||
|
--presets-per-row: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preset {
|
||||||
|
background-color: var(--color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-choices .theme-preset::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -.4rem;
|
||||||
|
border-radius: .7rem;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-choices .theme-preset:hover::before {
|
||||||
|
border-color: var(--color-text-subdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-choices .theme-preset.current::before {
|
||||||
|
border-color: var(--color-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preset-light {
|
||||||
|
gap: 0.3rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-color {
|
||||||
|
background-color: var(--color);
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preset-light .theme-color {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-theme-preview {
|
||||||
|
opacity: 0.4;
|
||||||
|
transition: opacity .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-picker.popover-active .current-theme-preview, .theme-picker:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
.masonry {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--widget-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.masonry-column {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-small-content-bounds {
|
||||||
|
max-width: 350px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
clip-path: inset(50%);
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-horizontal-text {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-horizontal-text > *:not(:last-child)::after {
|
||||||
|
content: '•' / "";
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
margin: 0 0.4rem;
|
||||||
|
position: relative;
|
||||||
|
top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
word-spacing: -0.18em;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details[open] .summary {
|
||||||
|
margin-bottom: .8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -.3rem -.8rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background-color: var(--color-widget-background-highlight);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details[open] .summary::before, .summary:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details:not([open]) .list-with-transition {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary::after {
|
||||||
|
content: "◀" / "";
|
||||||
|
font-size: 1.2em;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
line-height: 1.3em;
|
||||||
|
right: 0;
|
||||||
|
transition: rotate .5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] .summary::after {
|
||||||
|
rotate: -90deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO: refactor, otherwise I hope I never have to change dynamic columns again */
|
||||||
|
.dynamic-columns {
|
||||||
|
--list-half-gap: 0.5rem;
|
||||||
|
gap: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--columns-per-row), 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-columns > * {
|
||||||
|
padding-left: var(--widget-content-horizontal-padding);
|
||||||
|
border-left: 1px solid var(--color-separator);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-columns > *:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 599px) {
|
||||||
|
.dynamic-columns { gap: 0; }
|
||||||
|
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
|
||||||
|
.dynamic-columns > * {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.dynamic-columns > *:not(:first-child) {
|
||||||
|
margin-top: calc(var(--list-half-gap) * 2);
|
||||||
|
}
|
||||||
|
.dynamic-columns.list-with-separator > *:not(:first-child) {
|
||||||
|
margin-top: var(--list-half-gap);
|
||||||
|
border-top: 1px solid var(--color-separator);
|
||||||
|
padding-top: var(--list-half-gap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@container widget (min-width: 600px) and (max-width: 849px) {
|
||||||
|
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
|
||||||
|
.dynamic-columns > :nth-child(2n-1) {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@container widget (min-width: 850px) and (max-width: 1249px) {
|
||||||
|
.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
|
||||||
|
.dynamic-columns > :nth-child(3n+1) {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@container widget (min-width: 1250px) and (max-width: 1499px) {
|
||||||
|
.dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
|
||||||
|
.dynamic-columns > :nth-child(4n+1) {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@container widget (min-width: 1500px) {
|
||||||
|
.dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; }
|
||||||
|
.dynamic-columns > :nth-child(5n+1) {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-horizontal {
|
||||||
|
--cards-per-row: 6.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-horizontal, .cards-vertical {
|
||||||
|
--cards-gap: calc(var(--widget-content-vertical-padding) * 0.7);
|
||||||
|
display: flex;
|
||||||
|
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 {
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-grid {
|
||||||
|
--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: 1100px) { .cards-horizontal { --cards-per-row: 4.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: 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: 1100px) { .cards-grid { --cards-per-row: 4; } }
|
||||||
|
@container widget (max-width: 850px) { .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; } }
|
||||||
|
|
||||||
|
.text-truncate,
|
||||||
|
.single-line-titles .title
|
||||||
|
{
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-line-titles .title {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-truncate-2-lines, .text-truncate-3-lines {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-truncate-3-lines { line-clamp: 3; -webkit-line-clamp: 3; }
|
||||||
|
.text-truncate-2-lines { line-clamp: 2; -webkit-line-clamp: 2; }
|
||||||
|
|
||||||
|
.visited-indicator:not(.text-truncate)::after,
|
||||||
|
.visited-indicator.text-truncate::before {
|
||||||
|
content: '↗' / "";
|
||||||
|
margin-left: 0.5em;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
top: 0.15em;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visited-indicator.text-truncate {
|
||||||
|
direction: rtl;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visited-indicator:not(:visited)::before, .visited-indicator:not(:visited)::after {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-columns-transitioned .list-with-transition > * { animation: collapsibleItemReveal .25s backwards; }
|
||||||
|
.list-with-transition > *:nth-child(2) { animation-delay: 30ms; }
|
||||||
|
.list-with-transition > *:nth-child(3) { animation-delay: 60ms; }
|
||||||
|
.list-with-transition > *:nth-child(4) { animation-delay: 90ms; }
|
||||||
|
.list-with-transition > *:nth-child(5) { animation-delay: 120ms; }
|
||||||
|
.list-with-transition > *:nth-child(6) { animation-delay: 150ms; }
|
||||||
|
.list-with-transition > *:nth-child(7) { animation-delay: 180ms; }
|
||||||
|
.list-with-transition > *:nth-child(8) { animation-delay: 210ms; }
|
||||||
|
|
||||||
|
.list > *:not(:first-child) {
|
||||||
|
margin-top: calc(var(--list-half-gap) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list.list-with-separator > *:not(:first-child) {
|
||||||
|
margin-top: var(--list-half-gap);
|
||||||
|
border-top: 1px solid var(--color-separator);
|
||||||
|
padding-top: var(--list-half-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-container:not(.container-expanded) > .collapsible-item {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-item {
|
||||||
|
animation: collapsibleItemReveal .25s backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes collapsibleItemReveal {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle-button {
|
||||||
|
font: inherit;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: var(--font-size-h4);
|
||||||
|
padding: var(--widget-content-vertical-padding) 0;
|
||||||
|
background: var(--color-widget-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle-button.container-expanded {
|
||||||
|
position: sticky;
|
||||||
|
/* -1px to hide 1px gap on chrome */
|
||||||
|
bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle-button-icon {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 1rem;
|
||||||
|
position: relative;
|
||||||
|
top: -.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle-button-icon::before {
|
||||||
|
content: '' / "";
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle-button.container-expanded .expand-toggle-button-icon::before {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-grid.collapsible-container + .expand-toggle-button {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-content:has(.expand-toggle-button:last-child) {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-container::before, .carousel-container::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 2rem;
|
||||||
|
top: 0;
|
||||||
|
bottom: 1rem;
|
||||||
|
z-index: 10;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition-duration: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-container::before {
|
||||||
|
background: linear-gradient(to right, var(--color-background), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-container::after {
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(to left, var(--color-background), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-container.show-left-cutoff::before, .carousel-container.show-right-cutoff::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-scheme=light]) .flat-icon {
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments > * {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
font-size: var(--font-size-h6);
|
||||||
|
background-color: var(--color-separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
border: 1px solid var(--color-progress-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
height: 1.5rem;
|
||||||
|
/* naughty, but oh so beautiful */
|
||||||
|
margin-inline: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-combined {
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-active > .progress-bar {
|
||||||
|
transition: border-color .3s;
|
||||||
|
border-color: var(--color-text-subdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-value {
|
||||||
|
--half-border-radius: calc(var(--border-radius) / 2);
|
||||||
|
border-radius: 0 var(--half-border-radius) var(--half-border-radius) 0;
|
||||||
|
background: var(--color-progress-value);
|
||||||
|
width: calc(var(--percent) * 1%);
|
||||||
|
min-width: 1px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-value:first-child {
|
||||||
|
border-top-left-radius: var(--half-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-value:last-child {
|
||||||
|
border-bottom-left-radius: var(--half-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-value-notice {
|
||||||
|
background: linear-gradient(to right, var(--color-progress-value) 65%, var(--color-negative));
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-separator {
|
||||||
|
min-width: 2rem;
|
||||||
|
margin-inline: 0.8rem;
|
||||||
|
flex: 1;
|
||||||
|
height: calc(1em * 1.1);
|
||||||
|
border-bottom: 1px dotted var(--color-text-subdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
filter: grayscale(0.2) contrast(0.9);
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: filter 0.2s, opacity .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-container {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--color-separator);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-container > * {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-parent:hover .thumbnail {
|
||||||
|
opacity: 1;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.hide-scrollbars {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide on Safari and Chrome */
|
||||||
|
.hide-scrollbars::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-icon {
|
||||||
|
width: 2.3rem;
|
||||||
|
height: 2.3rem;
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-h1 { font-size: var(--font-size-h1); }
|
||||||
|
.size-h2 { font-size: var(--font-size-h2); }
|
||||||
|
.size-h3 { font-size: var(--font-size-h3); }
|
||||||
|
.size-h4 { font-size: var(--font-size-h4); }
|
||||||
|
.size-base { font-size: var(--font-size-base); }
|
||||||
|
.size-h5 { font-size: var(--font-size-h5); }
|
||||||
|
.size-h6 { font-size: var(--font-size-h6); }
|
||||||
|
|
||||||
|
.color-highlight { color: var(--color-text-highlight); }
|
||||||
|
.color-paragraph { color: var(--color-text-paragraph); }
|
||||||
|
.color-base { color: var(--color-text-base); }
|
||||||
|
.color-subdue { color: var(--color-text-subdue); }
|
||||||
|
.color-negative { color: var(--color-negative); }
|
||||||
|
.color-positive { color: var(--color-positive); }
|
||||||
|
.color-primary { color: var(--color-primary); }
|
||||||
|
|
||||||
|
.color-primary-if-not-visited:not(:visited) {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-and-drop-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-and-drop-decoy {
|
||||||
|
outline: 1px dashed var(--color-primary);
|
||||||
|
opacity: 0.25;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-and-drop-draggable {
|
||||||
|
position: absolute;
|
||||||
|
cursor: grabbing !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-and-drop-draggable:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-and-drop-draggable * {
|
||||||
|
cursor: grabbing !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-scaling-textarea-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-scaling-textarea {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
resize: none;
|
||||||
|
color: inherit;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-scaling-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-scaling-textarea-mimic {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
min-height: 1lh;
|
||||||
|
user-select: none;
|
||||||
|
word-wrap: break-word;
|
||||||
|
font: inherit;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-help { cursor: help; }
|
||||||
|
.rounded { border-radius: var(--border-radius); }
|
||||||
|
.break-all { word-break: break-all; }
|
||||||
|
.text-left { text-align: left; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-elevate { margin-top: -0.2em; }
|
||||||
|
.text-compact { word-spacing: -0.18em; }
|
||||||
|
.text-very-compact { word-spacing: -0.35em; }
|
||||||
|
.rtl { direction: rtl; }
|
||||||
|
.shrink { flex-shrink: 1; }
|
||||||
|
.shrink-0 { flex-shrink: 0; }
|
||||||
|
.min-width-0 { min-width: 0; }
|
||||||
|
.max-width-100 { max-width: 100%; }
|
||||||
|
.block { display: block; }
|
||||||
|
.inline-block { display: inline-block; }
|
||||||
|
.overflow-hidden { overflow: hidden; }
|
||||||
|
.relative { position: relative; }
|
||||||
|
.flex { display: flex; }
|
||||||
|
.flex-1 { flex: 1; }
|
||||||
|
.flex-wrap { flex-wrap: wrap; }
|
||||||
|
.flex-nowrap { flex-wrap: nowrap; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.justify-stretch { justify-content: stretch; }
|
||||||
|
.justify-evenly { justify-content: space-evenly; }
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.justify-end { justify-content: end; }
|
||||||
|
.uppercase { text-transform: uppercase; }
|
||||||
|
.grow { flex-grow: 1; }
|
||||||
|
.flex-column { flex-direction: column; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.self-center { align-self: center; }
|
||||||
|
.items-start { align-items: start; }
|
||||||
|
.items-end { align-items: end; }
|
||||||
|
.gap-5 { gap: 0.5rem; }
|
||||||
|
.gap-7 { gap: 0.7rem; }
|
||||||
|
.gap-10 { gap: 1rem; }
|
||||||
|
.gap-12 { gap: 1.2rem; }
|
||||||
|
.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-left-auto { margin-left: auto; }
|
||||||
|
.margin-top-3 { margin-top: 0.3rem; }
|
||||||
|
.margin-top-5 { margin-top: 0.5rem; }
|
||||||
|
.margin-top-7 { margin-top: 0.7rem; }
|
||||||
|
.margin-top-10 { margin-top: 1rem; }
|
||||||
|
.margin-top-15 { margin-top: 1.5rem; }
|
||||||
|
.margin-top-20 { margin-top: 2rem; }
|
||||||
|
.margin-top-25 { margin-top: 2.5rem; }
|
||||||
|
.margin-top-35 { margin-top: 3.5rem; }
|
||||||
|
.margin-top-40 { margin-top: 4rem; }
|
||||||
|
.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-8 { margin-block: 0.8rem; }
|
||||||
|
.margin-block-10 { margin-block: 1rem; }
|
||||||
|
.margin-block-15 { margin-block: 1.5rem; }
|
||||||
|
.margin-bottom-3 { margin-bottom: 0.3rem; }
|
||||||
|
.margin-bottom-5 { margin-bottom: 0.5rem; }
|
||||||
|
.margin-bottom-7 { margin-bottom: 0.7rem; }
|
||||||
|
.margin-bottom-10 { margin-bottom: 1rem; }
|
||||||
|
.margin-bottom-15 { margin-bottom: 1.5rem; }
|
||||||
|
.margin-bottom-auto { margin-bottom: auto; }
|
||||||
|
.margin-bottom-widget { margin-bottom: var(--widget-content-vertical-padding); }
|
||||||
|
.padding-widget { padding: var(--widget-content-padding); }
|
||||||
|
.padding-block-widget { padding-block: var(--widget-content-vertical-padding); }
|
||||||
|
.padding-inline-widget { padding-inline: var(--widget-content-horizontal-padding); }
|
||||||
|
.pointer-events-none { pointer-events: none; }
|
||||||
|
.select-none { user-select: none; }
|
||||||
|
.padding-block-5 { padding-block: 0.5rem; }
|
||||||
|
.scale-half { transform: scale(0.5); }
|
||||||
|
.list { --list-half-gap: 0rem; }
|
||||||
|
.list-gap-2 { --list-half-gap: 0.1rem; }
|
||||||
|
.list-gap-4 { --list-half-gap: 0.2rem; }
|
||||||
|
.list-gap-8 { --list-half-gap: 0.4rem; }
|
||||||
|
.list-gap-10 { --list-half-gap: 0.5rem; }
|
||||||
|
.list-gap-14 { --list-half-gap: 0.7rem; }
|
||||||
|
.list-gap-20 { --list-half-gap: 1rem; }
|
||||||
|
.list-gap-24 { --list-half-gap: 1.2rem; }
|
||||||
|
.list-gap-34 { --list-half-gap: 1.7rem; }
|
||||||
|
|
||||||
|
@media (max-width: 1190px) {
|
||||||
|
.size-base-on-mobile { font-size: var(--font-size-base); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
.bookmarks-group {
|
||||||
|
--bookmarks-group-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-group-title {
|
||||||
|
color: var(--bookmarks-group-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
|
||||||
|
content: '↗' / "";
|
||||||
|
margin-left: 0.5em;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
top: 0.15em;
|
||||||
|
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;
|
||||||
|
opacity: 0.7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
.old-calendar-day {
|
||||||
|
width: calc(100% / 7);
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.6rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.old-calendar-day-today {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%)));
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-dates {
|
||||||
|
text-align: center;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-date {
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-current-date {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background-color: var(--color-popover-border);
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-spillover-date {
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header-button {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
z-index: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header-button::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -0.2rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background-color: var(--color-text-subdue);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header-button:hover::before {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-undo-button {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: text-top;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
margin-left: 0.7rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.clock-time {
|
||||||
|
min-width: 8ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-time span {
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
.dns-stats-totals {
|
||||||
|
transition: opacity .3s;
|
||||||
|
transition-delay: 50ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats:has(.dns-stats-graph .popover-active) .dns-stats-totals {
|
||||||
|
opacity: 0.1;
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph {
|
||||||
|
--graph-height: 70px;
|
||||||
|
height: var(--graph-height);
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-gridlines-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-gridlines {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-columns {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-column {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
width: calc(100% / 8);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-column::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 1px 0;
|
||||||
|
opacity: 0;
|
||||||
|
background: var(--color-text-base);
|
||||||
|
transition: opacity .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-column:hover::before {
|
||||||
|
opacity: 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-bar {
|
||||||
|
width: 14px;
|
||||||
|
height: calc((var(--bar-height) / 100) * var(--graph-height));
|
||||||
|
border: 1px solid var(--color-progress-border);
|
||||||
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
|
display: flex;
|
||||||
|
background: var(--color-widget-background);
|
||||||
|
padding: 2px 2px 0 2px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
transition: border-color .2s;
|
||||||
|
min-height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-column.popover-active .dns-stats-graph-bar {
|
||||||
|
border-color: var(--color-text-subdue);
|
||||||
|
border-bottom-color: var(--color-progress-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-bar > * {
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--color-vertical-progress-value);
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-bar > .queries {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-bar > *:last-child {
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-bar > .blocked {
|
||||||
|
background-color: var(--color-negative);
|
||||||
|
flex-basis: calc(var(--percent) - 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-time, .dns-stats-graph-columns:hover .dns-stats-graph-time {
|
||||||
|
position: absolute;
|
||||||
|
font-size: var(--font-size-h6);
|
||||||
|
inset-inline: 0;
|
||||||
|
text-align: center;
|
||||||
|
height: 2.5rem;
|
||||||
|
line-height: 2.5rem;
|
||||||
|
top: 100%;
|
||||||
|
user-select: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-0.5rem);
|
||||||
|
transition: opacity .2s, transform .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-column:hover .dns-stats-graph-time {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-stats-graph-columns:hover .dns-stats-graph-column:not(:hover) .dns-stats-graph-time {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
.docker-container-icon {
|
||||||
|
display: block;
|
||||||
|
filter: grayscale(0.4);
|
||||||
|
object-fit: contain;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
width: 2.7rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: filter 0.3s, opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-container-icon.flat-icon {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-container:hover .docker-container-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-container:hover .docker-container-icon:not(.flat-icon) {
|
||||||
|
filter: grayscale(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-container-status-icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
.widget-group-header {
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-title {
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
border: none;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px dotted transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color .3s, border-color .3s;
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
line-height: calc(1.6em - 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-title:hover:not(.widget-group-title-current) {
|
||||||
|
color: var(--color-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-group-title-current {
|
||||||
|
border-bottom-color: var(--color-text-base-muted);
|
||||||
|
color: var(--color-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
.market-chart {
|
||||||
|
margin-left: auto;
|
||||||
|
width: 6.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-chart svg {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-values {
|
||||||
|
min-width: 8rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
.monitor-site-icon {
|
||||||
|
display: block;
|
||||||
|
opacity: 0.8;
|
||||||
|
filter: grayscale(0.4);
|
||||||
|
object-fit: contain;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
width: 3.2rem;
|
||||||
|
position: relative;
|
||||||
|
top: -0.1rem;
|
||||||
|
transition: filter 0.3s, opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-site-icon.flat-icon {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-site:hover .monitor-site-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-site:hover .monitor-site-icon:not(.flat-icon) {
|
||||||
|
filter: grayscale(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-site-status-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-site-status-icon-compact {
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
.reddit-card-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: 0% 20%;
|
||||||
|
opacity: 0.15;
|
||||||
|
filter: blur(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reddit-card-thumbnail-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reddit-card-thumbnail-container::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(0deg, var(--color-widget-background) 10%, transparent);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.release-source-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
.rss-card-image {
|
||||||
|
height: var(--rss-thumbnail-height, 10rem);
|
||||||
|
object-fit: cover;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
.widget-type-server-info {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server + .server {
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-details {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon {
|
||||||
|
height: 3rem;
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-spicy-cpu-icon {
|
||||||
|
height: 1em;
|
||||||
|
align-self: center;
|
||||||
|
margin-left: 0.4em;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-stat-unavailable {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container widget (min-width: 650px) {
|
||||||
|
.server {
|
||||||
|
gap: 2rem;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server + .server {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
justify-content: unset;
|
||||||
|
margin-right: auto;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-stats {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: right;
|
||||||
|
min-width: 450px;
|
||||||
|
margin-top: 0;
|
||||||
|
gap: 2rem;
|
||||||
|
padding-bottom: 0.8rem;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-stats > * {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
.todo-widget {
|
||||||
|
padding-top: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-plus-icon {
|
||||||
|
--icon-color: var(--color-text-subdue);
|
||||||
|
position: relative;
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-plus-icon::before, .todo-plus-icon::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
background-color: var(--icon-color);
|
||||||
|
transition: background-color .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-plus-icon::before {
|
||||||
|
width: 2px;
|
||||||
|
inset-block: 0.2rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-plus-icon::after {
|
||||||
|
height: 2px;
|
||||||
|
inset-inline: 0.2rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-input textarea::placeholder {
|
||||||
|
color: var(--color-text-base-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-input {
|
||||||
|
position: relative;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-input:focus-within .todo-plus-icon {
|
||||||
|
--icon-color: var(--color-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item {
|
||||||
|
transform-origin: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-checkbox {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
border: 2px solid var(--color-text-subdue);
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
transition: border-color .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-checkbox::before {
|
||||||
|
content: "";
|
||||||
|
inset: -1rem;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-checkbox::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0.3rem;
|
||||||
|
border-radius: 0.1rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-checkbox:checked::after {
|
||||||
|
background: var(--color-primary);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-checkbox:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-text {
|
||||||
|
color: var(--color-text-base);
|
||||||
|
transition: color .35s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-text:focus {
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-drag-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.5rem;
|
||||||
|
inset-inline: 0;
|
||||||
|
height: 1rem;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.is-being-dragged .todo-item-drag-handle {
|
||||||
|
height: 3rem;
|
||||||
|
top: -1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item:has(.todo-item-checkbox:checked) .todo-item-text {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item-delete {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .2s;
|
||||||
|
outline-offset: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item:hover .todo-item-delete, .todo-item:focus-within .todo-item-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.is-being-dragged .todo-item-delete {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
.twitch-category-thumbnail {
|
||||||
|
width: 5rem;
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.twitch-channel-avatar {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twitch-channel-avatar-container {
|
||||||
|
width: 4.4rem;
|
||||||
|
height: 4.4rem;
|
||||||
|
border: 2px solid var(--color-text-subdue);
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twitch-channel-live .twitch-channel-avatar-container {
|
||||||
|
border: 2px solid var(--color-positive);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twitch-channel-live .twitch-channel-avatar-container::after {
|
||||||
|
content: 'LIVE';
|
||||||
|
position: absolute;
|
||||||
|
background: var(--color-positive);
|
||||||
|
color: var(--color-widget-background);
|
||||||
|
font-size: var(--font-size-h6);
|
||||||
|
left: 50%;
|
||||||
|
bottom: -35%;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding-inline: 0.3rem;
|
||||||
|
transform: translate(-50%);
|
||||||
|
border: 2px solid var(--color-widget-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.twitch-stream-preview {
|
||||||
|
max-width: 100%;
|
||||||
|
width: 400px;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
.video-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 8.9;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-horizontal-list-thumbnail {
|
||||||
|
height: 4rem;
|
||||||
|
aspect-ratio: 16 / 8.9;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
.weather-column {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: end;
|
||||||
|
flex-direction: column;
|
||||||
|
width: calc(100% / 12);
|
||||||
|
padding-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-value, .weather-columns:hover .weather-column-value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-highlight);
|
||||||
|
letter-spacing: -0.1rem;
|
||||||
|
margin-right: 0.1rem;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0.5rem);
|
||||||
|
transition: opacity .2s, transform .2s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-current .weather-column-value, .weather-column:hover .weather-column-value {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-value::after {
|
||||||
|
position: absolute;
|
||||||
|
content: '°';
|
||||||
|
left: 100%;
|
||||||
|
color: var(--color-text-subdue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-value.weather-column-value-negative::before {
|
||||||
|
position: absolute;
|
||||||
|
content: '-';
|
||||||
|
right: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-bar, .weather-columns:hover .weather-bar {
|
||||||
|
height: calc(20px + var(--weather-bar-height) * 40px);
|
||||||
|
width: 6px;
|
||||||
|
background-color: hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 18%)));
|
||||||
|
border: 1px solid hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 24%)));
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
mask-image: linear-gradient(0deg, transparent 0, #000 10px);
|
||||||
|
-webkit-mask-image: linear-gradient(0deg, transparent 0, #000 10px);
|
||||||
|
transition: background-color .2s, border-color .2s, width .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-current .weather-bar, .weather-column:hover .weather-bar {
|
||||||
|
width: 10px;
|
||||||
|
background-color: hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 40%)));
|
||||||
|
border: 1px solid hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 50%)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-rain {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
bottom: 20%;
|
||||||
|
overflow: hidden;
|
||||||
|
mask-image: linear-gradient(0deg, transparent 40%, #000);
|
||||||
|
-webkit-mask-image: linear-gradient(0deg, transparent 40%, #000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-rain::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
/* TODO: figure out a way to make it look continuous between columns, right now */
|
||||||
|
/* depending on the width of the page the rain inside two columns next to each other */
|
||||||
|
/* can overlap and look bad */
|
||||||
|
background: radial-gradient(circle at 4px 4px, hsl(200, 90%, 70%, 0.4) 1px, transparent 0);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
transform: rotate(45deg) translate(-50%, 25%);
|
||||||
|
height: 130%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
left: 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column:nth-child(3) .weather-column-time,
|
||||||
|
.weather-column:nth-child(7) .weather-column-time,
|
||||||
|
.weather-column:nth-child(11) .weather-column-time {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-time, .weather-columns:hover .weather-column-time {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: var(--font-size-h6);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-0.5rem);
|
||||||
|
transition: opacity .2s, transform .2s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column:hover .weather-column-time {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-daylight {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(0deg, transparent 30px, hsl(50, 50%, 30%, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-daylight-sunrise {
|
||||||
|
border-radius: 20px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-column-daylight-sunset {
|
||||||
|
border-radius: 0 20px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-icon {
|
||||||
|
width: 0.8em;
|
||||||
|
height: 0.8em;
|
||||||
|
border-radius: 0 50% 50% 50%;
|
||||||
|
background-color: currentColor;
|
||||||
|
transform: rotate(225deg) translate(.1em, .1em);
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-icon::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
width: .4em;
|
||||||
|
height: .4em;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-widget-background);
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
@import "widget-bookmarks.css";
|
||||||
|
@import "widget-calendar.css";
|
||||||
|
@import "widget-clock.css";
|
||||||
|
@import "widget-dns-stats.css";
|
||||||
|
@import "widget-docker-containers.css";
|
||||||
|
@import "widget-group.css";
|
||||||
|
@import "widget-markets.css";
|
||||||
|
@import "widget-monitor.css";
|
||||||
|
@import "widget-reddit.css";
|
||||||
|
@import "widget-releases.css";
|
||||||
|
@import "widget-rss.css";
|
||||||
|
@import "widget-search.css";
|
||||||
|
@import "widget-server-stats.css";
|
||||||
|
@import "widget-twitch.css";
|
||||||
|
@import "widget-videos.css";
|
||||||
|
@import "widget-weather.css";
|
||||||
|
@import "widget-todo.css";
|
||||||
|
|
||||||
|
@import "forum-posts.css";
|
||||||
|
|
||||||
|
.widget-error-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1.8rem;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-error-header::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: calc(0rem - (var(--widget-content-vertical-padding) / 2)) calc(0rem - (var(--widget-content-horizontal-padding) / 2));
|
||||||
|
background: var(--color-negative);
|
||||||
|
opacity: 0.05;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-error-icon {
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
stroke: var(--color-negative);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-widgets {
|
||||||
|
margin-bottom: var(--widget-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-content {
|
||||||
|
container-type: inline-size;
|
||||||
|
container-name: widget;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-content:not(.widget-content-frameless) {
|
||||||
|
padding: var(--widget-content-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-content:not(.widget-content-frameless), .widget-content-frame {
|
||||||
|
background: var(--color-widget-background);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: 1px solid var(--color-widget-content-border);
|
||||||
|
box-shadow: 0px 3px 0px 0px hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl)) - 0.5%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-header {
|
||||||
|
padding: 0 calc(var(--widget-content-horizontal-padding) + 1px);
|
||||||
|
font-size: var(--font-size-h4);
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-beta-icon {
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform .45s, opacity .45s, stroke .45s;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-beta-icon:hover, .widget-header .popover-active > .widget-beta-icon {
|
||||||
|
fill: var(--color-text-highlight);
|
||||||
|
transform: translateY(-10%) scale(1.3);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget + .widget {
|
||||||
|
margin-top: var(--widget-gap);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 607 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="26" height="26" rx="3" fill="#151519"/>
|
||||||
|
<rect x="2" y="2" width="10" height="22" rx="2" fill="#ededed"/>
|
||||||
|
<rect x="14" y="2" width="10" height="10" rx="2" fill="#ededed"/>
|
||||||
|
<path d="M16.3018 5.04032L17.328 4H22V8.72984L20.9014 9.81855V6.49193C20.9014 6.35484 20.9095 6.21774 20.9256 6.08065C20.9497 5.93548 20.9859 5.81855 21.0342 5.72984L16.7847 10L16 9.2379L20.3099 4.93145C20.2294 4.97984 20.1167 5.0121 19.9718 5.02823C19.827 5.03629 19.674 5.04032 19.5131 5.04032H16.3018Z" fill="#151519"/>
|
||||||
|
<rect x="14" y="14" width="10" height="10" rx="2" fill="#ededed"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 677 B |
@@ -31,3 +31,28 @@ export function slideFade({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function animateReposition(
|
||||||
|
element,
|
||||||
|
onAnimEnd,
|
||||||
|
animOptions = { duration: 400, easing: easeOutQuint }
|
||||||
|
) {
|
||||||
|
const rectBefore = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const rectAfter = element.getBoundingClientRect();
|
||||||
|
const offsetY = rectBefore.y - rectAfter.y;
|
||||||
|
const offsetX = rectBefore.x - rectAfter.x;
|
||||||
|
|
||||||
|
element.animate({
|
||||||
|
keyframes: [
|
||||||
|
{ transform: `translate(${offsetX}px, ${offsetY}px)` },
|
||||||
|
{ transform: 'none' }
|
||||||
|
],
|
||||||
|
options: animOptions
|
||||||
|
}, onAnimEnd);
|
||||||
|
|
||||||
|
return rectAfter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const [datesEntranceLeft, datesEntranceRight] = directions(
|
|||||||
const undoEntrance = slideFade({ direction: "left", distance: "100%", duration: 300 });
|
const undoEntrance = slideFade({ direction: "left", distance: "100%", duration: 300 });
|
||||||
|
|
||||||
export default function(element) {
|
export default function(element) {
|
||||||
element.swap(Calendar(
|
element.swapWith(Calendar(
|
||||||
Number(element.dataset.firstDayOfWeek ?? 1)
|
Number(element.dataset.firstDayOfWeek ?? 1)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { find } from "./templating.js";
|
||||||
|
|
||||||
|
const AUTH_ENDPOINT = pageData.baseURL + "/api/authenticate";
|
||||||
|
|
||||||
|
const showPasswordSVG = `<svg class="form-input-icon" stroke="var(--color-text-base)" 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="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const hidePasswordSVG = `<svg class="form-input-icon" stroke="var(--color-text-base)" 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.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const container = find("#login-container");
|
||||||
|
const usernameInput = find("#username");
|
||||||
|
const passwordInput = find("#password");
|
||||||
|
const errorMessage = find("#error-message");
|
||||||
|
const loginButton = find("#login-button");
|
||||||
|
const toggleVisibilityButton = find("#toggle-password-visibility");
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
lastUsername: "",
|
||||||
|
lastPassword: "",
|
||||||
|
isLoading: false,
|
||||||
|
isRateLimited: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const lang = {
|
||||||
|
showPassword: "Show password",
|
||||||
|
hidePassword: "Hide password",
|
||||||
|
incorrectCredentials: "Incorrect username or password",
|
||||||
|
rateLimited: "Too many login attempts, try again in a few minutes",
|
||||||
|
unknownError: "An error occurred, please try again",
|
||||||
|
};
|
||||||
|
|
||||||
|
container.clearStyles("display");
|
||||||
|
setTimeout(() => usernameInput.focus(), 200);
|
||||||
|
|
||||||
|
toggleVisibilityButton
|
||||||
|
.html(showPasswordSVG)
|
||||||
|
.attr("title", lang.showPassword)
|
||||||
|
.on("click", function() {
|
||||||
|
if (passwordInput.type === "password") {
|
||||||
|
passwordInput.type = "text";
|
||||||
|
toggleVisibilityButton.html(hidePasswordSVG).attr("title", lang.hidePassword);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordInput.type = "password";
|
||||||
|
toggleVisibilityButton.html(showPasswordSVG).attr("title", lang.showPassword);
|
||||||
|
});
|
||||||
|
|
||||||
|
function enableLoginButtonIfCriteriaMet() {
|
||||||
|
const usernameValue = usernameInput.value.trim();
|
||||||
|
const passwordValue = passwordInput.value.trim();
|
||||||
|
|
||||||
|
const usernameValid = usernameValue.length >= 3;
|
||||||
|
const passwordValid = passwordValue.length >= 6;
|
||||||
|
|
||||||
|
const isUsingLastCredentials =
|
||||||
|
usernameValue === state.lastUsername
|
||||||
|
&& passwordValue === state.lastPassword;
|
||||||
|
|
||||||
|
loginButton.disabled = !(
|
||||||
|
usernameValid
|
||||||
|
&& passwordValid
|
||||||
|
&& !isUsingLastCredentials
|
||||||
|
&& !state.isLoading
|
||||||
|
&& !state.isRateLimited
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameInput.on("input", enableLoginButtonIfCriteriaMet);
|
||||||
|
passwordInput.on("input", enableLoginButtonIfCriteriaMet);
|
||||||
|
|
||||||
|
async function handleLoginAttempt() {
|
||||||
|
state.lastUsername = usernameInput.value;
|
||||||
|
state.lastPassword = passwordInput.value;
|
||||||
|
errorMessage.text("");
|
||||||
|
|
||||||
|
loginButton.disable();
|
||||||
|
state.isLoading = true;
|
||||||
|
|
||||||
|
const response = await fetch(AUTH_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: usernameInput.value,
|
||||||
|
password: passwordInput.value
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
state.isLoading = false;
|
||||||
|
if (response.status === 200) {
|
||||||
|
setTimeout(() => { window.location.href = pageData.baseURL + "/"; }, 300);
|
||||||
|
|
||||||
|
container.animate({
|
||||||
|
keyframes: [{ offset: 1, transform: "scale(0.95)", opacity: 0 }],
|
||||||
|
options: { duration: 300, easing: "ease", fill: "forwards" }}
|
||||||
|
);
|
||||||
|
|
||||||
|
find("footer")?.animate({
|
||||||
|
keyframes: [{ offset: 1, opacity: 0 }],
|
||||||
|
options: { duration: 300, easing: "ease", fill: "forwards", delay: 50 }
|
||||||
|
});
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
errorMessage.text(lang.incorrectCredentials);
|
||||||
|
passwordInput.focus();
|
||||||
|
} else if (response.status === 429) {
|
||||||
|
errorMessage.text(lang.rateLimited);
|
||||||
|
state.isRateLimited = true;
|
||||||
|
const retryAfter = response.headers.get("Retry-After") || 30;
|
||||||
|
setTimeout(() => {
|
||||||
|
state.lastUsername = "";
|
||||||
|
state.lastPassword = "";
|
||||||
|
state.isRateLimited = false;
|
||||||
|
|
||||||
|
enableLoginButtonIfCriteriaMet();
|
||||||
|
}, retryAfter * 1000);
|
||||||
|
} else {
|
||||||
|
errorMessage.text(lang.unknownError);
|
||||||
|
passwordInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loginButton.disable().on("click", handleLoginAttempt);
|
||||||
@@ -37,9 +37,6 @@ export function setupMasonries() {
|
|||||||
columnsFragment.append(column);
|
columnsFragment.append(column);
|
||||||
}
|
}
|
||||||
|
|
||||||
// poor man's masonry
|
|
||||||
// TODO: add an option that allows placing items in the
|
|
||||||
// shortest column instead of iterating the columns in order
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
columnsFragment.children[i % columnsCount].appendChild(items[i]);
|
columnsFragment.children[i % columnsCount].appendChild(items[i]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { setupPopovers } from './popover.js';
|
import { setupPopovers } from './popover.js';
|
||||||
import { setupMasonries } from './masonry.js';
|
import { setupMasonries } from './masonry.js';
|
||||||
import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js';
|
import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js';
|
||||||
|
import { elem, find, findAll } from './templating.js';
|
||||||
|
|
||||||
async function fetchPageContent(pageData) {
|
async function fetchPageContent(pageData) {
|
||||||
// TODO: handle non 200 status codes/time outs
|
// TODO: handle non 200 status codes/time outs
|
||||||
@@ -104,6 +105,7 @@ function setupSearchBoxes() {
|
|||||||
for (let i = 0; i < searchWidgets.length; i++) {
|
for (let i = 0; i < searchWidgets.length; i++) {
|
||||||
const widget = searchWidgets[i];
|
const widget = searchWidgets[i];
|
||||||
const defaultSearchUrl = widget.dataset.defaultSearchUrl;
|
const defaultSearchUrl = widget.dataset.defaultSearchUrl;
|
||||||
|
const target = widget.dataset.target || "_blank";
|
||||||
const newTab = widget.dataset.newTab === "true";
|
const newTab = widget.dataset.newTab === "true";
|
||||||
const inputElement = widget.getElementsByClassName("search-input")[0];
|
const inputElement = widget.getElementsByClassName("search-input")[0];
|
||||||
const bangElement = widget.getElementsByClassName("search-bang")[0];
|
const bangElement = widget.getElementsByClassName("search-bang")[0];
|
||||||
@@ -143,7 +145,7 @@ function setupSearchBoxes() {
|
|||||||
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
|
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
|
||||||
|
|
||||||
if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
|
if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
|
||||||
window.open(url, '_blank').focus();
|
window.open(url, target).focus();
|
||||||
} else {
|
} else {
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
}
|
}
|
||||||
@@ -192,7 +194,7 @@ function setupSearchBoxes() {
|
|||||||
|
|
||||||
document.addEventListener("keydown", (event) => {
|
document.addEventListener("keydown", (event) => {
|
||||||
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
|
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
|
||||||
if (event.key != "s") return;
|
if (event.code != "KeyS") return;
|
||||||
|
|
||||||
inputElement.focus();
|
inputElement.focus();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -284,7 +286,9 @@ function setupGroups() {
|
|||||||
|
|
||||||
for (let i = 0; i < titles.length; i++) {
|
for (let i = 0; i < titles.length; i++) {
|
||||||
titles[i].classList.remove("widget-group-title-current");
|
titles[i].classList.remove("widget-group-title-current");
|
||||||
|
titles[i].setAttribute("aria-selected", "false");
|
||||||
tabs[i].classList.remove("widget-group-content-current");
|
tabs[i].classList.remove("widget-group-content-current");
|
||||||
|
tabs[i].setAttribute("aria-hidden", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current < t) {
|
if (current < t) {
|
||||||
@@ -296,7 +300,9 @@ function setupGroups() {
|
|||||||
current = t;
|
current = t;
|
||||||
|
|
||||||
title.classList.add("widget-group-title-current");
|
title.classList.add("widget-group-title-current");
|
||||||
|
title.setAttribute("aria-selected", "true");
|
||||||
tabs[t].classList.add("widget-group-content-current");
|
tabs[t].classList.add("widget-group-content-current");
|
||||||
|
tabs[t].setAttribute("aria-hidden", "false");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -636,6 +642,17 @@ async function setupCalendars() {
|
|||||||
calendar.default(elems[i]);
|
calendar.default(elems[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setupTodos() {
|
||||||
|
const elems = Array.from(document.getElementsByClassName("todo"));
|
||||||
|
if (elems.length == 0) return;
|
||||||
|
|
||||||
|
const todo = await import ('./todo.js');
|
||||||
|
|
||||||
|
for (let i = 0; i < elems.length; i++){
|
||||||
|
todo.default(elems[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setupTruncatedElementTitles() {
|
function setupTruncatedElementTitles() {
|
||||||
const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines");
|
const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines");
|
||||||
|
|
||||||
@@ -645,11 +662,90 @@ function setupTruncatedElementTitles() {
|
|||||||
|
|
||||||
for (let i = 0; i < elements.length; i++) {
|
for (let i = 0; i < elements.length; i++) {
|
||||||
const element = elements[i];
|
const element = elements[i];
|
||||||
if (element.title === "") element.title = element.textContent;
|
if (element.getAttribute("title") === null)
|
||||||
|
element.title = element.innerText.trim().replace(/\s+/g, " ");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeTheme(key, onChanged) {
|
||||||
|
const themeStyleElem = find("#theme-style");
|
||||||
|
|
||||||
|
const response = await fetch(`${pageData.baseURL}/api/set-theme/${key}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status != 200) {
|
||||||
|
alert("Failed to set theme: " + response.statusText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newThemeStyle = await response.text();
|
||||||
|
|
||||||
|
const tempStyle = elem("style")
|
||||||
|
.html("* { transition: none !important; }")
|
||||||
|
.appendTo(document.head);
|
||||||
|
|
||||||
|
themeStyleElem.html(newThemeStyle);
|
||||||
|
document.documentElement.setAttribute("data-theme", key);
|
||||||
|
document.documentElement.setAttribute("data-scheme", response.headers.get("X-Scheme"));
|
||||||
|
typeof onChanged == "function" && onChanged();
|
||||||
|
setTimeout(() => { tempStyle.remove(); }, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initThemePicker() {
|
||||||
|
const themeChoicesInMobileNav = find(".mobile-navigation .theme-choices");
|
||||||
|
if (!themeChoicesInMobileNav) return;
|
||||||
|
|
||||||
|
const themeChoicesInHeader = find(".header-container .theme-choices");
|
||||||
|
|
||||||
|
if (themeChoicesInHeader) {
|
||||||
|
themeChoicesInHeader.replaceWith(
|
||||||
|
themeChoicesInMobileNav.cloneNode(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetElems = findAll(".theme-choices .theme-preset");
|
||||||
|
let themePreviewElems = document.getElementsByClassName("current-theme-preview");
|
||||||
|
let isLoading = false;
|
||||||
|
|
||||||
|
presetElems.forEach((presetElement) => {
|
||||||
|
const themeKey = presetElement.dataset.key;
|
||||||
|
|
||||||
|
if (themeKey === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (themeKey == pageData.theme) {
|
||||||
|
presetElement.classList.add("current");
|
||||||
|
}
|
||||||
|
|
||||||
|
presetElement.addEventListener("click", () => {
|
||||||
|
if (themeKey == pageData.theme) return;
|
||||||
|
if (isLoading) return;
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
changeTheme(themeKey, function() {
|
||||||
|
isLoading = false;
|
||||||
|
pageData.theme = themeKey;
|
||||||
|
presetElems.forEach((e) => { e.classList.remove("current"); });
|
||||||
|
|
||||||
|
Array.from(themePreviewElems).forEach((preview) => {
|
||||||
|
preview.querySelector(".theme-preset").replaceWith(
|
||||||
|
presetElement.cloneNode(true)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
|
||||||
|
presetElems.forEach((e) => {
|
||||||
|
if (e.dataset.key != themeKey) return;
|
||||||
|
e.classList.add("current");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function setupPage() {
|
async function setupPage() {
|
||||||
|
initThemePicker();
|
||||||
|
|
||||||
const pageElement = document.getElementById("page");
|
const pageElement = document.getElementById("page");
|
||||||
const pageContentElement = document.getElementById("page-content");
|
const pageContentElement = document.getElementById("page-content");
|
||||||
const pageContent = await fetchPageContent(pageData);
|
const pageContent = await fetchPageContent(pageData);
|
||||||
@@ -660,6 +756,7 @@ async function setupPage() {
|
|||||||
setupPopovers();
|
setupPopovers();
|
||||||
setupClocks()
|
setupClocks()
|
||||||
await setupCalendars();
|
await setupCalendars();
|
||||||
|
await setupTodos();
|
||||||
setupCarousels();
|
setupCarousels();
|
||||||
setupSearchBoxes();
|
setupSearchBoxes();
|
||||||
setupCollapsibleLists();
|
setupCollapsibleLists();
|
||||||
@@ -670,6 +767,7 @@ async function setupPage() {
|
|||||||
setupLazyImages();
|
setupLazyImages();
|
||||||
} finally {
|
} finally {
|
||||||
pageElement.classList.add("content-ready");
|
pageElement.classList.add("content-ready");
|
||||||
|
pageElement.setAttribute("aria-busy", "false");
|
||||||
|
|
||||||
for (let i = 0; i < contentReadyCallbacks.length; i++) {
|
for (let i = 0; i < contentReadyCallbacks.length; i++) {
|
||||||
contentReadyCallbacks[i]();
|
contentReadyCallbacks[i]();
|
||||||
@@ -38,6 +38,8 @@ function handleMouseEnter(event) {
|
|||||||
if (activeTarget !== target) {
|
if (activeTarget !== target) {
|
||||||
hidePopover();
|
hidePopover();
|
||||||
requestAnimationFrame(() => requestAnimationFrame(showPopover));
|
requestAnimationFrame(() => requestAnimationFrame(showPopover));
|
||||||
|
} else if (activeTarget.dataset.popoverTrigger === "click") {
|
||||||
|
hidePopover();
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -100,11 +102,14 @@ function showPopover() {
|
|||||||
contentElement.style.maxWidth = contentMaxWidth;
|
contentElement.style.maxWidth = contentMaxWidth;
|
||||||
activeTarget.classList.add("popover-active");
|
activeTarget.classList.add("popover-active");
|
||||||
document.addEventListener("keydown", handleHidePopoverOnEscape);
|
document.addEventListener("keydown", handleHidePopoverOnEscape);
|
||||||
|
window.addEventListener("scroll", queueRepositionContainer);
|
||||||
window.addEventListener("resize", queueRepositionContainer);
|
window.addEventListener("resize", queueRepositionContainer);
|
||||||
observer.observe(containerElement);
|
observer.observe(containerElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
function repositionContainer() {
|
function repositionContainer() {
|
||||||
|
if (activeTarget === null) return;
|
||||||
|
|
||||||
containerElement.style.display = "block";
|
containerElement.style.display = "block";
|
||||||
|
|
||||||
const targetBounds = activeTarget.dataset.popoverAnchor !== undefined
|
const targetBounds = activeTarget.dataset.popoverAnchor !== undefined
|
||||||
@@ -125,7 +130,7 @@ function repositionContainer() {
|
|||||||
} else if (left + containerBounds.width > window.innerWidth) {
|
} else if (left + containerBounds.width > window.innerWidth) {
|
||||||
containerElement.style.removeProperty("left");
|
containerElement.style.removeProperty("left");
|
||||||
containerElement.style.right = 0;
|
containerElement.style.right = 0;
|
||||||
containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + -1 + "px");
|
containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (document.documentElement.clientWidth - targetBounds.left - targetBoundsWidthOffset) + -1 + "px");
|
||||||
} else {
|
} else {
|
||||||
containerElement.style.removeProperty("right");
|
containerElement.style.removeProperty("right");
|
||||||
containerElement.style.left = left + "px";
|
containerElement.style.left = left + "px";
|
||||||
@@ -157,7 +162,11 @@ function hidePopover() {
|
|||||||
|
|
||||||
activeTarget.classList.remove("popover-active");
|
activeTarget.classList.remove("popover-active");
|
||||||
containerElement.style.display = "none";
|
containerElement.style.display = "none";
|
||||||
|
containerElement.style.removeProperty("top");
|
||||||
|
containerElement.style.removeProperty("left");
|
||||||
|
containerElement.style.removeProperty("right");
|
||||||
document.removeEventListener("keydown", handleHidePopoverOnEscape);
|
document.removeEventListener("keydown", handleHidePopoverOnEscape);
|
||||||
|
window.removeEventListener("scroll", queueRepositionContainer);
|
||||||
window.removeEventListener("resize", queueRepositionContainer);
|
window.removeEventListener("resize", queueRepositionContainer);
|
||||||
observer.unobserve(containerElement);
|
observer.unobserve(containerElement);
|
||||||
|
|
||||||
@@ -181,7 +190,12 @@ export function setupPopovers() {
|
|||||||
for (let i = 0; i < targets.length; i++) {
|
for (let i = 0; i < targets.length; i++) {
|
||||||
const target = targets[i];
|
const target = targets[i];
|
||||||
|
|
||||||
|
if (target.dataset.popoverTrigger === "click") {
|
||||||
|
target.addEventListener("click", handleMouseEnter);
|
||||||
|
} else {
|
||||||
target.addEventListener("mouseenter", handleMouseEnter);
|
target.addEventListener("mouseenter", handleMouseEnter);
|
||||||
|
}
|
||||||
|
|
||||||
target.addEventListener("mouseleave", handleMouseLeave);
|
target.addEventListener("mouseleave", handleMouseLeave);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ export function findAll(selector) {
|
|||||||
return document.querySelectorAll(selector);
|
return document.querySelectorAll(selector);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
HTMLCollection.prototype.map = function(fn) {
|
||||||
|
return Array.from(this).map(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
HTMLCollection.prototype.indexOf = function(element) {
|
||||||
|
return Array.prototype.indexOf.call(this, element);
|
||||||
|
}
|
||||||
|
|
||||||
const ep = HTMLElement.prototype;
|
const ep = HTMLElement.prototype;
|
||||||
const fp = DocumentFragment.prototype;
|
const fp = DocumentFragment.prototype;
|
||||||
const tp = Text.prototype;
|
const tp = Text.prototype;
|
||||||
@@ -110,7 +119,7 @@ ep.appendTo = function(parent) {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
ep.swap = function(element) {
|
ep.swapWith = function(element) {
|
||||||
this.replaceWith(element);
|
this.replaceWith(element);
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
@@ -147,6 +156,22 @@ ep.styles = function(s) {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ep.clearStyles = function(...props) {
|
||||||
|
for (let i = 0; i < props.length; i++)
|
||||||
|
this.style.removeProperty(props[i]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
ep.disable = function() {
|
||||||
|
this.disabled = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
ep.enable = function() {
|
||||||
|
this.disabled = false;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
const epAnimate = ep.animate;
|
const epAnimate = ep.animate;
|
||||||
ep.animate = function(anim, callback) {
|
ep.animate = function(anim, callback) {
|
||||||
const a = epAnimate.call(this, anim.keyframes, anim.options);
|
const a = epAnimate.call(this, anim.keyframes, anim.options);
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
import { elem, fragment } from "./templating.js";
|
||||||
|
import { animateReposition } from "./animations.js";
|
||||||
|
import { clamp, Vec2, toggleableEvents, throttledDebounce } from "./utils.js";
|
||||||
|
|
||||||
|
const trashIconSvg = `<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z" clip-rule="evenodd" />
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
export default function(element) {
|
||||||
|
element.swapWith(
|
||||||
|
Todo(element.dataset.todoId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemAnim(height, entrance = true) {
|
||||||
|
const visible = { height: height + "px", opacity: 1 };
|
||||||
|
const hidden = { height: "0", opacity: 0, padding: "0" };
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyframes: [
|
||||||
|
entrance ? hidden : visible,
|
||||||
|
entrance ? visible : hidden
|
||||||
|
],
|
||||||
|
options: { duration: 200, easing: "ease" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputMarginAnim(entrance = true) {
|
||||||
|
const amount = "1.5rem";
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyframes: [
|
||||||
|
{ marginBottom: entrance ? "0px" : amount },
|
||||||
|
{ marginBottom: entrance ? amount : "0" }
|
||||||
|
],
|
||||||
|
options: { duration: 200, easing: "ease", fill: "forwards" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFromLocalStorage(id) {
|
||||||
|
return JSON.parse(localStorage.getItem(`todo-${id}`) || "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToLocalStorage(id, data) {
|
||||||
|
localStorage.setItem(`todo-${id}`, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function Item(unserialize = {}, onUpdate, onDelete, onEscape, onDragStart) {
|
||||||
|
let item, input, inputArea;
|
||||||
|
|
||||||
|
const serializeable = {
|
||||||
|
text: unserialize.text || "",
|
||||||
|
checked: unserialize.checked || false
|
||||||
|
};
|
||||||
|
|
||||||
|
item = elem().classes("todo-item", "flex", "gap-10", "items-center").append(
|
||||||
|
elem("input")
|
||||||
|
.classes("todo-item-checkbox", "shrink-0")
|
||||||
|
.styles({ marginTop: "-0.1rem" })
|
||||||
|
.attrs({ type: "checkbox" })
|
||||||
|
.on("change", (e) => {
|
||||||
|
serializeable.checked = e.target.checked;
|
||||||
|
onUpdate();
|
||||||
|
})
|
||||||
|
.tap(self => self.checked = serializeable.checked),
|
||||||
|
|
||||||
|
input = autoScalingTextarea(textarea => inputArea = textarea
|
||||||
|
.classes("todo-item-text")
|
||||||
|
.attrs({
|
||||||
|
placeholder: "empty task",
|
||||||
|
spellcheck: "false"
|
||||||
|
})
|
||||||
|
.on("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onEscape();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("input", () => {
|
||||||
|
serializeable.text = inputArea.value;
|
||||||
|
onUpdate();
|
||||||
|
})
|
||||||
|
).classes("min-width-0", "grow").append(
|
||||||
|
elem()
|
||||||
|
.classes("todo-item-drag-handle")
|
||||||
|
.on("mousedown", (e) => onDragStart(e, item))
|
||||||
|
),
|
||||||
|
|
||||||
|
elem("button")
|
||||||
|
.classes("todo-item-delete", "shrink-0")
|
||||||
|
.html(trashIconSvg)
|
||||||
|
.on("click", () => onDelete(item))
|
||||||
|
);
|
||||||
|
|
||||||
|
input.component.setValue(serializeable.text);
|
||||||
|
return item.component({
|
||||||
|
focusInput: () => inputArea.focus(),
|
||||||
|
serialize: () => serializeable
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function Todo(id) {
|
||||||
|
let items, input, inputArea, inputContainer, lastAddedItem;
|
||||||
|
let queuedForRemoval = 0;
|
||||||
|
let reorderable;
|
||||||
|
let isDragging = false;
|
||||||
|
|
||||||
|
const onDragEnd = () => isDragging = false;
|
||||||
|
const onDragStart = (event, element) => {
|
||||||
|
isDragging = true;
|
||||||
|
reorderable.component.onDragStart(event, element);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveItems = () => {
|
||||||
|
if (isDragging) return;
|
||||||
|
|
||||||
|
saveToLocalStorage(
|
||||||
|
id, items.children.map(item => item.component.serialize())
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onItemRepositioned = () => saveItems();
|
||||||
|
const debouncedOnItemUpdate = throttledDebounce(saveItems, 10, 1000);
|
||||||
|
|
||||||
|
const onItemDelete = (item) => {
|
||||||
|
if (lastAddedItem === item) lastAddedItem = null;
|
||||||
|
const height = item.clientHeight;
|
||||||
|
queuedForRemoval++;
|
||||||
|
item.animate(itemAnim(height, false), () => {
|
||||||
|
item.remove();
|
||||||
|
queuedForRemoval--;
|
||||||
|
saveItems();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.children.length - queuedForRemoval === 0)
|
||||||
|
inputContainer.animate(inputMarginAnim(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const newItem = (data) => Item(
|
||||||
|
data,
|
||||||
|
debouncedOnItemUpdate,
|
||||||
|
onItemDelete,
|
||||||
|
() => inputArea.focus(),
|
||||||
|
onDragStart
|
||||||
|
);
|
||||||
|
|
||||||
|
const addNewItem = (itemText, prepend) => {
|
||||||
|
const totalItemsBeforeAppending = items.children.length;
|
||||||
|
const item = lastAddedItem = newItem({ text: itemText });
|
||||||
|
|
||||||
|
prepend ? items.prepend(item) : items.append(item);
|
||||||
|
saveItems();
|
||||||
|
const height = item.clientHeight;
|
||||||
|
item.animate(itemAnim(height));
|
||||||
|
|
||||||
|
if (totalItemsBeforeAppending === 0)
|
||||||
|
inputContainer.animate(inputMarginAnim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputKeyDown = (e) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case "Enter":
|
||||||
|
e.preventDefault();
|
||||||
|
const value = e.target.value.trim();
|
||||||
|
if (value === "") return;
|
||||||
|
addNewItem(value, e.ctrlKey);
|
||||||
|
input.component.setValue("");
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
e.target.blur();
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
if (!lastAddedItem) return;
|
||||||
|
e.preventDefault();
|
||||||
|
lastAddedItem.component.focusInput();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
items = elem()
|
||||||
|
.classes("todo-items")
|
||||||
|
.append(
|
||||||
|
...loadFromLocalStorage(id).map(data => newItem(data))
|
||||||
|
);
|
||||||
|
|
||||||
|
return fragment().append(
|
||||||
|
inputContainer = elem()
|
||||||
|
.classes("todo-input", "flex", "gap-10", "items-center")
|
||||||
|
.classesIf(items.children.length > 0, "margin-bottom-15")
|
||||||
|
.styles({ paddingRight: "2.5rem" })
|
||||||
|
.append(
|
||||||
|
elem().classes("todo-plus-icon", "shrink-0"),
|
||||||
|
input = autoScalingTextarea(textarea => inputArea = textarea
|
||||||
|
.on("keydown", handleInputKeyDown)
|
||||||
|
.attrs({
|
||||||
|
placeholder: "Add a task",
|
||||||
|
spellcheck: "false"
|
||||||
|
})
|
||||||
|
).classes("grow", "min-width-0")
|
||||||
|
),
|
||||||
|
|
||||||
|
reorderable = verticallyReorderable(items, onItemRepositioned, onDragEnd),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// See https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/
|
||||||
|
export function autoScalingTextarea(yieldTextarea = null) {
|
||||||
|
let textarea, mimic;
|
||||||
|
|
||||||
|
const updateMimic = (newValue) => mimic.text(newValue + ' ');
|
||||||
|
const container = elem().classes("auto-scaling-textarea-container").append(
|
||||||
|
textarea = elem("textarea")
|
||||||
|
.classes("auto-scaling-textarea")
|
||||||
|
.on("input", () => updateMimic(textarea.value)),
|
||||||
|
mimic = elem().classes("auto-scaling-textarea-mimic")
|
||||||
|
)
|
||||||
|
|
||||||
|
if (typeof yieldTextarea === "function") yieldTextarea(textarea);
|
||||||
|
|
||||||
|
return container.component({ setValue: (newValue) => {
|
||||||
|
textarea.value = newValue;
|
||||||
|
updateMimic(newValue);
|
||||||
|
}});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verticallyReorderable(itemsContainer, onItemRepositioned, onDragEnd) {
|
||||||
|
const classToAddToDraggedItem = "is-being-dragged";
|
||||||
|
|
||||||
|
const currentlyBeingDragged = {
|
||||||
|
element: null,
|
||||||
|
initialIndex: null,
|
||||||
|
clientOffset: Vec2.new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const decoy = {
|
||||||
|
element: null,
|
||||||
|
currentIndex: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const draggableContainer = {
|
||||||
|
element: null,
|
||||||
|
initialRect: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastClientPos = Vec2.new();
|
||||||
|
let initialScrollY = null;
|
||||||
|
let addDocumentEvents, removeDocumentEvents;
|
||||||
|
|
||||||
|
const handleReposition = (event) => {
|
||||||
|
if (currentlyBeingDragged.element == null) return;
|
||||||
|
|
||||||
|
if (event.clientY !== undefined && event.clientX !== undefined)
|
||||||
|
lastClientPos.setFromEvent(event);
|
||||||
|
|
||||||
|
const client = lastClientPos;
|
||||||
|
const container = draggableContainer;
|
||||||
|
const item = currentlyBeingDragged;
|
||||||
|
|
||||||
|
const scrollOffset = window.scrollY - initialScrollY;
|
||||||
|
const offsetY = client.y - container.initialRect.y - item.clientOffset.y + scrollOffset;
|
||||||
|
const offsetX = client.x - container.initialRect.x - item.clientOffset.x;
|
||||||
|
|
||||||
|
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||||
|
const viewportWidth = window.innerWidth - scrollbarWidth;
|
||||||
|
|
||||||
|
const confinedX = clamp(
|
||||||
|
offsetX,
|
||||||
|
-container.initialRect.x,
|
||||||
|
viewportWidth - container.initialRect.x - container.initialRect.width
|
||||||
|
);
|
||||||
|
|
||||||
|
container.element.styles({
|
||||||
|
transform: `translate(${confinedX}px, ${offsetY}px)`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerTop = client.y - item.clientOffset.y;
|
||||||
|
const containerBottom = client.y + container.initialRect.height - item.clientOffset.y;
|
||||||
|
|
||||||
|
let swapWithLast = true;
|
||||||
|
let swapWithIndex = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < itemsContainer.children.length; i++) {
|
||||||
|
const childRect = itemsContainer.children[i].getBoundingClientRect();
|
||||||
|
const topThreshold = childRect.top + childRect.height * .6;
|
||||||
|
const bottomThreshold = childRect.top + childRect.height * .4;
|
||||||
|
|
||||||
|
if (containerBottom > topThreshold) {
|
||||||
|
if (containerTop < bottomThreshold && i != decoy.currentIndex) {
|
||||||
|
swapWithIndex = i;
|
||||||
|
swapWithLast = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
swapWithLast = false;
|
||||||
|
|
||||||
|
if (i == decoy.currentIndex || i-1 == decoy.currentIndex) break;
|
||||||
|
swapWithIndex = (i < decoy.currentIndex) ? i : i-1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastItemIndex = itemsContainer.children.length - 1;
|
||||||
|
|
||||||
|
if (swapWithLast && decoy.currentIndex != lastItemIndex)
|
||||||
|
swapWithIndex = lastItemIndex;
|
||||||
|
|
||||||
|
if (swapWithIndex === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const diff = swapWithIndex - decoy.currentIndex;
|
||||||
|
if (Math.abs(diff) > 1) {
|
||||||
|
swapWithIndex = decoy.currentIndex + Math.sign(diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblingToSwapWith = itemsContainer.children[swapWithIndex];
|
||||||
|
|
||||||
|
if (siblingToSwapWith.isCurrentlyAnimating) return;
|
||||||
|
|
||||||
|
const animateDecoy = animateReposition(decoy.element);
|
||||||
|
const animateChild = animateReposition(
|
||||||
|
siblingToSwapWith,
|
||||||
|
() => {
|
||||||
|
siblingToSwapWith.isCurrentlyAnimating = false;
|
||||||
|
handleReposition({
|
||||||
|
clientX: client.x,
|
||||||
|
clientY: client.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
siblingToSwapWith.isCurrentlyAnimating = true;
|
||||||
|
|
||||||
|
if (swapWithIndex > decoy.currentIndex)
|
||||||
|
decoy.element.before(siblingToSwapWith);
|
||||||
|
else
|
||||||
|
decoy.element.after(siblingToSwapWith);
|
||||||
|
|
||||||
|
decoy.currentIndex = itemsContainer.children.indexOf(decoy.element);
|
||||||
|
|
||||||
|
animateDecoy();
|
||||||
|
animateChild();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRelease = (event) => {
|
||||||
|
if (event.buttons != 0) return;
|
||||||
|
|
||||||
|
removeDocumentEvents();
|
||||||
|
const item = currentlyBeingDragged;
|
||||||
|
const element = item.element;
|
||||||
|
element.styles({ pointerEvents: "none" });
|
||||||
|
const animate = animateReposition(element, () => {
|
||||||
|
item.element = null;
|
||||||
|
element
|
||||||
|
.clearClasses(classToAddToDraggedItem)
|
||||||
|
.clearStyles("pointer-events");
|
||||||
|
|
||||||
|
if (typeof onDragEnd === "function") onDragEnd(element);
|
||||||
|
|
||||||
|
if (item.initialIndex != decoy.currentIndex && typeof onItemRepositioned === "function")
|
||||||
|
onItemRepositioned(element, item.initialIndex, decoy.currentIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
decoy.element.swapWith(element);
|
||||||
|
draggableContainer.element.append(decoy.element);
|
||||||
|
draggableContainer.element.clearStyles("transform", "width");
|
||||||
|
|
||||||
|
item.element = null;
|
||||||
|
decoy.element.remove();
|
||||||
|
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
const preventDefault = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGrab = (event, element) => {
|
||||||
|
if (currentlyBeingDragged.element != null) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const item = currentlyBeingDragged;
|
||||||
|
if (item.element != null) return;
|
||||||
|
|
||||||
|
addDocumentEvents();
|
||||||
|
initialScrollY = window.scrollY;
|
||||||
|
const client = lastClientPos.setFromEvent(event);
|
||||||
|
const elementRect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
item.element = element;
|
||||||
|
item.initialIndex = decoy.currentIndex = itemsContainer.children.indexOf(element);
|
||||||
|
item.clientOffset.set(client.x - elementRect.x, client.y - elementRect.y);
|
||||||
|
|
||||||
|
// We use getComputedStyle here to get width and height because .clientWidth and .clientHeight
|
||||||
|
// return integers and not the real float values, which can cause the decoy to be off by a pixel
|
||||||
|
const elementStyle = getComputedStyle(element);
|
||||||
|
const initialWidth = elementStyle.width;
|
||||||
|
|
||||||
|
decoy.element = elem().classes("drag-and-drop-decoy").styles({
|
||||||
|
height: elementStyle.height,
|
||||||
|
width: initialWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = draggableContainer;
|
||||||
|
|
||||||
|
element.swapWith(decoy.element);
|
||||||
|
container.element.append(element);
|
||||||
|
element.classes(classToAddToDraggedItem);
|
||||||
|
|
||||||
|
decoy.element.animate({
|
||||||
|
keyframes: [{ transform: "scale(.9)", opacity: 0, offset: 0 }],
|
||||||
|
options: { duration: 300, easing: "ease" }
|
||||||
|
})
|
||||||
|
|
||||||
|
container.element.styles({ width: initialWidth, transform: "none" });
|
||||||
|
container.initialRect = container.element.getBoundingClientRect();
|
||||||
|
|
||||||
|
const offsetY = elementRect.y - container.initialRect.y;
|
||||||
|
const offsetX = elementRect.x - container.initialRect.x;
|
||||||
|
|
||||||
|
container.element.styles({ transform: `translate(${offsetX}px, ${offsetY}px)` });
|
||||||
|
}
|
||||||
|
|
||||||
|
[addDocumentEvents, removeDocumentEvents] = toggleableEvents(document, {
|
||||||
|
"mousemove": handleReposition,
|
||||||
|
"scroll": handleReposition,
|
||||||
|
"mousedown": preventDefault,
|
||||||
|
"contextmenu": preventDefault,
|
||||||
|
"mouseup": handleRelease,
|
||||||
|
});
|
||||||
|
|
||||||
|
return elem().classes("drag-and-drop-container").append(
|
||||||
|
itemsContainer,
|
||||||
|
draggableContainer.element = elem().classes("drag-and-drop-draggable")
|
||||||
|
).component({
|
||||||
|
onDragStart: handleGrab
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -36,3 +36,46 @@ export function openURLInNewTab(url, focus = true) {
|
|||||||
|
|
||||||
if (focus && newWindow != null) newWindow.focus();
|
if (focus && newWindow != null) newWindow.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class Vec2 {
|
||||||
|
constructor(x, y) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
static new(x = 0, y = 0) {
|
||||||
|
return new Vec2(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromEvent(event) {
|
||||||
|
return new Vec2(event.clientX, event.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFromEvent(event) {
|
||||||
|
this.x = event.clientX;
|
||||||
|
this.y = event.clientY;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(x, y) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleableEvents(element, eventToHandlerMap) {
|
||||||
|
return [
|
||||||
|
() => {
|
||||||
|
for (const [event, handler] of Object.entries(eventToHandlerMap)) {
|
||||||
|
element.addEventListener(event, handler);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
for (const [event, handler] of Object.entries(eventToHandlerMap)) {
|
||||||
|
element.removeEventListener(event, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Glance",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#151519",
|
|
||||||
"scope": "/",
|
|
||||||
"start_url": "/",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "app-icon.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -21,15 +21,19 @@ var globalTemplateFunctions = template.FuncMap{
|
|||||||
"safeURL": func(str string) template.URL {
|
"safeURL": func(str string) template.URL {
|
||||||
return template.URL(str)
|
return template.URL(str)
|
||||||
},
|
},
|
||||||
|
"safeHTML": func(str string) template.HTML {
|
||||||
|
return template.HTML(str)
|
||||||
|
},
|
||||||
"absInt": func(i int) int {
|
"absInt": func(i int) int {
|
||||||
return int(math.Abs(float64(i)))
|
return int(math.Abs(float64(i)))
|
||||||
},
|
},
|
||||||
"formatPrice": func(price float64) string {
|
"formatPrice": func(price float64) string {
|
||||||
return intl.Sprintf("%.2f", price)
|
return intl.Sprintf("%.2f", price)
|
||||||
},
|
},
|
||||||
"dynamicRelativeTimeAttrs": func(t interface{ Unix() int64 }) template.HTMLAttr {
|
"formatPriceWithPrecision": func(precision int, price float64) string {
|
||||||
return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`)
|
return intl.Sprintf("%."+strconv.Itoa(precision)+"f", price)
|
||||||
},
|
},
|
||||||
|
"dynamicRelativeTimeAttrs": dynamicRelativeTimeAttrs,
|
||||||
"formatServerMegabytes": func(mb uint64) template.HTML {
|
"formatServerMegabytes": func(mb uint64) template.HTML {
|
||||||
var value string
|
var value string
|
||||||
var label string
|
var label string
|
||||||
@@ -81,3 +85,7 @@ func formatApproxNumber(count int) string {
|
|||||||
|
|
||||||
return strconv.FormatFloat(float64(count)/1_000_000, 'f', 1, 64) + "m"
|
return strconv.FormatFloat(float64(count)/1_000_000, 'f', 1, 64) + "m"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dynamicRelativeTimeAttrs(t interface{ Unix() int64 }) template.HTMLAttr {
|
||||||
|
return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{{- template "document.html" . }}
|
||||||
|
|
||||||
|
{{- define "document-title" }}Settings: {{ .Page.Title }} - {{ .App.Config.Branding.AppName }}{{ end }}
|
||||||
|
|
||||||
|
{{- define "document-body" }}
|
||||||
|
<div class="flex flex-column body-content">
|
||||||
|
<main class="content-bounds" style="padding-block: 2rem;">
|
||||||
|
<div class="margin-bottom-15">
|
||||||
|
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit">← Edit</a>
|
||||||
|
<span class="color-subdue"> / </span>
|
||||||
|
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit/pages/{{ .Page.Slug }}">{{ .Page.Title }}</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="size-h1 margin-bottom-25">Page settings</h1>
|
||||||
|
|
||||||
|
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/pages/{{ .Page.Slug }}/fields"
|
||||||
|
style="display:flex;flex-direction:column;gap:1rem;max-width:36rem;">
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Name <span style="color:var(--color-negative);">*</span>
|
||||||
|
<input type="text" name="name" class="input" required value="{{ .PageSettings.Name }}">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
URL slug
|
||||||
|
<input type="text" name="slug" class="input" value="{{ .PageSettings.Slug }}" placeholder="auto from name">
|
||||||
|
<small class="color-subdue size-h5">Leave blank to auto-generate from the name.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Page width
|
||||||
|
<select name="width" class="input">
|
||||||
|
<option value="" {{ if eq .PageSettings.Width "" }}selected{{ end }}>default</option>
|
||||||
|
<option value="slim" {{ if eq .PageSettings.Width "slim" }}selected{{ end }}>slim</option>
|
||||||
|
<option value="wide" {{ if eq .PageSettings.Width "wide" }}selected{{ end }}>wide</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Desktop nav width
|
||||||
|
<select name="desktop-navigation-width" class="input">
|
||||||
|
<option value="" {{ if eq .PageSettings.DesktopNavigationWidth "" }}selected{{ end }}>same as page width</option>
|
||||||
|
<option value="slim" {{ if eq .PageSettings.DesktopNavigationWidth "slim" }}selected{{ end }}>slim</option>
|
||||||
|
<option value="wide" {{ if eq .PageSettings.DesktopNavigationWidth "wide" }}selected{{ end }}>wide</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header" style="display:flex;flex-direction:row;align-items:center;gap:0.5rem;">
|
||||||
|
<input type="checkbox" name="show-mobile-header" {{ if .PageSettings.ShowMobileHeader }}checked{{ end }}>
|
||||||
|
<span>Show mobile header (page title bar)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header" style="display:flex;flex-direction:row;align-items:center;gap:0.5rem;">
|
||||||
|
<input type="checkbox" name="hide-desktop-navigation" {{ if .PageSettings.HideDesktopNavigation }}checked{{ end }}>
|
||||||
|
<span>Hide desktop nav (single-page mode)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header" style="display:flex;flex-direction:row;align-items:center;gap:0.5rem;">
|
||||||
|
<input type="checkbox" name="center-vertically" {{ if .PageSettings.CenterVertically }}checked{{ end }}>
|
||||||
|
<span>Center vertically</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex gap-10 margin-top-10">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<a class="color-subdue" style="align-self:center;" href="{{ .App.Config.Server.BaseURL }}/edit/pages/{{ .Page.Slug }}">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{{- template "document.html" . }}
|
||||||
|
|
||||||
|
{{- define "document-title" }}Edit {{ .Page.Title }} - {{ .App.Config.Branding.AppName }}{{ end }}
|
||||||
|
|
||||||
|
{{- define "document-body" }}
|
||||||
|
<div class="flex flex-column body-content">
|
||||||
|
<main class="content-bounds" style="padding-block: 2rem;">
|
||||||
|
<div class="margin-bottom-15">
|
||||||
|
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit">← Edit</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-10">
|
||||||
|
<h1 class="size-h1 grow">{{ .Page.Title }}</h1>
|
||||||
|
<a href="{{ .App.Config.Server.BaseURL }}/edit/pages/{{ .Page.Slug }}/settings">
|
||||||
|
<button type="button">Page settings</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="color-subdue margin-bottom-25">/{{ .Page.Slug }}</p>
|
||||||
|
|
||||||
|
{{- if .Page.HeadWidgets }}
|
||||||
|
<h2 class="size-h2 margin-bottom-10">Head widgets</h2>
|
||||||
|
<ul class="list list-gap-10 margin-bottom-25">
|
||||||
|
{{- range .Page.HeadWidgets }}
|
||||||
|
<li>
|
||||||
|
<a class="color-primary" href="{{ $.App.Config.Server.BaseURL }}/edit/pages/{{ $.Page.Slug }}/widgets/-1/{{ .WidgetIndex }}">
|
||||||
|
{{ if .Title }}{{ .Title }}{{ else }}<em>(untitled)</em>{{ end }}
|
||||||
|
</a>
|
||||||
|
<span class="color-subdue"> — {{ .Type }}</span>
|
||||||
|
</li>
|
||||||
|
{{- end }}
|
||||||
|
</ul>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
<h2 class="size-h2 margin-bottom-10">Columns</h2>
|
||||||
|
{{- if not .Page.Columns }}
|
||||||
|
<p class="color-subdue">No columns configured on this page. Edit the YAML manually to add columns — column editing comes in a later phase.</p>
|
||||||
|
{{- end }}
|
||||||
|
{{- range $colIdx, $col := .Page.Columns }}
|
||||||
|
<section class="margin-bottom-25">
|
||||||
|
<h3 class="size-h3 margin-bottom-10">
|
||||||
|
Column {{ $col.Index }}
|
||||||
|
<span class="color-subdue size-h5">({{ if $col.Size }}{{ $col.Size }}{{ else }}default{{ end }})</span>
|
||||||
|
</h3>
|
||||||
|
{{- if not $col.Widgets }}
|
||||||
|
<p class="color-subdue">No widgets in this column.</p>
|
||||||
|
{{- else }}
|
||||||
|
<ul class="list list-gap-10 margin-bottom-15">
|
||||||
|
{{- range $col.Widgets }}
|
||||||
|
<li class="flex items-center gap-10">
|
||||||
|
<div class="grow">
|
||||||
|
<a class="color-primary" href="{{ $.App.Config.Server.BaseURL }}/edit/pages/{{ $.Page.Slug }}/widgets/{{ .ColumnIndex }}/{{ .WidgetIndex }}">
|
||||||
|
{{ if .Title }}{{ .Title }}{{ else }}<em>(untitled)</em>{{ end }}
|
||||||
|
</a>
|
||||||
|
<span class="color-subdue"> — {{ .Type }}</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/pages/{{ $.Page.Slug }}/widgets/{{ .ColumnIndex }}/{{ .WidgetIndex }}/move?dir=up">
|
||||||
|
<button type="submit" {{ if .IsFirst }}disabled{{ end }}>↑</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/pages/{{ $.Page.Slug }}/widgets/{{ .ColumnIndex }}/{{ .WidgetIndex }}/move?dir=down">
|
||||||
|
<button type="submit" {{ if .IsLast }}disabled{{ end }}>↓</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/pages/{{ $.Page.Slug }}/widgets/{{ .ColumnIndex }}/{{ .WidgetIndex }}/delete"
|
||||||
|
onsubmit="return confirm('Delete this widget?');">
|
||||||
|
<button type="submit" class="color-negative">Delete</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{{- end }}
|
||||||
|
</ul>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
<form method="get" action="{{ $.App.Config.Server.BaseURL }}/edit/pages/{{ $.Page.Slug }}/widgets/{{ $col.Index }}/new" class="flex gap-10 items-center">
|
||||||
|
<select name="type" required>
|
||||||
|
<option value="">— add widget —</option>
|
||||||
|
{{- range $.WidgetTypes }}
|
||||||
|
<option value="{{ . }}">{{ . }}</option>
|
||||||
|
{{- end }}
|
||||||
|
</select>
|
||||||
|
<button type="submit">Configure & add</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{- end }}
|
||||||
|
</main>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{{- template "document.html" . }}
|
||||||
|
|
||||||
|
{{- define "document-title" }}Site settings - {{ .App.Config.Branding.AppName }}{{ end }}
|
||||||
|
|
||||||
|
{{- define "document-body" }}
|
||||||
|
<div class="flex flex-column body-content">
|
||||||
|
<main class="content-bounds" style="padding-block: 2rem;">
|
||||||
|
<div class="margin-bottom-15">
|
||||||
|
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit">← Edit</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="size-h1 margin-bottom-15">Site settings</h1>
|
||||||
|
<p class="color-subdue margin-bottom-25">
|
||||||
|
Branding shown in the header and footer. Theme colors, server settings,
|
||||||
|
and authentication still need YAML — edit <code>{{ .App.ConfigPath }}</code>
|
||||||
|
directly for those.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/site-settings"
|
||||||
|
style="display:flex;flex-direction:column;gap:1rem;max-width:36rem;">
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
App name
|
||||||
|
<input type="text" name="app-name" class="input" value="{{ .SiteSettings.AppName }}" placeholder="Glance">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Logo text (header)
|
||||||
|
<input type="text" name="logo-text" class="input" value="{{ .SiteSettings.LogoText }}">
|
||||||
|
<small class="color-subdue size-h5">Shown if no logo URL is set.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Logo URL
|
||||||
|
<input type="text" name="logo-url" class="input" value="{{ .SiteSettings.LogoURL }}" placeholder="/assets/logo.svg">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Favicon URL
|
||||||
|
<input type="text" name="favicon-url" class="input" value="{{ .SiteSettings.FaviconURL }}">
|
||||||
|
<small class="color-subdue size-h5">Defaults to the bundled glance favicon.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
App icon URL (PWA)
|
||||||
|
<input type="text" name="app-icon-url" class="input" value="{{ .SiteSettings.AppIconURL }}">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
App background color (PWA)
|
||||||
|
<input type="text" name="app-background-color" class="input" value="{{ .SiteSettings.AppBackgroundColor }}" placeholder="#000000">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header" style="display:flex;flex-direction:row;align-items:center;gap:0.5rem;">
|
||||||
|
<input type="checkbox" name="hide-footer" {{ if .SiteSettings.HideFooter }}checked{{ end }}>
|
||||||
|
<span>Hide footer</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Custom footer (HTML)
|
||||||
|
<textarea name="custom-footer" class="input" rows="4">{{ .SiteSettings.CustomFooter }}</textarea>
|
||||||
|
<small class="color-subdue size-h5">Replaces the default Glance footer if set.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex gap-10 margin-top-10">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<a class="color-subdue" style="align-self:center;" href="{{ .App.Config.Server.BaseURL }}/edit">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{{- template "document.html" . }}
|
||||||
|
|
||||||
|
{{- define "document-title" }}{{ if .PresetForm.IsNew }}Add preset{{ else }}Edit preset — {{ .PresetForm.Key }}{{ end }} - {{ .App.Config.Branding.AppName }}{{ end }}
|
||||||
|
|
||||||
|
{{- define "document-body" }}
|
||||||
|
<div class="flex flex-column body-content">
|
||||||
|
<main class="content-bounds" style="padding-block: 2rem;">
|
||||||
|
<div class="margin-bottom-15">
|
||||||
|
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit/theme-settings">← Theme settings</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="size-h1 margin-bottom-15">{{ if .PresetForm.IsNew }}Add preset{{ else }}Edit preset <code>{{ .PresetForm.Key }}</code>{{ end }}</h1>
|
||||||
|
|
||||||
|
{{ if .PresetForm.ErrorMessage }}
|
||||||
|
<p class="color-negative margin-bottom-15">{{ .PresetForm.ErrorMessage }}</p>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if .PresetForm.IsNew }}
|
||||||
|
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/theme-presets"
|
||||||
|
style="display:flex;flex-direction:column;gap:1rem;max-width:36rem;">
|
||||||
|
{{ else }}
|
||||||
|
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/theme-presets/{{ .PresetForm.Key }}"
|
||||||
|
style="display:flex;flex-direction:column;gap:1rem;max-width:36rem;">
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if .PresetForm.IsNew }}
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Preset name (key)
|
||||||
|
<input type="text" name="name" class="input" value="{{ .PresetForm.Key }}" placeholder="my-dark-theme" required
|
||||||
|
pattern="[^\s:{}[]|>&*]+" title="No spaces or special characters">
|
||||||
|
<small class="color-subdue size-h5">Used as the key in YAML and in the theme picker. Lowercase kebab-case recommended.</small>
|
||||||
|
</label>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<fieldset style="border:1px solid var(--color-widget-content-border);border-radius:4px;padding:1rem;">
|
||||||
|
<legend class="color-subdue size-h5">Colors</legend>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr auto;gap:0.75rem;align-items:center;">
|
||||||
|
<label for="bg">Background</label>
|
||||||
|
<input id="bg" type="color" name="background-color"
|
||||||
|
value="{{ if .PresetForm.BackgroundColorHex }}{{ .PresetForm.BackgroundColorHex }}{{ else }}#151823{{ end }}">
|
||||||
|
|
||||||
|
<label for="pc">Primary (accent)</label>
|
||||||
|
<input id="pc" type="color" name="primary-color"
|
||||||
|
value="{{ if .PresetForm.PrimaryColorHex }}{{ .PresetForm.PrimaryColorHex }}{{ else }}#e4cf8d{{ end }}">
|
||||||
|
|
||||||
|
<label for="ps">Positive</label>
|
||||||
|
<input id="ps" type="color" name="positive-color"
|
||||||
|
value="{{ if .PresetForm.PositiveColorHex }}{{ .PresetForm.PositiveColorHex }}{{ else }}#7fbf7f{{ end }}">
|
||||||
|
|
||||||
|
<label for="ng">Negative</label>
|
||||||
|
<input id="ng" type="color" name="negative-color"
|
||||||
|
value="{{ if .PresetForm.NegativeColorHex }}{{ .PresetForm.NegativeColorHex }}{{ else }}#e07f7f{{ end }}">
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset style="border:1px solid var(--color-widget-content-border);border-radius:4px;padding:1rem;">
|
||||||
|
<legend class="color-subdue size-h5">Tweaks</legend>
|
||||||
|
|
||||||
|
<label class="form-label widget-header" style="display:flex;flex-direction:row;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
|
||||||
|
<input type="checkbox" name="light" {{ if .PresetForm.Light }}checked{{ end }}>
|
||||||
|
<span>Light mode</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Contrast multiplier
|
||||||
|
<input type="number" step="0.05" min="0.5" max="2.0" name="contrast-multiplier" class="input"
|
||||||
|
value="{{ if .PresetForm.ContrastMultiplier }}{{ .PresetForm.ContrastMultiplier }}{{ end }}" placeholder="1.0">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header" style="margin-top:0.75rem;">
|
||||||
|
Text saturation multiplier
|
||||||
|
<input type="number" step="0.05" min="0" max="2.0" name="text-saturation-multiplier" class="input"
|
||||||
|
value="{{ if .PresetForm.TextSaturationMultiplier }}{{ .PresetForm.TextSaturationMultiplier }}{{ end }}" placeholder="1.0">
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex gap-10 margin-top-10">
|
||||||
|
<button type="submit">{{ if .PresetForm.IsNew }}Add preset{{ else }}Save{{ end }}</button>
|
||||||
|
<a class="color-subdue" style="align-self:center;" href="{{ .App.Config.Server.BaseURL }}/edit/theme-settings">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
{{- template "document.html" . }}
|
||||||
|
|
||||||
|
{{- define "document-title" }}Theme settings - {{ .App.Config.Branding.AppName }}{{ end }}
|
||||||
|
|
||||||
|
{{- define "document-body" }}
|
||||||
|
<div class="flex flex-column body-content">
|
||||||
|
<main class="content-bounds" style="padding-block: 2rem;">
|
||||||
|
<div class="margin-bottom-15">
|
||||||
|
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit">← Edit</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="size-h1 margin-bottom-15">Theme</h1>
|
||||||
|
|
||||||
|
<h2 class="size-h3 margin-bottom-10" style="margin-top:0;">Default theme</h2>
|
||||||
|
<p class="color-subdue margin-bottom-25">
|
||||||
|
Colors are stored as HSL in the YAML, but the picker below shows hex for convenience.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/theme-settings"
|
||||||
|
style="display:flex;flex-direction:column;gap:1rem;max-width:36rem;">
|
||||||
|
|
||||||
|
<fieldset style="border:1px solid var(--color-widget-content-border);border-radius:4px;padding:1rem;">
|
||||||
|
<legend class="color-subdue size-h5">Colors</legend>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr auto;gap:0.75rem;align-items:center;">
|
||||||
|
<label for="bg">Background</label>
|
||||||
|
<input id="bg" type="color" name="background-color" value="{{ if .ThemeSettings.BackgroundColorHex }}{{ .ThemeSettings.BackgroundColorHex }}{{ else }}#151823{{ end }}">
|
||||||
|
|
||||||
|
<label for="pc">Primary (accent)</label>
|
||||||
|
<input id="pc" type="color" name="primary-color" value="{{ if .ThemeSettings.PrimaryColorHex }}{{ .ThemeSettings.PrimaryColorHex }}{{ else }}#e4cf8d{{ end }}">
|
||||||
|
|
||||||
|
<label for="ps">Positive</label>
|
||||||
|
<input id="ps" type="color" name="positive-color" value="{{ if .ThemeSettings.PositiveColorHex }}{{ .ThemeSettings.PositiveColorHex }}{{ else }}#7fbf7f{{ end }}">
|
||||||
|
|
||||||
|
<label for="ng">Negative</label>
|
||||||
|
<input id="ng" type="color" name="negative-color" value="{{ if .ThemeSettings.NegativeColorHex }}{{ .ThemeSettings.NegativeColorHex }}{{ else }}#e07f7f{{ end }}">
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset style="border:1px solid var(--color-widget-content-border);border-radius:4px;padding:1rem;">
|
||||||
|
<legend class="color-subdue size-h5">Tweaks</legend>
|
||||||
|
|
||||||
|
<label class="form-label widget-header" style="display:flex;flex-direction:row;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
|
||||||
|
<input type="checkbox" name="light" {{ if .ThemeSettings.Light }}checked{{ end }}>
|
||||||
|
<span>Light mode (use light text/background scheme)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header" style="display:flex;flex-direction:row;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
|
||||||
|
<input type="checkbox" name="disable-picker" {{ if .ThemeSettings.DisablePicker }}checked{{ end }}>
|
||||||
|
<span>Hide the theme picker in the header</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Contrast multiplier
|
||||||
|
<input type="number" step="0.05" min="0.5" max="2.0" name="contrast-multiplier" class="input"
|
||||||
|
value="{{ if .ThemeSettings.ContrastMultiplier }}{{ .ThemeSettings.ContrastMultiplier }}{{ end }}" placeholder="1.0">
|
||||||
|
<small class="color-subdue size-h5">Higher = stronger contrast for body text. Default 1.0.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label widget-header" style="margin-top:0.75rem;">
|
||||||
|
Text saturation multiplier
|
||||||
|
<input type="number" step="0.05" min="0" max="2.0" name="text-saturation-multiplier" class="input"
|
||||||
|
value="{{ if .ThemeSettings.TextSaturationMultiplier }}{{ .ThemeSettings.TextSaturationMultiplier }}{{ end }}" placeholder="1.0">
|
||||||
|
<small class="color-subdue size-h5">0 = monochrome text. Default 1.0.</small>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label class="form-label widget-header">
|
||||||
|
Custom CSS file
|
||||||
|
<input type="text" name="custom-css-file" class="input" value="{{ .ThemeSettings.CustomCSSFile }}" placeholder="/assets/custom.css">
|
||||||
|
<small class="color-subdue size-h5">Path to a .css file under your assets-path, loaded after the bundled styles.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex gap-10 margin-top-10">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<a class="color-subdue" style="align-self:center;" href="{{ .App.Config.Server.BaseURL }}/edit">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{/* ── Presets ── */}}
|
||||||
|
<div style="margin-top:2.5rem;max-width:36rem;">
|
||||||
|
<div class="flex" style="justify-content:space-between;align-items:baseline;margin-bottom:0.75rem;">
|
||||||
|
<h2 class="size-h3" style="margin:0;">Presets</h2>
|
||||||
|
<a href="{{ .App.Config.Server.BaseURL }}/edit/theme-settings/presets/new"
|
||||||
|
style="font-size:0.85rem;">+ Add preset</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if .ThemeSettings.Presets }}
|
||||||
|
<div style="display:flex;flex-direction:column;gap:0.5rem;">
|
||||||
|
{{ range .ThemeSettings.Presets }}
|
||||||
|
<div style="display:flex;align-items:center;gap:0.75rem;padding:0.5rem 0.75rem;border:1px solid var(--color-widget-content-border);border-radius:4px;">
|
||||||
|
<div style="flex-shrink:0;pointer-events:none;">{{ .PreviewHTML }}</div>
|
||||||
|
<span style="flex:1;font-weight:500;">{{ .Key }}</span>
|
||||||
|
<a href="{{ $.App.Config.Server.BaseURL }}/edit/theme-settings/presets/{{ .Key }}"
|
||||||
|
style="font-size:0.85rem;">Edit</a>
|
||||||
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/theme-presets/{{ .Key }}/delete"
|
||||||
|
style="display:inline;" onsubmit="return confirm('Delete preset {{ .Key }}?')">
|
||||||
|
<button type="submit" class="color-negative"
|
||||||
|
style="background:none;border:none;cursor:pointer;font-size:0.85rem;padding:0;">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<p class="color-subdue size-h5">No custom presets yet. Add one below or import from the built-in catalog.</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ── Built-in catalog ── */}}
|
||||||
|
<div style="margin-top:2rem;max-width:36rem;">
|
||||||
|
<details>
|
||||||
|
<summary class="size-h3" style="cursor:pointer;user-select:none;margin-bottom:0.75rem;">
|
||||||
|
Built-in theme catalog
|
||||||
|
</summary>
|
||||||
|
<p class="color-subdue size-h5 margin-bottom-15">
|
||||||
|
Click a theme to open the preset form pre-filled with its colors. You can rename it before saving.
|
||||||
|
</p>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(11rem,1fr));gap:0.5rem;">
|
||||||
|
{{ range .ThemeSettings.CatalogThemes }}
|
||||||
|
{{- $cm := "" }}{{- if .CM }}{{- $cm = printf "%g" .CM }}{{ end }}
|
||||||
|
{{- $tsm := "" }}{{- if .TSM }}{{- $tsm = printf "%g" .TSM }}{{ end }}
|
||||||
|
{{- $light := "" }}{{- if .Light }}{{- $light = "true" }}{{ end }}
|
||||||
|
<a href="{{ $.App.Config.Server.BaseURL }}/edit/theme-settings/presets/new?name={{ .Key }}&bg={{ .BgHex }}&primary={{ .PrimaryHex }}&positive={{ .PositiveHex }}&negative={{ .NegativeHex }}&light={{ $light }}&cm={{ $cm }}&tsm={{ $tsm }}"
|
||||||
|
style="text-decoration:none;display:flex;flex-direction:column;align-items:center;gap:0.4rem;padding:0.6rem;border:1px solid var(--color-widget-content-border);border-radius:4px;transition:border-color 0.15s;"
|
||||||
|
onmouseover="this.style.borderColor='var(--color-primary)'" onmouseout="this.style.borderColor='var(--color-widget-content-border)'">
|
||||||
|
<div style="pointer-events:none;">{{ .PreviewHTML }}</div>
|
||||||
|
<span class="size-h5" style="text-align:center;color:var(--color-text-base);">{{ .Name }}</span>
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{{- template "document.html" . }}
|
||||||
|
|
||||||
|
{{- define "document-title" }}{{ if .IsNew }}Add {{ .Widget.Type }}{{ else }}Edit {{ if .Widget.Title }}{{ .Widget.Title }}{{ else }}{{ .Widget.Type }}{{ end }}{{ end }} - {{ .App.Config.Branding.AppName }}{{ end }}
|
||||||
|
|
||||||
|
{{- define "document-body" }}
|
||||||
|
<div class="flex flex-column body-content">
|
||||||
|
<main class="content-bounds" style="padding-block: 2rem;">
|
||||||
|
<div class="margin-bottom-15">
|
||||||
|
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit">← Edit</a>
|
||||||
|
<span class="color-subdue"> / </span>
|
||||||
|
<a class="color-subdue" href="{{ .App.Config.Server.BaseURL }}/edit/pages/{{ .Page.Slug }}">{{ .Page.Title }}</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="size-h1">
|
||||||
|
{{- if .IsNew -}}
|
||||||
|
Add widget <span class="color-subdue size-h4">— {{ .Widget.Type }}</span>
|
||||||
|
{{- else -}}
|
||||||
|
{{ if .Widget.Title }}{{ .Widget.Title }}{{ else }}<em>(untitled)</em>{{ end }}
|
||||||
|
<span class="color-subdue size-h4">— {{ .Widget.Type }}</span>
|
||||||
|
{{- end }}
|
||||||
|
</h1>
|
||||||
|
<p class="color-subdue margin-bottom-25">
|
||||||
|
{{- if .IsNew -}}
|
||||||
|
New widget for column {{ .Widget.ColumnIndex }}
|
||||||
|
{{- else if eq .Widget.ColumnIndex -1 -}}
|
||||||
|
head widget #{{ .Widget.WidgetIndex }}
|
||||||
|
{{- else -}}
|
||||||
|
column {{ .Widget.ColumnIndex }}, widget #{{ .Widget.WidgetIndex }}
|
||||||
|
{{- end }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{- if .ErrorMessage }}
|
||||||
|
<div class="margin-bottom-15" style="padding:1rem;background:#fee;border:1px solid #c00;border-radius:4px;color:#700;">
|
||||||
|
<strong>Couldn't save:</strong>
|
||||||
|
<pre style="white-space:pre-wrap;margin:0.5rem 0 0;">{{ .ErrorMessage }}</pre>
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- if .LoadError }}
|
||||||
|
<div class="margin-bottom-15" style="padding:1rem;background:#fee;border:1px solid #c00;border-radius:4px;">
|
||||||
|
<strong>Can't load YAML:</strong>
|
||||||
|
<pre style="white-space:pre-wrap;margin-top:0.5rem;">{{ .LoadError }}</pre>
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- if or .WidgetYAML .IsNew }}
|
||||||
|
<details class="margin-bottom-15" style="background:var(--color-widget-content-frame-background);border:1px solid var(--color-widget-content-border);border-radius:4px;padding:0.75rem;" {{ if .IsNew }}open{{ end }}>
|
||||||
|
<summary style="cursor:pointer;font-weight:bold;">How to edit a {{ if .Widget.Type }}<code>{{ .Widget.Type }}</code>{{ else }}widget{{ end }}</summary>
|
||||||
|
<div style="margin-top:0.75rem;line-height:1.6;">
|
||||||
|
{{- if .WidgetExample }}
|
||||||
|
<p style="margin-bottom:0.25rem;">A working example of a <code>{{ .Widget.Type }}</code> widget:</p>
|
||||||
|
<pre style="margin:0 0 0.75rem;padding:0.5rem;background:rgba(0,0,0,0.15);border-radius:3px;font-size:0.85rem;overflow:auto;">{{- .WidgetExample -}}</pre>
|
||||||
|
{{- end }}
|
||||||
|
{{- if .FieldReference }}
|
||||||
|
<p style="margin-bottom:0.25rem;">All fields supported by <code>{{ .Widget.Type }}</code> (defaults shown — fields with <code>yaml:"-"</code> tags or empty defaults still apply):</p>
|
||||||
|
<pre style="margin:0 0 0.75rem;padding:0.5rem;background:rgba(0,0,0,0.15);border-radius:3px;font-size:0.85rem;overflow:auto;max-height:18rem;">{{- .FieldReference -}}</pre>
|
||||||
|
{{- end }}
|
||||||
|
<p style="margin-bottom:0.25rem;">YAML quick tips:</p>
|
||||||
|
<ul style="padding-left:1.5rem;margin:0 0 0.75rem;">
|
||||||
|
<li>Indentation matters — use two spaces per level, never tabs.</li>
|
||||||
|
<li>Strings, numbers, booleans go inline: <code>limit: 10</code>, <code>hide-header: true</code>.</li>
|
||||||
|
<li>List items start with <code>-</code> and align under the parent key.</li>
|
||||||
|
<li>Pull secrets from the environment: <code>token: ${env:MY_TOKEN}</code>.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Validation runs before saving — if the YAML is invalid, your file isn't touched and the error appears above.</p>
|
||||||
|
{{- if .Widget.Type }}
|
||||||
|
<p>Full upstream reference: <a href="{{ .DocsURL }}" target="_blank" rel="noreferrer">{{ .Widget.Type }} docs ↗</a></p>
|
||||||
|
{{- end }}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<form method="post"
|
||||||
|
action="{{- if .IsNew -}}
|
||||||
|
{{ .App.Config.Server.BaseURL }}/edit/api/pages/{{ .Page.Slug }}/widgets/{{ .Widget.ColumnIndex }}/new
|
||||||
|
{{- else -}}
|
||||||
|
{{ .App.Config.Server.BaseURL }}/edit/api/pages/{{ .Page.Slug }}/widgets/{{ .Widget.ColumnIndex }}/{{ .Widget.WidgetIndex }}
|
||||||
|
{{- end }}">
|
||||||
|
<label for="widget-yaml" class="form-label widget-header margin-bottom-10">Widget YAML</label>
|
||||||
|
<textarea id="widget-yaml" name="yaml" rows="20" spellcheck="false"
|
||||||
|
style="width:100%;font-family:monospace;font-size:0.9rem;padding:0.75rem;background:var(--color-widget-content-frame-background);color:var(--color-text-base);border:1px solid var(--color-widget-content-border);border-radius:4px;">{{- .WidgetYAML -}}</textarea>
|
||||||
|
<div class="flex gap-10 margin-top-10">
|
||||||
|
<button type="submit">{{ if .IsNew }}Create{{ else }}Save{{ end }}</button>
|
||||||
|
<a href="{{ .App.Config.Server.BaseURL }}/edit/pages/{{ .Page.Slug }}" class="color-subdue" style="align-self:center;">Cancel</a>
|
||||||
|
</div>
|
||||||
|
{{- if not .IsNew }}
|
||||||
|
<p class="color-subdue size-h5" style="margin-top:0.5rem;">
|
||||||
|
You can change <code>type:</code> here to convert this widget to any type.
|
||||||
|
</p>
|
||||||
|
{{- end }}
|
||||||
|
</form>
|
||||||
|
{{- end }}
|
||||||
|
</main>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{{- template "document.html" . }}
|
||||||
|
|
||||||
|
{{- define "document-title" }}Edit - {{ .App.Config.Branding.AppName }}{{ end }}
|
||||||
|
|
||||||
|
{{- define "document-body" }}
|
||||||
|
<div class="flex flex-column body-content">
|
||||||
|
<main class="content-bounds" style="padding-block: 2rem;">
|
||||||
|
<div class="flex items-center gap-10">
|
||||||
|
<h1 class="size-h1 grow">Edit</h1>
|
||||||
|
<a href="{{ .App.Config.Server.BaseURL }}/edit/theme-settings">
|
||||||
|
<button type="button">Theme</button>
|
||||||
|
</a>
|
||||||
|
<a href="{{ .App.Config.Server.BaseURL }}/edit/site-settings">
|
||||||
|
<button type="button">Site settings</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="color-subdue margin-bottom-25">
|
||||||
|
Editing <code>{{ .App.ConfigPath }}</code>. Changes take effect via hot-reload.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="size-h2 margin-bottom-10">Pages</h2>
|
||||||
|
<ul class="list list-gap-10 margin-bottom-25">
|
||||||
|
{{- range .Pages }}
|
||||||
|
<li class="flex items-center gap-10">
|
||||||
|
<div class="grow">
|
||||||
|
<a class="color-primary" href="{{ $.App.Config.Server.BaseURL }}/edit/pages/{{ .Slug }}">
|
||||||
|
{{ .Title }}
|
||||||
|
</a>
|
||||||
|
<span class="color-subdue"> — {{ .WidgetCount }} widget{{ if ne .WidgetCount 1 }}s{{ end }}</span>
|
||||||
|
<div class="color-subdue size-h5">/{{ .Slug }}</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/pages/{{ .Slug }}/move?dir=up">
|
||||||
|
<button type="submit" {{ if .IsFirst }}disabled{{ end }} title="Move up">↑</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/pages/{{ .Slug }}/move?dir=down">
|
||||||
|
<button type="submit" {{ if .IsLast }}disabled{{ end }} title="Move down">↓</button>
|
||||||
|
</form>
|
||||||
|
<a href="{{ $.App.Config.Server.BaseURL }}/edit/pages/{{ .Slug }}/settings" title="Page settings">
|
||||||
|
<button type="button">Settings</button>
|
||||||
|
</a>
|
||||||
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/pages/{{ .Slug }}/delete"
|
||||||
|
onsubmit="return confirm('Delete page "{{ .Title }}" and all its widgets?');">
|
||||||
|
<button type="submit" class="color-negative">Delete</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{{- end }}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 class="size-h3 margin-bottom-10">Add a page</h3>
|
||||||
|
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/pages" class="flex gap-10 items-center">
|
||||||
|
<input type="text" name="title" required placeholder="Page title" class="input grow">
|
||||||
|
<button type="submit">Add page</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr style="margin-block: 2rem; border: none; border-top: 1px solid var(--color-widget-content-border);">
|
||||||
|
<h3 class="size-h3 margin-bottom-10">Saved versions</h3>
|
||||||
|
<p class="color-subdue margin-bottom-10">
|
||||||
|
Up to {{ len .Backups }} previous versions of <code>{{ .App.ConfigPath }}</code> are kept.
|
||||||
|
Restoring any one writes it as the current config and saves the just-current state as the new <em>most recent</em>, so you can always step back.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" action="{{ .App.Config.Server.BaseURL }}/edit/api/restore"
|
||||||
|
onsubmit="return confirm('Restore the most recent backup? You can click again to flip back.');" class="margin-bottom-15">
|
||||||
|
<button type="submit">Restore previous version (toggle)</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ul class="list list-gap-10">
|
||||||
|
{{- range .Backups }}
|
||||||
|
{{- if .Present }}
|
||||||
|
<li class="flex items-center gap-10">
|
||||||
|
<div class="grow">
|
||||||
|
<span class="color-text-base">Version {{ .N }}</span>
|
||||||
|
<span class="color-subdue size-h5"> — {{ .Time }} · {{ .SizeKB }} KB</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ $.App.Config.Server.BaseURL }}/edit/api/restore/{{ .N }}"
|
||||||
|
onsubmit="return confirm('Restore this version? Current state will be saved as the new most recent backup.');">
|
||||||
|
<button type="submit">Restore this</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@@ -2,22 +2,29 @@
|
|||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<div class="dynamic-columns list-gap-24 list-with-separator">
|
<div class="dynamic-columns list-gap-24 list-with-separator">
|
||||||
{{ range .Groups }}
|
{{- range .Groups }}
|
||||||
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.String | safeCSS }}"{{ end }}>
|
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.String | safeCSS }}"{{ end }}>
|
||||||
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
|
{{- if ne .Title "" }}
|
||||||
|
<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>
|
||||||
|
{{- end }}
|
||||||
<ul class="list list-gap-2">
|
<ul class="list list-gap-2">
|
||||||
{{ range .Links }}
|
{{- range .Links }}
|
||||||
<li class="flex items-center gap-10">
|
<li>
|
||||||
{{ if ne "" .Icon.URL }}
|
<div class="flex items-center gap-10">
|
||||||
|
{{- if ne "" .Icon.URL }}
|
||||||
<div class="bookmarks-icon-container">
|
<div class="bookmarks-icon-container">
|
||||||
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
<img class="bookmarks-icon{{ if .Icon.AutoInvert }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{- end }}
|
||||||
<a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||||
|
</div>
|
||||||
|
{{- if .Description }}
|
||||||
|
<div class="margin-bottom-5">{{ .Description }}</div>
|
||||||
|
{{- end }}
|
||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{- end }}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{- end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -59,8 +59,8 @@
|
|||||||
{{ if ne $column.Queries $column.Blocked }}
|
{{ if ne $column.Queries $column.Blocked }}
|
||||||
<div class="queries"></div>
|
<div class="queries"></div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if or (gt $column.Blocked 0) (and (lt $column.PercentTotal 15) (lt $column.PercentBlocked 10)) }}
|
{{ if gt $column.PercentBlocked 0 }}
|
||||||
<div class="blocked" style="flex-basis: {{ $column.PercentBlocked }}%"></div>
|
<div class="blocked" style="--percent: {{ $column.PercentBlocked }}%"></div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
<summary class="summary">Top blocked domains</summary>
|
<summary class="summary">Top blocked domains</summary>
|
||||||
<ul class="list list-gap-4 list-with-transition size-h5">
|
<ul class="list list-gap-4 list-with-transition size-h5">
|
||||||
{{ range .Stats.TopBlockedDomains }}
|
{{ range .Stats.TopBlockedDomains }}
|
||||||
<li class="flex justify-between">
|
<li class="flex justify-between gap-10">
|
||||||
<div class="text-truncate rtl">{{ .Domain }}</div>
|
<div class="text-truncate rtl">{{ .Domain }}</div>
|
||||||
<div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
|
<div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{- define "widget-content" }}
|
{{- define "widget-content" }}
|
||||||
<div class="dynamic-columns list-gap-20 list-with-separator">
|
<ul class="dynamic-columns list-gap-20 list-with-separator">
|
||||||
{{- range .Containers }}
|
{{- range .Containers }}
|
||||||
<div class="docker-container flex items-center gap-15">
|
<li class="docker-container flex items-center gap-15">
|
||||||
<div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem" data-popover-max-width="400px">
|
<div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem" data-popover-max-width="400px" aria-hidden="true">
|
||||||
<img class="docker-container-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
<img class="docker-container-icon{{ if .Icon.AutoInvert }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||||
<div data-popover-html>
|
<div data-popover-html>
|
||||||
<div class="color-highlight text-truncate block">{{ .Image }}</div>
|
<div class="color-highlight text-truncate block">{{ .Image }}</div>
|
||||||
<div>{{ .StateText }}</div>
|
<div>{{ .StateText }}</div>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
{{- range .Children }}
|
{{- range .Children }}
|
||||||
<li class="flex gap-7 items-center">
|
<li class="flex gap-7 items-center">
|
||||||
<div class="margin-bottom-3">{{ template "state-icon" .StateIcon }}</div>
|
<div class="margin-bottom-3">{{ template "state-icon" .StateIcon }}</div>
|
||||||
<div class="color-highlight">{{ .Title }} <span class="size-h5 color-base">{{ .StateText }}</span></div>
|
<div class="color-highlight">{{ .Name }} <span class="size-h5 color-base">{{ .StateText }}</span></div>
|
||||||
</li>
|
</li>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -22,42 +22,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-width-0">
|
<div class="min-width-0 grow">
|
||||||
{{- if .URL }}
|
{{- if .URL }}
|
||||||
<a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Name }}</a>
|
||||||
{{- else }}
|
{{- else }}
|
||||||
<div class="color-highlight text-truncate size-title-dynamic">{{ .Title }}</div>
|
<div class="color-highlight text-truncate size-title-dynamic">{{ .Name }}</div>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Description }}
|
{{- if .Description }}
|
||||||
<div class="text-truncate">{{ .Description }}</div>
|
<div class="text-truncate">{{ .Description }}</div>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="margin-left-auto shrink-0" data-popover-type="text" data-popover-position="above" data-popover-text="{{ .State }}">
|
<div class="margin-left-auto shrink-0" data-popover-type="text" data-popover-position="above" data-popover-text="{{ .State }}" aria-label="{{ .State }}">
|
||||||
{{ template "state-icon" .StateIcon }}
|
{{ template "state-icon" .StateIcon }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div class="visually-hidden" aria-label="{{ .StateText }}"></div>
|
||||||
|
</li>
|
||||||
{{- else }}
|
{{- else }}
|
||||||
<div class="text-center">No containers available to show.</div>
|
<div class="text-center">No containers available to show.</div>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
</div>
|
</ul>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{- define "state-icon" }}
|
{{- define "state-icon" }}
|
||||||
{{- if eq . "ok" }}
|
{{- if eq . "ok" }}
|
||||||
<svg class="docker-container-status-icon" fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
<svg class="docker-container-status-icon" fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
{{- else if eq . "warn" }}
|
{{- else if eq . "warn" }}
|
||||||
<svg class="docker-container-status-icon" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
<svg class="docker-container-status-icon" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
{{- else if eq . "paused" }}
|
{{- else if eq . "paused" }}
|
||||||
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
<path fill-rule="evenodd" d="M2 10a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm5-2.25A.75.75 0 0 1 7.75 7h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Zm4 0a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M2 10a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm5-2.25A.75.75 0 0 1 7.75 7h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Zm4 0a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
{{- else }}
|
{{- else }}
|
||||||
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.94 6.94a.75.75 0 1 1-1.061-1.061 3 3 0 1 1 2.871 5.026v.345a.75.75 0 0 1-1.5 0v-.5c0-.72.57-1.172 1.081-1.287A1.5 1.5 0 1 0 8.94 6.94ZM10 15a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.94 6.94a.75.75 0 1 1-1.061-1.061 3 3 0 1 1 2.871 5.026v.345a.75.75 0 0 1-1.5 0v-.5c0-.72.57-1.172 1.081-1.287A1.5 1.5 0 1 0 8.94 6.94ZM10 15a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html {{ block "document-root-attrs" . }}{{ end }} lang="en" id="top">
|
<html lang="en" id="top" data-theme="{{ .Request.Theme.Key }}" data-scheme="{{ if .Request.Theme.Light }}light{{ else }}dark{{ end }}">
|
||||||
<head>
|
<head>
|
||||||
{{ block "document-head-before" . }}{{ end }}
|
{{ block "document-head-before" . }}{{ end }}
|
||||||
|
<script>
|
||||||
|
if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');
|
||||||
|
const pageData = {
|
||||||
|
/*{{ if .Page }}*/slug: "{{ .Page.Slug }}",/*{{ end }}*/
|
||||||
|
baseURL: "{{ .App.Config.Server.BaseURL }}",
|
||||||
|
theme: "{{ .Request.Theme.Key }}",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
<title>{{ block "document-title" . }}{{ end }}</title>
|
<title>{{ block "document-title" . }}{{ end }}</title>
|
||||||
<script>if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');</script>
|
|
||||||
<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, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="Glance">
|
<meta name="apple-mobile-web-app-title" content="{{ .App.Config.Branding.AppName }}">
|
||||||
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
|
<meta name="theme-color" content="{{ .Request.Theme.BackgroundColorAsHex }}">
|
||||||
<link rel="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}">
|
<link rel="apple-touch-icon" sizes="512x512" href='{{ .App.Config.Branding.AppIconURL }}'>
|
||||||
<link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
|
<link rel="manifest" href='{{ .App.VersionedAssetPath "manifest.json" }}'>
|
||||||
<link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
|
<link rel="icon" type="{{ .App.Config.Branding.FaviconType }}" href="{{ .App.Config.Branding.FaviconURL }}" />
|
||||||
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
|
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/bundle.css" }}'>
|
||||||
<script type="module" src="{{ .App.AssetPath "js/main.js" }}"></script>
|
<style id="theme-style">{{ .Request.Theme.CSS }}</style>
|
||||||
|
{{ if .App.Config.Theme.CustomCSSFile }}<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.CreatedAt.Unix }}">{{ end }}
|
||||||
{{ block "document-head-after" . }}{{ end }}
|
{{ block "document-head-after" . }}{{ end }}
|
||||||
|
{{ if .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{ template "document-body" . }}
|
{{ template "document-body" . }}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{{ if not .App.Config.Branding.HideFooter }}
|
||||||
|
<footer class="footer flex items-center flex-column">
|
||||||
|
{{ if eq "" .App.Config.Branding.CustomFooter }}
|
||||||
|
<div>
|
||||||
|
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
{{ .App.Config.Branding.CustomFooter }}
|
||||||
|
{{ end }}
|
||||||
|
</footer>
|
||||||
|
{{ end }}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
<div class="grow min-width-0">
|
<div class="grow min-width-0">
|
||||||
<a href="{{ .DiscussionUrl }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
<a href="{{ .DiscussionUrl | safeURL }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||||
{{- if .Tags }}
|
{{- if .Tags }}
|
||||||
<div class="inline-block forum-post-tags-container">
|
<div class="inline-block forum-post-tags-container">
|
||||||
<ul class="attachments">
|
<ul class="attachments">
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<ul class="list-horizontal-text flex-nowrap text-compact">
|
<ul class="list-horizontal-text flex-nowrap text-compact">
|
||||||
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li class="shrink-0">{{ .Score | formatApproxNumber }} points</li>
|
<li class="shrink-0">{{ .Score | formatApproxNumber }} points</li>
|
||||||
<li class="shrink-0{{ if .TargetUrl }} forum-post-autohide{{ end }}">{{ .CommentCount | formatApproxNumber }} comments</li>
|
<li class="shrink-0{{ if .TargetUrl | safeURL }} forum-post-autohide{{ end }}">{{ .CommentCount | formatApproxNumber }} comments</li>
|
||||||
{{- if .TargetUrl }}
|
{{- if .TargetUrl }}
|
||||||
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -4,17 +4,18 @@
|
|||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<div class="widget-group-header">
|
<div class="widget-group-header">
|
||||||
<div class="widget-header gap-20">
|
<div class="widget-header gap-20" role="tablist">
|
||||||
{{ range $i, $widget := .Widgets }}
|
{{- range $i, $widget := .Widgets }}
|
||||||
<button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}"{{ if ne "" .TitleURL }} data-title-url="{{ .TitleURL }}"{{ end }}>{{ $widget.Title }}</button>
|
<button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}"{{ if ne "" .TitleURL }} data-title-url="{{ .TitleURL }}"{{ end }} aria-selected="{{ if eq $i 0 }}true{{ else }}false{{ end }}" arial-level="2" role="tab" aria-controls="widget-{{ .GetID }}-tabpanel-{{ $i }}" id="widget-{{ .GetID }}-tab-{{ $i }}">{{ $widget.Title }}</button>
|
||||||
{{ end }}
|
{{- end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="widget-group-contents">
|
<div class="widget-group-contents">
|
||||||
{{ range $i, $widget := .Widgets }}
|
{{- range $i, $widget := .Widgets }}
|
||||||
<div class="widget-group-content{{ if eq $i 0 }} widget-group-content-current{{ end }}">{{ .Render }}</div>
|
<div class="widget-group-content{{ if eq $i 0 }} widget-group-content-current{{ end }}" id="widget-{{ .GetID }}-tabpanel-{{ $i }}" role="tabpanel" aria-labelledby="widget-{{ .GetID }}-tab-{{ $i }}" aria-hidden="{{ if eq $i 0 }}false{{ else }}true{{ end }}">
|
||||||
{{ end }}
|
{{- .Render -}}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{{- template "document.html" . }}
|
||||||
|
|
||||||
|
{{- define "document-title" }}Login{{ end }}
|
||||||
|
|
||||||
|
{{- define "document-head-before" }}
|
||||||
|
<link rel="preload" href='{{ .App.StaticAssetPath "js/templating.js" }}' as="script"/>
|
||||||
|
<link rel="prefetch" href='{{ .App.StaticAssetPath "js/page.js" }}'/>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "document-head-after" }}
|
||||||
|
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/login.css" }}'>
|
||||||
|
<script type="module" src='{{ .App.StaticAssetPath "js/login.js" }}'></script>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- define "document-body" }}
|
||||||
|
<div class="flex flex-column body-content">
|
||||||
|
<div class="flex grow items-center justify-center" style="padding-bottom: 5rem">
|
||||||
|
<h1 class="visually-hidden">Login</h1>
|
||||||
|
<main id="login-container" class="grow login-bounds" style="display: none;">
|
||||||
|
<div class="animate-entrance">
|
||||||
|
<label class="form-label widget-header" for="username">Username</label>
|
||||||
|
<div class="form-input widget-content-frame padding-inline-widget flex gap-10 items-center">
|
||||||
|
<svg class="form-input-icon" fill="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path d="M10 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.465 14.493a1.23 1.23 0 0 0 .41 1.412A9.957 9.957 0 0 0 10 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 0 0-13.074.003Z" />
|
||||||
|
</svg>
|
||||||
|
<input type="text" id="username" class="input" placeholder="Enter your username" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="animate-entrance">
|
||||||
|
<label class="form-label widget-header margin-top-20" for="password">Password</label>
|
||||||
|
<div class="form-input widget-content-frame padding-inline-widget flex gap-10 items-center">
|
||||||
|
<svg class="form-input-icon" fill="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M8 7a5 5 0 1 1 3.61 4.804l-1.903 1.903A1 1 0 0 1 9 14H8v1a1 1 0 0 1-1 1H6v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-2a1 1 0 0 1 .293-.707L8.196 8.39A5.002 5.002 0 0 1 8 7Zm5-3a.75.75 0 0 0 0 1.5A1.5 1.5 0 0 1 14.5 7 .75.75 0 0 0 16 7a3 3 0 0 0-3-3Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<input type="password" id="password" class="input" placeholder="********" autocomplete="off">
|
||||||
|
<button class="toggle-password-visibility" id="toggle-password-visibility" tabindex="-1"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-error-message" id="error-message"></div>
|
||||||
|
|
||||||
|
<button class="login-button animate-entrance" id="login-button">
|
||||||
|
<div>LOGIN</div>
|
||||||
|
<svg stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{{ template "footer.html" . }}
|
||||||
|
</div>
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "{{ .App.Config.Branding.AppName }}",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "{{ .App.Config.Branding.AppBackgroundColor }}",
|
||||||
|
"theme_color": "{{ .App.Config.Branding.AppBackgroundColor }}",
|
||||||
|
"scope": "/",
|
||||||
|
"start_url": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "{{ .App.Config.Branding.AppIconURL }}",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -11,13 +11,13 @@
|
|||||||
|
|
||||||
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
|
<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">
|
<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>
|
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-linejoin="round" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="market-values shrink-0">
|
<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="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 class="text-right">{{ .Currency }}{{ .Price | formatPriceWithPrecision .PriceHint }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -22,9 +22,9 @@
|
|||||||
|
|
||||||
{{ define "site" }}
|
{{ define "site" }}
|
||||||
{{ if .Icon.URL }}
|
{{ if .Icon.URL }}
|
||||||
<img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
<img class="monitor-site-icon{{ if .Icon.AutoInvert }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div class="min-width-0">
|
<div class="grow min-width-0">
|
||||||
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
{{ if not .Status.Error }}
|
{{ if not .Status.Error }}
|
||||||
|
|||||||
@@ -2,12 +2,20 @@
|
|||||||
<div class="mobile-reachability-header">{{ .Page.Title }}</div>
|
<div class="mobile-reachability-header">{{ .Page.Title }}</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if .Page.HeadWidgets }}
|
||||||
|
<div class="head-widgets">
|
||||||
|
{{- range .Page.HeadWidgets }}
|
||||||
|
{{- .Render }}
|
||||||
|
{{- end }}
|
||||||
|
</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 }}">
|
||||||
{{ range .Widgets }}
|
{{- range .Widgets }}
|
||||||
{{ .Render }}
|
{{- .Render }}
|
||||||
{{ end }}
|
{{- end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{- end }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,43 +2,70 @@
|
|||||||
|
|
||||||
{{ define "document-title" }}{{ .Page.Title }}{{ end }}
|
{{ define "document-title" }}{{ .Page.Title }}{{ end }}
|
||||||
|
|
||||||
{{ define "document-head-before" }}
|
|
||||||
<script>
|
|
||||||
const pageData = {
|
|
||||||
slug: "{{ .Page.Slug }}",
|
|
||||||
baseURL: "{{ .App.Config.Server.BaseURL }}",
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }} {{ end }}{{ if .Page.CenterVertically }}page-center-vertically{{ end }}"{{ end }}
|
|
||||||
|
|
||||||
{{ define "document-head-after" }}
|
{{ define "document-head-after" }}
|
||||||
{{ .App.ParsedThemeStyle }}
|
<script type="module" src='{{ .App.StaticAssetPath "js/page.js" }}'></script>
|
||||||
|
{{ if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }}
|
||||||
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
|
<script src='{{ .App.StaticAssetPath "js/vendor/Sortable.min.js" }}'></script>
|
||||||
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
|
<script src='{{ .App.StaticAssetPath "js/edit-mode.js" }}'></script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "navigation-links" }}
|
{{ define "navigation-links" }}
|
||||||
{{ range .App.Config.Pages }}
|
{{ range .App.Config.Pages }}
|
||||||
<a href="{{ $.App.Config.Server.BaseURL }}/{{ .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 }}"{{ if eq .Slug $.Page.Slug }} aria-current="page"{{ end }}>{{ .Title }}</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "document-body" }}
|
{{ define "document-body" }}
|
||||||
<div class="flex flex-column body-content">
|
<div class="flex flex-column body-content">
|
||||||
{{ if not .Page.HideDesktopNavigation }}
|
{{ if not .Page.HideDesktopNavigation }}
|
||||||
<div class="header-container content-bounds">
|
<div class="header-container content-bounds{{ if .Page.DesktopNavigationWidth }} content-bounds-{{ .Page.DesktopNavigationWidth }} {{ end }}">
|
||||||
<div class="header flex padding-inline-widget widget-content-frame">
|
<div class="header flex padding-inline-widget widget-content-frame">
|
||||||
<!-- TODO: Replace G with actual logo, first need an actual logo -->
|
<div class="logo" aria-hidden="true">
|
||||||
<div class="logo">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
|
{{- if .App.Config.Branding.LogoURL }}
|
||||||
<div class="nav flex grow">
|
<img src="{{ .App.Config.Branding.LogoURL }}" alt="">
|
||||||
{{ template "navigation-links" . }}
|
{{- else if .App.Config.Branding.LogoText }}
|
||||||
|
{{- .App.Config.Branding.LogoText }}
|
||||||
|
{{- else }}
|
||||||
|
<svg style="max-height: 2rem;" width="100%" viewBox="0 0 108 108" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect fill="var(--color-text-subdue)" width="50" height="108" rx="6.875" />
|
||||||
|
<path fill="var(--color-primary)" fill-rule="evenodd" clip-rule="evenodd" d="M64.875 0C61.078 0 58 3.07804 58 6.875V43.125C58 46.922 61.078 50 64.875 50H101.125C104.922 50 108 46.922 108 43.125V6.875C108 3.07804 104.922 0 101.125 0H64.875ZM75.7545 11L71.3078 15.6814H85.2233C85.9209 15.6814 86.5835 15.6633 87.2113 15.627C87.839 15.5544 88.3273 15.4093 88.6761 15.1915L70 34.5706L73.4004 38L91.8149 18.7843C91.6056 19.1835 91.4487 19.7097 91.3441 20.3629C91.2743 20.9798 91.2394 21.5968 91.2394 22.2137V37.1835L96 32.2843V11H75.7545Z"/>
|
||||||
|
<rect fill="var(--color-text-base)" x="58" y="58" width="50" height="50" rx="6.875" />
|
||||||
|
</svg>
|
||||||
|
{{- end }}
|
||||||
</div>
|
</div>
|
||||||
|
<nav class="nav flex grow hide-scrollbars">
|
||||||
|
{{ template "navigation-links" . }}
|
||||||
|
</nav>
|
||||||
|
{{ if not .App.Config.Theme.DisablePicker }}
|
||||||
|
<div class="theme-picker self-center" data-popover-type="html" data-popover-position="below" data-popover-show-delay="0">
|
||||||
|
<div class="current-theme-preview">
|
||||||
|
{{ .Request.Theme.PreviewHTML }}
|
||||||
|
</div>
|
||||||
|
<div data-popover-html>
|
||||||
|
<div class="theme-choices"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{- if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }}
|
||||||
|
<button id="edit-mode-toggle" class="block self-center" title="Toggle edit mode" aria-label="Toggle edit mode">
|
||||||
|
<svg class="logout-button" stroke="currentColor" 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="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<a class="block self-center" href="{{ .App.Config.Server.BaseURL }}/edit" title="Open advanced editor">
|
||||||
|
<svg class="logout-button" 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="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{{- end }}
|
||||||
|
{{- if .App.RequiresAuth }}
|
||||||
|
<a class="block self-center" href="{{ .App.Config.Server.BaseURL }}/logout" title="Logout">
|
||||||
|
<svg class="logout-button" 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="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{{- end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -49,35 +76,76 @@
|
|||||||
{{ range $i, $column := .Page.Columns }}
|
{{ 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 $i $.Page.PrimaryColumnIndex }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
|
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq $i $.Page.PrimaryColumnIndex }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"{{ if .Page.ExpandMobilePageNavigation }} checked{{ end }}><div class="hamburger-icon"></div></label>
|
<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">
|
|
||||||
|
<div class="mobile-navigation-page-links hide-scrollbars">
|
||||||
{{ template "navigation-links" . }}
|
{{ template "navigation-links" . }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-navigation-actions flex flex-column margin-block-10">
|
||||||
|
{{ if not .App.Config.Theme.DisablePicker }}
|
||||||
|
<div class="theme-picker flex justify-between items-center" data-popover-type="html" data-popover-position="above" data-popover-show-delay="0" data-popover-hide-delay="100" data-popover-anchor=".current-theme-preview" data-popover-trigger="click">
|
||||||
|
<div data-popover-html>
|
||||||
|
<div class="theme-choices">
|
||||||
|
{{ .App.Config.Theme.PreviewHTML }}
|
||||||
|
{{ range $_, $preset := .App.Config.Theme.Presets.Items }}
|
||||||
|
{{ $preset.PreviewHTML }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-bounds grow">
|
<div class="size-h3 pointer-events-none select-none">Change theme</div>
|
||||||
<div class="page" id="page">
|
|
||||||
|
<div class="flex gap-15 items-center pointer-events-none">
|
||||||
|
<div class="current-theme-preview">
|
||||||
|
{{ .Request.Theme.PreviewHTML }}
|
||||||
|
</div>
|
||||||
|
<svg class="ui-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="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }}
|
||||||
|
<button id="edit-mode-toggle" class="flex justify-between items-center" style="background:none;border:none;padding:0;width:100%;cursor:pointer;color:inherit;font:inherit;">
|
||||||
|
<div class="size-h3">Edit mode</div>
|
||||||
|
<svg class="ui-icon" stroke="currentColor" 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="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<a href="{{ .App.Config.Server.BaseURL }}/edit" class="flex justify-between items-center">
|
||||||
|
<div class="size-h3">Advanced editor</div>
|
||||||
|
<svg class="ui-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="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if .App.RequiresAuth }}
|
||||||
|
<a href="{{ .App.Config.Server.BaseURL }}/logout" class="flex justify-between items-center">
|
||||||
|
<div class="size-h3">Logout</div>
|
||||||
|
<svg class="ui-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="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-bounds grow{{ if .Page.Width }} content-bounds-{{ .Page.Width }}{{ end }}">
|
||||||
|
<main class="page{{ if .Page.CenterVertically }} center-vertically{{ end }}" id="page" aria-live="polite" aria-busy="true">
|
||||||
|
<h1 class="visually-hidden">{{ .Page.Title }}</h1>
|
||||||
<div class="page-content" id="page-content"></div>
|
<div class="page-content" id="page-content"></div>
|
||||||
<div class="page-loading-container">
|
<div class="page-loading-container">
|
||||||
<!-- TODO: add a bigger/better loading indicator -->
|
<div class="visually-hidden">Loading</div>
|
||||||
<div class="loading-icon"></div>
|
<div class="loading-icon" aria-hidden="true"></div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ if not .App.Config.Branding.HideFooter }}
|
{{ template "footer.html" . }}
|
||||||
<div class="footer flex items-center flex-column">
|
|
||||||
{{ if eq "" .App.Config.Branding.CustomFooter }}
|
|
||||||
<div>
|
|
||||||
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
|
||||||
</div>
|
|
||||||
{{ else }}
|
|
||||||
{{ .App.Config.Branding.CustomFooter }}
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
<div class="mobile-navigation-offset"></div>
|
<div class="mobile-navigation-offset"></div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
{{ if gt (len .Repository.Commits) 0 }}
|
{{ if gt (len .Repository.Commits) 0 }}
|
||||||
<hr class="margin-block-8">
|
<hr class="margin-block-8">
|
||||||
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
|
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
|
||||||
<div class="flex gap-7 size-h5 margin-top-3">
|
<div class="flex gap-7 size-h5 size-base-on-mobile margin-top-3">
|
||||||
<ul class="list list-gap-2">
|
<ul class="list list-gap-2">
|
||||||
{{ range .Repository.Commits }}
|
{{ range .Repository.Commits }}
|
||||||
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
{{ if gt (len .Repository.PullRequests) 0 }}
|
{{ if gt (len .Repository.PullRequests) 0 }}
|
||||||
<hr class="margin-block-8">
|
<hr class="margin-block-8">
|
||||||
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .Repository.OpenPullRequests | formatNumber }} total)</a>
|
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .Repository.OpenPullRequests | formatNumber }} total)</a>
|
||||||
<div class="flex gap-7 size-h5 margin-top-3">
|
<div class="flex gap-7 size-h5 size-base-on-mobile margin-top-3">
|
||||||
<ul class="list list-gap-2">
|
<ul class="list list-gap-2">
|
||||||
{{ range .Repository.PullRequests }}
|
{{ range .Repository.PullRequests }}
|
||||||
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
{{ if gt (len .Repository.Issues) 0 }}
|
{{ if gt (len .Repository.Issues) 0 }}
|
||||||
<hr class="margin-block-10">
|
<hr class="margin-block-10">
|
||||||
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .Repository.OpenIssues | formatNumber }} total)</a>
|
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .Repository.OpenIssues | formatNumber }} total)</a>
|
||||||
<div class="flex gap-7 size-h5 margin-top-3">
|
<div class="flex gap-7 size-h5 size-base-on-mobile margin-top-3">
|
||||||
<ul class="list list-gap-2">
|
<ul class="list list-gap-2">
|
||||||
{{ range .Repository.Issues }}
|
{{ range .Repository.Issues }}
|
||||||
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ 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 widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}" data-new-tab="{{ .NewTab }}" data-target="{{ .Target }}">
|
||||||
<div class="search-bangs">
|
<div class="search-bangs">
|
||||||
{{ range .Bangs }}
|
{{ range .Bangs }}
|
||||||
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
|
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{{- $background := "hsl(240, 8%, 9%)" | safeCSS }}
|
||||||
|
{{- $primary := "hsl(43, 50%, 70%)" | safeCSS }}
|
||||||
|
{{- $positive := "hsl(43, 50%, 70%)" | safeCSS }}
|
||||||
|
{{- $negative := "hsl(0, 70%, 70%)" | safeCSS }}
|
||||||
|
{{- if .BackgroundColor }}{{ $background = .BackgroundColor.String | safeCSS }}{{ end }}
|
||||||
|
{{- if .PrimaryColor }}
|
||||||
|
{{- $primary = .PrimaryColor.String | safeCSS }}
|
||||||
|
{{- if not .PositiveColor }}
|
||||||
|
{{- $positive = $primary }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $positive = .PositiveColor.String | safeCSS }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .NegativeColor }}{{ $negative = .NegativeColor.String | safeCSS }}{{ end }}
|
||||||
|
<button class="theme-preset{{ if .Light }} theme-preset-light{{ end }}" style="--color: {{ $background }}" data-key="{{ .Key }}">
|
||||||
|
<div class="theme-color" style="--color: {{ $primary }}"></div>
|
||||||
|
<div class="theme-color" style="--color: {{ $positive }}"></div>
|
||||||
|
<div class="theme-color" style="--color: {{ $negative }}"></div>
|
||||||
|
</button>
|
||||||