Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
947a42ddd4 | ||
|
|
8e4f1380ce | ||
|
|
2351d909ca | ||
|
|
7a0a4b9780 | ||
|
|
e108feb74b | ||
|
|
7f36f30dad | ||
|
|
8ba6dcb704 | ||
|
|
4b8a34a2ae | ||
|
|
2e198a19b4 |
@@ -3,3 +3,5 @@
|
|||||||
/playground
|
/playground
|
||||||
/.idea
|
/.idea
|
||||||
/glance*.yml
|
/glance*.yml
|
||||||
|
*.yml.bak
|
||||||
|
*.yml.bak.*
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ 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 .
|
||||||
|
|
||||||
|
|||||||
@@ -1,446 +1,123 @@
|
|||||||
<p align="center"><img src="docs/logo.png"></p>
|
<p align="center"><img src="docs/logo.png"></p>
|
||||||
<h1 align="center">Glance</h1>
|
<h1 align="center">modern-glance</h1>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#installation">Install</a> •
|
<a href="#installation">Install</a> •
|
||||||
|
<a href="#web-based-editor">Editor</a> •
|
||||||
<a href="docs/configuration.md#configuring-glance">Configuration</a> •
|
<a href="docs/configuration.md#configuring-glance">Configuration</a> •
|
||||||
<a href="https://discord.com/invite/7KQ7Xa9kJd">Discord</a> •
|
|
||||||
<a href="https://github.com/sponsors/glanceapp">Sponsor</a>
|
|
||||||
</p>
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://github.com/glanceapp/community-widgets">Community widgets</a> •
|
|
||||||
<a href="docs/preconfigured-pages.md">Preconfigured pages</a> •
|
|
||||||
<a href="docs/themes.md">Themes</a>
|
<a href="docs/themes.md">Themes</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">A lightweight, highly customizable dashboard that displays<br> your feeds in a beautiful, streamlined interface</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>
|
||||||
|
|
||||||

|
> **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).
|
||||||
|
|
||||||
## Features
|
> **⚠️ 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.
|
||||||
### 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#configuring-glance)
|
|
||||||
|
|
||||||
### Fast and lightweight
|
---
|
||||||
* 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
|
## What's added
|
||||||
* 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
|
### In-place edit mode
|
||||||
Because you'll want to take it with you on the go.
|
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)
|
||||||
|
|
||||||

|
### Page management (`/edit`)
|
||||||
|
- Add, delete, and reorder pages
|
||||||
|
- Per-page settings: name, slug, width, navigation options, mobile header
|
||||||
|
|
||||||
### Themeable
|
### Site settings (`/edit/site-settings`)
|
||||||
Easily create your own theme by tweaking a few numbers or choose from one of the [already available themes](docs/themes.md).
|
- App name, logo, favicon, footer — all editable without touching YAML
|
||||||
|
|
||||||

|
### 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
|
||||||
|
|
||||||
<br>
|
### Multi-step undo
|
||||||
|
`/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.
|
||||||
|
|
||||||
## Configuration
|
### Auth gate
|
||||||
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#configuring-glance).
|
`/edit` returns 403 unless `auth.users` is configured **or** `admin.allow-without-auth: true` is set. See `docs/glance.yml` for examples of both.
|
||||||
<details>
|
|
||||||
<summary><strong>Preview example configuration file</strong></summary>
|
|
||||||
<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
|
|
||||||
restart: unless-stopped
|
|
||||||
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
|
||||||
|
mkdir -p config assets
|
||||||
|
curl -o config/glance.yml https://raw.githubusercontent.com/uhlwoogi/modern-glance/main/docs/glance.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
mkdir config && wget -O config/glance.yml https://raw.githubusercontent.com/glanceapp/glance/refs/heads/main/docs/glance.yml
|
docker compose pull && docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Feel free to edit the `glance.yml` file to your liking, and when ready run:
|
---
|
||||||
|
|
||||||
```bash
|
## Original Glance features
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
If you encounter any issues, you can check the logs by running:
|
Everything from upstream is intact:
|
||||||
|
|
||||||
```bash
|
- **Widgets**: RSS, Reddit, Hacker News, weather, YouTube, Twitch, markets, Docker containers, server stats, bookmarks, calendar, monitors, and [many more](docs/configuration.md#configuring-glance)
|
||||||
docker logs 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)
|
||||||
|
|
||||||
<hr>
|
Full configuration reference: [docs/configuration.md](docs/configuration.md#configuring-glance)
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
---
|
||||||
<summary><strong>Manual binary installation</strong></summary>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
Precompiled binaries are available for Linux, Windows and macOS (x86, x86_64, ARM and ARM64 architectures).
|
|
||||||
|
|
||||||
### Linux
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```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>
|
|
||||||
|
|
||||||
## Common issues
|
|
||||||
<details>
|
|
||||||
<summary><strong>Requests timing out</strong></summary>
|
|
||||||
|
|
||||||
The most common cause of this is when using Pi-Hole, AdGuard Home or other ad-blocking DNS services, which by default have a fairly low rate limit. Depending on the number of widgets you have in a single page, this limit can very easily be exceeded. To fix this, increase the rate limit in the settings of your DNS service.
|
|
||||||
|
|
||||||
If using Podman, in some rare cases the timeout can be caused by an unknown issue, in which case it may be resolved by adding the following to the bottom of your `docker-compose.yml` file:
|
|
||||||
```yaml
|
|
||||||
networks:
|
|
||||||
podman:
|
|
||||||
external: true
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Broken layout for markets, bookmarks or other widgets</strong></summary>
|
|
||||||
|
|
||||||
This is almost always caused by the browser extension Dark Reader. To fix this, disable dark mode for the domain where Glance is hosted.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>cannot unmarshal !!map into []glance.page</strong></summary>
|
|
||||||
|
|
||||||
The most common cause of this is having a `pages` key in your `glance.yml` and then also having a `pages` key inside one of your included pages. To fix this, remove the `pages` key from the top of your included pages.
|
|
||||||
|
|
||||||
</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>
|
|
||||||
|
|
||||||
## 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>
|
|
||||||
|
|
||||||
## 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
|
||||||
+52
-78
@@ -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
|
|
||||||
- giantwaffle
|
|
||||||
- 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,50 +63,17 @@ pages:
|
|||||||
subreddit: selfhosted
|
subreddit: selfhosted
|
||||||
show-thumbnails: true
|
show-thumbnails: true
|
||||||
|
|
||||||
- size: small
|
# Add more pages here:
|
||||||
widgets:
|
# - name: Your page name
|
||||||
- type: weather
|
# columns:
|
||||||
location: London, United Kingdom
|
# - size: small
|
||||||
units: metric # alternatively "imperial"
|
# widgets:
|
||||||
hour-format: 12h # alternatively "24h"
|
# # Add widgets here
|
||||||
# Optionally hide the location from being displayed in the widget
|
|
||||||
# hide-location: true
|
|
||||||
|
|
||||||
- type: markets
|
# - size: full
|
||||||
markets:
|
# widgets:
|
||||||
- symbol: SPY
|
# # Add widgets here
|
||||||
name: S&P 500
|
|
||||||
- symbol: BTC-USD
|
|
||||||
name: Bitcoin
|
|
||||||
- symbol: NVDA
|
|
||||||
name: NVIDIA
|
|
||||||
- symbol: AAPL
|
|
||||||
name: Apple
|
|
||||||
- symbol: MSFT
|
|
||||||
name: Microsoft
|
|
||||||
|
|
||||||
- type: releases
|
# - size: small
|
||||||
cache: 1d
|
# widgets:
|
||||||
# Without authentication the Github API allows for up to 60 requests per hour. You can create a
|
# # Add widgets here
|
||||||
# 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:
|
|
||||||
# - name: Your page name
|
|
||||||
# columns:
|
|
||||||
# - size: small
|
|
||||||
# widgets:
|
|
||||||
# # Add widgets here
|
|
||||||
|
|
||||||
# - size: full
|
|
||||||
# widgets:
|
|
||||||
# # Add widgets here
|
|
||||||
|
|
||||||
# - size: small
|
|
||||||
# widgets:
|
|
||||||
# # Add widgets here
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||||
|
}
|
||||||
@@ -65,6 +65,12 @@ type config struct {
|
|||||||
AppBackgroundColor string `yaml:"app-background-color"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type application struct {
|
|||||||
Version string
|
Version string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Config config
|
Config config
|
||||||
|
ConfigPath string
|
||||||
|
|
||||||
parsedManifest []byte
|
parsedManifest []byte
|
||||||
|
|
||||||
@@ -44,11 +45,12 @@ type application struct {
|
|||||||
failedAuthAttempts map[string]*failedAuthAttempt
|
failedAuthAttempts map[string]*failedAuthAttempt
|
||||||
}
|
}
|
||||||
|
|
||||||
func newApplication(c *config) (*application, error) {
|
func newApplication(c *config, configPath string) (*application, error) {
|
||||||
app := &application{
|
app := &application{
|
||||||
Version: buildVersion,
|
Version: buildVersion,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
Config: *c,
|
Config: *c,
|
||||||
|
ConfigPath: configPath,
|
||||||
slugToPage: make(map[string]*page),
|
slugToPage: make(map[string]*page),
|
||||||
widgetByID: make(map[uint64]widget),
|
widgetByID: make(map[uint64]widget),
|
||||||
}
|
}
|
||||||
@@ -450,6 +452,43 @@ func (a *application) server() (func() error, func() error) {
|
|||||||
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 {
|
if a.RequiresAuth {
|
||||||
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
|
||||||
mux.HandleFunc("GET /logout", a.handleLogoutRequest)
|
mux.HandleFunc("GET /logout", a.handleLogoutRequest)
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ func serveApp(configPath string) error {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -165,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -64,3 +64,4 @@
|
|||||||
@import "popover.css";
|
@import "popover.css";
|
||||||
@import "utils.css";
|
@import "utils.css";
|
||||||
@import "mobile.css";
|
@import "mobile.css";
|
||||||
|
@import "edit-mode.css";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -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 }}
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
{{ define "document-head-after" }}
|
{{ define "document-head-after" }}
|
||||||
<script type="module" src='{{ .App.StaticAssetPath "js/page.js" }}'></script>
|
<script type="module" src='{{ .App.StaticAssetPath "js/page.js" }}'></script>
|
||||||
|
{{ if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }}
|
||||||
|
<script src='{{ .App.StaticAssetPath "js/vendor/Sortable.min.js" }}'></script>
|
||||||
|
<script src='{{ .App.StaticAssetPath "js/edit-mode.js" }}'></script>
|
||||||
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "navigation-links" }}
|
{{ define "navigation-links" }}
|
||||||
@@ -43,6 +47,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ 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 }}
|
{{- if .App.RequiresAuth }}
|
||||||
<a class="block self-center" href="{{ .App.Config.Server.BaseURL }}/logout" title="Logout">
|
<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">
|
<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">
|
||||||
@@ -92,6 +108,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ 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 }}
|
{{ if .App.RequiresAuth }}
|
||||||
<a href="{{ .App.Config.Server.BaseURL }}/logout" class="flex justify-between items-center">
|
<a href="{{ .App.Config.Server.BaseURL }}/logout" class="flex justify-between items-center">
|
||||||
<div class="size-h3">Logout</div>
|
<div class="size-h3">Logout</div>
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ type widget interface {
|
|||||||
Render() template.HTML
|
Render() template.HTML
|
||||||
GetType() string
|
GetType() string
|
||||||
GetID() uint64
|
GetID() uint64
|
||||||
|
GetTitle() string
|
||||||
|
|
||||||
initialize() error
|
initialize() error
|
||||||
requiresUpdate(*time.Time) bool
|
requiresUpdate(*time.Time) bool
|
||||||
@@ -210,6 +211,10 @@ func (w *widgetBase) GetType() string {
|
|||||||
return w.Type
|
return w.Type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *widgetBase) GetTitle() string {
|
||||||
|
return w.Title
|
||||||
|
}
|
||||||
|
|
||||||
func (w *widgetBase) setProviders(providers *widgetProviders) {
|
func (w *widgetBase) setProviders(providers *widgetProviders) {
|
||||||
w.Providers = providers
|
w.Providers = providers
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user