Compare commits

..
224 Commits
Author SHA1 Message Date
uhlwoogi 947a42ddd4 Add vibe-coded disclaimer to README 2026-05-01 13:57:49 +00:00
uhlwoogiandClaude Sonnet 4.6 8e4f1380ce Update README for modern-glance fork
Documents the web editor, preset management, auth gate, undo, and
Docker deployment. Credits and links back to upstream glanceapp/glance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 13:56:38 +00:00
uhlwoogiandClaude Sonnet 4.6 2351d909ca Add theme preset UI and Docker compose
- /edit/theme-settings now lists user-defined presets with preview
  swatches, edit and delete actions
- Add/edit preset form at /edit/theme-settings/presets/new and /{key}
- Built-in catalog (14 themes from docs/themes.md) with one-click import
- docker-compose.yml using ghcr.io/uhlwoogi/modern-glance:latest
- Dockerfile gains OCI source label for GHCR repo linking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 13:53:46 +00:00
uhlwoogiandClaude Opus 4.7 7a0a4b9780 Add theme color editor and multi-step undo
- New /edit/theme-settings page with native color pickers for the
  background/primary/positive/negative theme colors plus light mode,
  contrast and saturation multipliers, custom-css-file path, and
  picker-disable. Pickers use hex; the backend converts to Glance's
  HSL "H S L" form before writing. Theme presets and full custom CSS
  intentionally still YAML.
- Numbered backup chain: save() rotates up to 10 prior versions as
  glance.yml.bak.1 .. .10 instead of a single .bak. The /edit page
  lists them with humanized timestamps and a per-row Restore button.
  Single-step "Restore previous (toggle)" is preserved.
- Restoring any backup saves the just-current state as a fresh
  .bak.1, so undo-the-undo always works.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 21:08:02 +00:00
uhlwoogiandClaude Opus 4.7 e108feb74b Round out the editor: head widgets, undo, group/split-column, site settings
- Head widgets are now first-class in dashboard edit mode: they get
  drag/edit/delete handles, their own SortableJS group (so they can't
  cross into columns), and the layout endpoint accepts a
  headWidgets[] sequence alongside columns[].
- New POST /edit/api/restore swaps glance.yml ↔ glance.yml.bak —
  effectively undo (and click again to redo). Surfaced as a "Restore
  previous version" button on /edit.
- group and split-column are now editable inline via a new
  list-of-widgets schema type. Each item picks a widget type from a
  dropdown (excluding group/split-column to honor the backend's
  no-nesting rule) and renders that type's schema-driven sub-form.
  Switching the type re-renders the body.
- New /edit/site-settings page with a form for branding (app name,
  logo, favicon, footer, app icon/background). POST applies via the
  same surgical yaml.Node update used for widgets. Theme/server
  intentionally still YAML for now.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:43:52 +00:00
uhlwoogiandClaude Opus 4.7 7f36f30dad Add page management to the structured editor
Pages can now be reordered, renamed, and have their metadata edited
without touching YAML.

- Up/down arrows next to each page in the /edit list to reorder.
- "Settings" button per page opens /edit/pages/{slug}/settings — a
  form covering name, slug, page width, desktop nav width, mobile
  header, hide desktop nav, center vertically.
- POST /edit/api/pages/{slug}/fields applies metadata changes
  surgically via the existing yaml.Node + atomic-save flow so
  columns/widgets/comments stay untouched.
- POST /edit/api/pages/{slug}/move?dir=up|down swaps adjacent pages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 17:18:52 +00:00
uhlwoogiandClaude Opus 4.7 8ba6dcb704 Add column editing in dashboard edit mode
Per-column header bar with size dropdown (small/full), left/right
move arrows, and delete. New "+ Add column" button at the bottom of
the page-columns container. Each action POSTs to a column endpoint
and reloads.

Backend endpoints:
- POST /edit/api/pages/{slug}/columns — add (form: size)
- POST /edit/api/pages/{slug}/columns/{col}/delete
- POST /edit/api/pages/{slug}/columns/{col}/move?dir=left|right
- POST /edit/api/pages/{slug}/columns/{col}/size — set size

All go through configEditor's yaml.Node tree so comments and other
config survive.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 17:16:08 +00:00
uhlwoogiandClaude Opus 4.7 4b8a34a2ae Add in-place dashboard edit mode
A toggle on the dashboard flips into edit mode where widgets get
drag/edit/delete handles and a "+ Add widget" button per column. Most
edits no longer require touching YAML.

- Drag-drop reorder via SortableJS, persisted through a bulk-layout
  endpoint that rebuilds each column's widgets sequence in the
  yaml.Node tree.
- Inline form dialog driven by per-widget field schemas
  (admin-schemas.go) covering 25 widget types. Surgical updates apply
  changed fields to the widget node so untouched keys and comments
  survive. Falls back to the existing YAML editor for group and
  split-column (those need a recursive nested-widget UI).
- Per-column "+ Add widget" picker that opens the dialog pre-filled
  with sensible defaults for the chosen type.
- Field validation and autocomplete for weather location (Open-Meteo
  geocoding), market symbol (Yahoo Finance), and RSS feed URL
  (gofeed). Network failures soft-pass for weather/markets so the
  save isn't blocked when external APIs are unreachable.
- Picking a market suggestion auto-fills the company name field via
  per-suggestion `extra` fields.
- Edit mode persists across reloads via localStorage so saves don't
  drop the user back into view mode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 16:51:52 +00:00
uhlwoogiandClaude Opus 4.7 2e198a19b4 Add in-app edit UI for dashboard config
A web editor at /edit for managing pages and widgets without hand-editing YAML:

- Page list and per-page widget views with breadcrumb navigation
- Add/delete pages, add/delete/reorder widgets within a column
- Per-widget textarea editor with curated starter templates, widget-specific
  help (example, full field reference, link to upstream docs), and inline
  error rendering that preserves the user's edits on validation failure
- Pre-save validation via newConfigFromYAML; atomic temp+rename writes with
  .bak backup; YAML edits go through yaml.Node so comments and unresolved
  ${env:X}/${secret:X} tokens survive round-trips
- Auth gate: editing requires auth.users to be configured, or
  admin.allow-without-auth: true as an explicit opt-in for trusted networks
- Pencil button on the dashboard near the theme picker for quick access
- Admin views read fresh from disk so the list updates immediately after
  saves rather than lagging the fsnotify watcher

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 14:12:18 +00:00
Svilen Markov 6c5b7a3f4c Update docs 2025-12-10 09:44:00 +00:00
Svilen Markov 36d5ae023f Merge pull request #848 from fullmetalsheep/main
feat(themes): Add theme 'Neon Pink'
2025-10-17 14:18:26 +01:00
fullmetalsheep 478c08f6a7 feat(themes): Add theme 'Neon Pink' 2025-10-17 10:53:25 +08:00
Svilen Markov cae90d16ba Update theme preview 2025-09-28 12:40:33 +01:00
Svilen Markov fbc07bd142 Merge pull request #833 from nicolasluckie/feat/add-shades-of-purple-theme
feat: Add Shades of Purple theme with screenshot
2025-09-28 12:36:36 +01:00
Svilen Markov 4a4d3e1755 Add contrast-multiplier to shades of purple 2025-09-28 12:34:41 +01:00
Svilen Markov f243a4938f Update readme 2025-09-28 12:21:12 +01:00
Svilen Markov 9416de1497 Fix indentation 2025-09-28 11:43:11 +01:00
Nic Luckie 283a5fcfd0 feat: add Shades of Purple theme with screenshot 2025-09-27 22:34:21 -04:00
Svilen Markov c88fd526e5 Merge branch 'dev'
Create release / release (push) Has been cancelled
2025-06-10 08:43:26 +01:00
Svilen Markov f0541ea5c8 Refactor icon stuff 2025-06-10 08:25:34 +01:00
Svilen Markov 8f986f1403 Refactor icons field and add mdi 2025-06-10 08:17:38 +01:00
Svilen Markov de9a192ba4 Remove healthcheck #676 2025-06-10 07:45:58 +01:00
Svilen Markov 88d8fa56fb Make auto-invert work with prefixed icons 2025-06-10 07:43:34 +01:00
Svilen Markov 9044e640bc Merge pull request #699 from septechx/patch-2
Fix typo in docs
2025-06-10 07:13:05 +01:00
Svilen Markov 808f3c1436 Update page.js 2025-06-10 07:11:25 +01:00
Svilen Markov d103c81df1 Fix search shortcut #719 2025-06-10 07:11:09 +01:00
Svilen Markov 429f0be675 Merge pull request #705 from rhijjawi/dev
setupTodos using incorrect logic and skipping to-do item.
2025-06-10 07:11:06 +01:00
Ramzi H 5a093f42b0 setupTodos using incorrect logic and skipping to-do item. 2025-05-27 12:54:22 +02:00
Sep ded435df0e Fix typo in docs 2025-05-25 23:34:31 +02:00
Svilen Markov e52374fa24 Fix popover triangle misalignment 2025-05-24 14:58:19 +01:00
Svilen Markov b94647efc9 Merge pull request #693 from chenrui333/update-purego
build: bump purego to build against newer go
2025-05-24 05:09:08 +01:00
Rui Chen d512770c10 build: bump purego to build against newer go
Signed-off-by: Rui Chen <rui@chenrui.dev>
2025-05-23 23:36:52 -04:00
Svilen Markov ea52318be6 Update FUNDING.yml 2025-05-24 00:03:17 +01:00
Svilen Markov 9e5023522b Merge pull request #689 from thallada/patch-1
Correct docs for Go's `text/html` built-in template functions
2025-05-22 23:32:33 +01:00
Tyler Hallada 4d0fdd9b11 Correct docs for Go's text/html built-in template functions
Source: https://pkg.go.dev/text/template#hdr-Functions

The comparison functions the greater-than-or-equal-to and less-than-or-equal-to are `ge` and `le`, not `gte` and `lte`. I got an error rendering my template before I realized this.
2025-05-22 18:03:45 -04:00
Svilen Markov 78725c8591 Merge pull request #687 from chenkhuaning0816/config
Docs: Add how to get playlist ID in configuration.md
2025-05-22 22:53:48 +01:00
acidburn f7adaad1c5 Docs: Add how to get playlist ID 2025-05-22 20:26:35 +08:00
Svilen Markov ab093cb232 Fix extra whitespace in titles (again) 2025-05-21 09:41:36 +01:00
Svilen Markov 2aaff02db8 Fix for extra whitespace in titles 2025-05-20 16:46:06 +01:00
Svilen Markov f5bfd9d4d1 Merge branch 'dev'
Create release / release (push) Has been cancelled
2025-05-19 21:25:48 +01:00
Svilen Markov 9e3639eb54 Update readme 2025-05-19 21:25:33 +01:00
Svilen Markov b4094b28bd Allow disabling theme picker 2025-05-19 21:25:33 +01:00
Svilen Markov b294839b79 Make theme key accessible via CSS 2025-05-19 21:25:33 +01:00
Svilen Markov 14a21de37f Add user agent to test reddit request 2025-05-19 21:23:13 +01:00
Svilen Markov d9239acbce Increase diagnose command timeout 2025-05-19 21:23:13 +01:00
Svilen Markov a2247c0b6c Use default client in diagnose command
defaultHTTPClient may behave differently to http.DefaultClient and is used for almost all widgets, using it within the diagnose
command will make debugging and replicating issues easier
2025-05-19 21:23:13 +01:00
Svilen Markov c1aaec5ffc Add .Options.JSON to custom API 2025-05-19 21:23:13 +01:00
Svilen Markov bcef9fbd61 Simplify implementation of func 2025-05-19 21:23:12 +01:00
Svilen Markov 32db59fda2 Merge pull request #678 from ralphocdol/fix-text-truncate-formatting
Fix text-truncate or related formatting
2025-05-19 21:12:21 +01:00
Svilen Markov 92bc68b61a Use innerText instead of textContent 2025-05-19 21:11:11 +01:00
Svilen Markov a6382b2e1d Merge pull request #681 from ralphocdol/update-the-and-or-doc-description
Docs: The 'and' and 'or' accepts more than 2 boolean arguments
2025-05-19 21:07:05 +01:00
Ralph Ocdol 571cdaf618 The 'and' and 'or' accepts more than 2 boolean arguments 2025-05-19 19:46:26 +08:00
Ralph Ocdol aa3b2c3b1b Fix text-truncate or related formatting 2025-05-18 20:33:03 +08:00
Svilen Markov 9bbf73db97 Merge pull request #661 from anant-j/dev
Add Mod operation
2025-05-16 17:33:58 +01:00
Svilen Markov dc950e7ec2 Merge pull request #668 from Xevion/main
Add Anchors to Links to Configuration Documentation within README
2025-05-16 17:30:14 +01:00
Svilen Markov 91ca57e242 Merge pull request #662 from mazzz1y/dev
Add http proxy support
2025-05-16 17:21:53 +01:00
Xevion 95541ef5e1 Use an anchor for links to root configuration 2025-05-15 23:19:54 -05:00
Svilen Markov 1ace129a58 Add safeHTML function 2025-05-15 14:21:59 +01:00
Svilen Markov af04605d7d Remove unused function 2025-05-15 13:57:27 +01:00
Dmitry Rubtsov 0ec9cf4aaa Add http proxy support 2025-05-15 13:34:17 +06:00
Anant Jain a5b0664b9c Add Mod operation 2025-05-14 22:38:13 -07:00
Svilen Markov c67eb4d2c0 Add startOfDay and endOfDay funcs
Create release / release (push) Has been cancelled
2025-05-14 22:05:35 +01:00
Svilen Markov e99ee4f774 Fix server crash when using head-widgets 2025-05-14 22:00:02 +01:00
Svilen Markov 74e6c5c960 Update docs 2025-05-14 01:16:53 +01:00
Svilen Markov f801da88a7 Fix center-vertically not working 2025-05-14 00:48:16 +01:00
Svilen Markov 66dcd1fa34 Don't escape safe URL 2025-05-13 23:44:47 +01:00
Svilen Markov 4f9e48cc17 Remove fallback favicon #653 2025-05-13 22:07:57 +01:00
Svilen Markov 95702a2e53 Fix error when hide-desktop-navigation is true
Create release / release (push) Has been cancelled
2025-05-13 20:35:34 +01:00
Svilen Markov 9de6843a2e Merge pull request #648 from glanceapp/dev
Create release / release (push) Has been cancelled
v0.8.0
2025-05-13 19:22:13 +01:00
Svilen Markov bd5cacd14a Update docs 2025-05-13 18:55:57 +01:00
Svilen Markov 96007e98bc Update docs 2025-05-13 17:35:16 +01:00
Svilen Markov ae409768ac Rename widget type 2025-05-13 17:10:28 +01:00
Svilen Markov baaf306ebf Add percentChange function 2025-05-11 21:09:12 +01:00
Svilen Markov 7bbf103e01 Add custom-api options and template requests 2025-05-11 15:49:21 +01:00
Svilen Markov 49c07f397e Don't escape safe URLs 2025-05-10 13:25:56 +01:00
Svilen Markov dd91a506fa Add todo widget 2025-05-08 16:59:02 +01:00
Svilen Markov 4ed8bef562 Update default light theme 2025-05-07 21:03:58 +01:00
Svilen Markov 8db6544400 Update favicon 2025-05-07 20:56:40 +01:00
Svilen Markov ca9e4d273b Fix popover error 2025-05-07 20:17:36 +01:00
Svilen Markov 9324da6a2f Change default light theme 2025-05-07 20:17:30 +01:00
Svilen Markov ce3a7ee29c Update auth stuff 2025-05-07 19:47:35 +01:00
Svilen Markov c9e6b774f3 Add head-widgets 2025-05-06 13:37:14 +01:00
Svilen Markov 9ffb2d9939 Change default light theme 2025-05-06 11:31:57 +01:00
Svilen Markov c5d4cf8f68 Allow hiding widget header 2025-05-06 10:52:46 +01:00
Svilen Markov 6b7d68d960 Add auth 2025-05-06 01:38:22 +01:00
Svilen Markov 0cb8a810e6 Remove transition on active 2025-05-05 13:50:55 +01:00
Svilen Markov 40ae263248 Add expiration to theme cookie 2025-05-05 13:46:36 +01:00
Svilen Markov bf97829814 Make theme cookie samesite lax 2025-05-05 13:30:14 +01:00
Svilen Markov b075607bac Make app icon bigger 2025-05-05 13:27:27 +01:00
Svilen Markov 56bdd2c9a3 Make repository widget links bigger on mobile 2025-05-05 13:15:51 +01:00
Svilen Markov 0f6e51ee5c Add new logo 2025-05-05 12:47:40 +01:00
Svilen Markov 1c5701fde5 Remove unused block 2025-05-05 11:04:40 +01:00
Svilen Markov 6c1c909c9d Make text non-selectable 2025-05-05 11:04:01 +01:00
Svilen Markov 26ab9c7b05 Auto detect favicon type and add fallback 2025-05-05 11:03:30 +01:00
Svilen Markov 176f14df70 Don't show background on hover 2025-05-05 10:58:22 +01:00
Svilen Markov 2d1e317c1f Reposition popover on scroll 2025-05-04 17:40:57 +01:00
Svilen Markov fcdea66a0f Hide popover on click 2025-05-04 17:40:49 +01:00
Svilen Markov 0e91958de5 Reduce text saturation of default light theme 2025-05-04 17:09:49 +01:00
Svilen Markov 6624b635df Increase contrast of default light theme 2025-05-04 17:05:20 +01:00
Svilen Markov 0612dc6f89 Use fmt.Errorf instead 2025-05-04 16:54:16 +01:00
Svilen Markov 0769f33653 Merge pull request #299 from hecht-a/theme_switcher
feat: theme switcher
2025-05-04 16:53:56 +01:00
Svilen Markov c2286f9a22 Update implementation 2025-05-04 16:49:20 +01:00
Svilen Markov a2d8410fec Merge remote-tracking branch 'upstream/dev' into theme_switcher 2025-04-29 19:03:41 +01:00
Svilen Markov a68805b55d Merge remote-tracking branch 'upstream/dev' into theme_switcher 2025-04-29 18:55:30 +01:00
Svilen Markov 8ca6879a1c Rename variable 2025-04-29 17:09:19 +01:00
Svilen Markov 1661f14adb Add timeout property 2025-04-29 17:08:36 +01:00
Svilen Markov 7d08eb312f Merge pull request #462 from charlesharries/rss-feed-conditional-requests
feat: Use conditional requests for RSS feeds
2025-04-29 10:42:28 +01:00
Svilen Markov 47340ed82a Merge branch 'dev' into rss-feed-conditional-requests 2025-04-29 10:39:19 +01:00
Svilen Markov 129441713b Refactor RSS widget 2025-04-29 10:37:46 +01:00
Svilen Markov a3e9510c1d Merge pull request #623 from mmshivesh/main
Minor design tweak, polishing up the polyline corners
2025-04-29 08:45:54 +01:00
Svilen Markov 328ba9a239 Merge branch 'main' into dev 2025-04-29 08:44:10 +01:00
Svilen Markov 3a3ff080ac Update returned err 2025-04-29 08:41:15 +01:00
Shivesh e2d3e1c04f Typed the wrong key- stroke-linecap instead of stroke-linejoin 2025-04-28 20:55:04 -04:00
Shivesh b3658bea09 Update markets.html
Ever so slightly improve the corner rounding of the polyline to make it more cohesive with the standard theming (slightly rounded corners etc.). Minor change, only visible at higher zoom levels.
2025-04-28 20:38:28 -04:00
Svilen Markov 1ad71f6974 Increase MaxIdleConnsPerHost 2025-04-28 20:01:12 +01:00
Svilen Markov 9dde306c0c Use limit query param in reddit widget 2025-04-28 18:49:10 +01:00
Svilen Markovands0ders d7a17aab01 Add reddit app auth #529
Co-authored-by: s0ders <39492740+s0ders@users.noreply.github.com>
2025-04-28 18:37:14 +01:00
Svilen Markov 65adf9b9c3 Add auto-invert option to icon fields 2025-04-28 16:27:08 +01:00
Svilen Markov aceb832645 Mention config schema in docs 2025-04-26 18:58:27 +01:00
Svilen Markov ec2f549295 Update property description 2025-04-26 18:56:25 +01:00
Svilen Markov 6e06c9c489 Bump results count to 20 #615 2025-04-26 18:12:25 +01:00
Svilen Markov 1862858c07 Merge pull request #453 from mike391/Add-support-configure-docker-containers-yaml
Feat: Added support for configuring Docker containers through glance.yaml instead of labels
2025-04-26 17:50:03 +01:00
Svilen Markov 306c28f203 Merge branch 'dev' into Add-support-configure-docker-containers-yaml 2025-04-26 17:44:34 +01:00
Svilen Markov bba2c5b20c Update label overrides implementation 2025-04-26 17:39:05 +01:00
Svilen Markov 18436e91e0 Docker containers: remote socket, category and running-only 2025-04-26 16:20:00 +01:00
Svilen Markov 77fb199cb3 Increase padding on mobile nav links 2025-04-26 16:20:00 +01:00
Svilen Markov 272bf1d2ad Merge pull request #594 from anxdpanic/error-responses
update error reporting for custom-api JSON validation
2025-04-24 09:06:05 +01:00
Svilen Markov ceebf34874 Use hex values & add theme_color in manifest.json
It looks like support for hsl color values within the manifest
is limited so we have to convert the colors to hex
2025-04-23 13:03:18 +01:00
Svilen Markov efd39e1f80 Merge pull request #602 from dvdpearson/dev
Dynamize manifest.json with configurable options
2025-04-23 12:25:09 +01:00
Svilen Markov 93c72f561b Also use app properties for apple-related elements 2025-04-23 12:18:46 +01:00
Svilen Markov 689e8f216c Rename function 2025-04-23 12:10:15 +01:00
Svilen Markov 4e4c3cfe64 Update dynamic manifest implementation 2025-04-23 12:09:33 +01:00
Svilen Markov e2112e0d83 Change HSL values to floats 2025-04-23 11:55:58 +01:00
Svilen Markov 44fca91089 Don't return if the err is a warning 2025-04-22 23:56:00 +01:00
Svilen Markov 4ae40cbd1e Merge branch 'main' into dev 2025-04-22 23:17:24 +01:00
David Pearson 333d40ed4f Dynamize manifest.json with configurable options 2025-04-20 10:00:17 -04:00
Svilen Markov cb3555f5ed Fix bug when making single request in job 2025-04-18 17:10:09 +01:00
anxdpanic 7a8f70db02 update error reporting for custom-api JSON validation
- show the request error on non-200 responses
2025-04-17 09:05:15 -04:00
Svilen Markov 1cf4f520f8 Merge pull request #578 from hitalos/dev
Support embed icons on bookmarks URLs
2025-04-15 21:58:24 +01:00
Svilen Markov e11ce5d3d0 Change the type of the field 2025-04-15 21:55:55 +01:00
Svilen Markov 44ea98fc41 Merge pull request #583 from anxdpanic/sensors
[sensors] allow sensor readings when there are warnings
2025-04-15 03:42:43 +01:00
Svilen Markov 5214fb9145 Update sensors warning check 2025-04-15 03:37:38 +01:00
anxdpanic 41e74ba9b5 [sensors] allow sensor readings when there are warnings 2025-04-14 09:05:18 -04:00
Svilen Markov 0daa620b11 Merge branch 'main' into dev 2025-04-13 17:27:32 +01:00
Svilen Markov c4e4c62072 Merge pull request #580 from ralphocdol/unique-array-custom-api
added unique to filter arrays with unique items in custom-api
2025-04-13 16:04:16 +01:00
Svilen Markov 33b54ded4f Delete main.css 2025-04-13 16:01:54 +01:00
Svilen Markov 0d2f5a818b Merge branch 'dev' into unique-array-custom-api 2025-04-13 16:00:39 +01:00
Svilen Markov 8f217e6f01 Merge branch 'main' into dev 2025-04-13 15:58:10 +01:00
Svilen Markov 835d7767cb Merge remote-tracking branch 'upstream/main' into unique-array-custom-api 2025-04-13 15:54:52 +01:00
Svilen Markov 8892a39160 Document function 2025-04-13 15:52:16 +01:00
Ralph Ocdol db97871945 added unique to filter arrays with unique items in custom-api 2025-04-13 10:46:43 +08:00
Hítalo Silva 90254b92d2 Support embed icons on bookmarks URLs 2025-04-12 19:48:09 -03:00
Svilen Markov eeda2104a6 Only set title if attribute isn't set 2025-04-11 23:15:31 +01:00
Svilen Markov 793da48f5c Add rounded class 2025-04-11 22:35:40 +01:00
Svilen Markov 84f7286460 Make Get return decorated result
This may break existing widgets that rely on .Get returing a raw gjson.Result
2025-04-11 20:30:07 +01:00
Svilen Markov d9c934d3eb Merge pull request #494 from HtFilia/dev
Add filter on already seen links for RSS feeds
2025-04-09 17:15:11 +01:00
Svilen Markov 0bc31b0f31 Merge pull request #542 from ralphocdol/custom-api-replace-all-regex
Add replaceMatches
2025-04-09 17:07:43 +01:00
Svilen Markov 5a38c8dd2f Add replaceMatches 2025-04-09 17:05:18 +01:00
Ralph Ocdol 15f20ffaeb feat: custom-api's replaceAll with regex support 2025-03-31 22:24:51 +08:00
Svilen Markov 7f0e9b3289 Merge branch 'main' into dev 2025-03-29 18:01:15 +00:00
Svilen Markov 9effff25df Increase z-index of mobile nav
This fixes the carousel gradient side
being above it since it also has z-index 10
2025-03-29 10:49:52 +00:00
Svilen Markov 4e6b14a467 Merge branch 'main' into dev 2025-03-26 19:46:16 +00:00
Lucas L. 18241136e3 Add filter on already seen links for RSS feeds 2025-03-22 19:59:00 +01:00
Svilen Markov 6fb6467b07 Fix sorting bug in twitch channels widget 2025-03-22 11:02:06 +00:00
Svilen Markov 83f0d16904 Increase scale on mobile 2025-03-21 23:40:23 +00:00
Svilen Markov 97b52792ef Fix CSS issues on mobile due to order of declarations 2025-03-21 14:04:43 +00:00
Svilen Markov 0680c5ff9c Bump contrast of progress value 2025-03-21 13:50:33 +00:00
Svilen Markov f1f0158238 Add parseRelativeTime function 2025-03-20 23:49:09 +00:00
Svilen Markov bca3617116 Add allow-insecure to custom-api widget 2025-03-20 23:02:17 +00:00
Svilen Markov 55ae674e0b Add desktop-navigation-width property 2025-03-20 22:54:39 +00:00
Svilen Markov 46eb610d26 Fix typo 2025-03-20 22:54:39 +00:00
Svilen Markov bfc2e9c7fb Reduce contrast of progress bar values 2025-03-20 22:54:39 +00:00
Svilen Markov ffedd9eaf9 Merge pull request #480 from stripedew/dev
Add Kagi and Startpage as search engines
2025-03-20 19:37:08 +00:00
Svilen Markov e566dcd0d9 Make config variables matching stricter
This prevents strings such as ${whatever} from getting
confused for env variables and resulting in config errors
2025-03-20 16:57:56 +00:00
Svilen Markov 43b8f8f31b Split CSS into multiple files
This shouldn't result in anything breaking,
will require thorough testing
2025-03-20 16:57:55 +00:00
stripedew 768f700719 Update configuration.md 2025-03-20 12:02:33 +00:00
stripedew ab1faedadf Update widget-search.go 2025-03-20 12:01:26 +00:00
stripedew fa325bd519 Update configuration.md
Added Kagi
2025-03-20 11:53:59 +00:00
Svilen Markov 568876fc4f Merge pull request #476 from jpinz/dev
Add bing and perplexity as search engines
2025-03-19 20:00:54 +00:00
Julian d506604918 Add bing and perplexity as search engines 2025-03-19 16:25:10 +00:00
Svilen Markov 7c9f79f243 Merge pull request #466 from ralphocdol/dns-stats-pihole-default-title-url
make title-url of pihole and pihole-v6 default to /admin
2025-03-18 13:13:46 +00:00
Svilen Markov a77d0ce5c0 Move behavior within the widget init
Otherwise we create a global getWidgetTitleURL
function that sounds a little too generic and vague
2025-03-18 13:11:19 +00:00
Ralph Ocdol 55685e6854 make title-url of pihole and pihole-v6 default to /admin 2025-03-18 11:02:26 +08:00
Charles Harries f36527995e feat: Use conditional requests for RSS feeds 2025-03-17 19:06:32 +00:00
Svilen Markov fd5cf98072 Refactor config variables and add new features
* Can now use Docker secrets
* Can now read files who's path is provided by an env var
2025-03-17 17:37:51 +00:00
Svilen Markov 82cb0143f2 Only override extension url query if parameters property present 2025-03-17 14:16:47 +00:00
MikeC 51e70347e4 Add Documentation 2025-03-17 07:09:31 -04:00
MikeC 075bdfdc23 Add ability to convert docker container names to humanreadable names 2025-03-17 06:58:11 -04:00
MikeC e84edb3e30 Add support to configure docker containers in yaml 2025-03-17 06:53:08 -04:00
Svilen Markov 2bde4656ed Merge branch 'main' into dev 2025-03-17 03:01:06 +00:00
Svilen Markov d22ac6a7a4 Add parseTime to custom-api 2025-03-13 00:55:31 +00:00
Svilen Markov 2f50f5ef34 Merge branch 'main' into dev 2025-03-12 18:04:16 +00:00
Svilen Markov 14db59318c Avoid spinning up unnecessary goroutines for single data jobs 2025-03-12 10:35:54 +00:00
Svilen Markov f9b3deaff2 Simplify worker num with min 2025-03-12 10:35:54 +00:00
Ralph OcdolandSvilen Markov c265e42220 Add subrequests to custom-api (#385)
* feat: custom-api multiple API queries

* fix template check

* refactor

* Update implementation & docs

* Swap statement

* Update docs

---------

Co-authored-by: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com>
2025-03-12 10:30:29 +00:00
Svilen Markov c0bdf1551d Add support for bool in query params fields 2025-03-10 09:56:47 +00:00
Ralph OcdolandSvilen Markov e373eeeed3 fix: full width clickable link for monitor-site (#405)
* feat: full width clickable link for monitor-site

* refactor

* Use grow instead of width-100

---------

Co-authored-by: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com>
2025-03-10 09:49:38 +00:00
Svilen Markov 6c8859863a Add description property to bookmarks widget links 2025-03-02 00:09:28 +00:00
Svilen Markov 31ecd91f7c Fix failing to parse empty response body in custom api widget 2025-03-01 23:43:33 +00:00
Svilen Markov 474255c985 Tweak error message 2025-03-01 23:38:29 +00:00
Svilen Markov 652f9ceb5c Merge pull request #378 from ralphocdol/custom-api-array-parameters
feat: add parameters and array parameters support
2025-03-01 23:36:29 +00:00
Svilen Markov 49668d4ba9 Also apply to extension widget 2025-03-01 23:30:20 +00:00
Svilen Markov acddaf07db Add note to docs 2025-03-01 23:29:56 +00:00
Svilen Markov 8da26ab409 Make query parameters field reusable 2025-03-01 23:29:28 +00:00
Ralph Ocdol 948289a038 feat: add parameters and array parameters support 2025-02-28 08:48:07 +08:00
Svilen Markov ce293ed891 Prevent infinite config include recursion 2025-02-27 07:22:18 +00:00
Svilen Markov 2738613344 Improve error message when widget type not specified 2025-02-27 07:12:07 +00:00
Svilen Markov 5d12d934b8 Use new range syntax 2025-02-27 07:11:44 +00:00
Svilen Markov 19a89645a1 Add support for nested includes 2025-02-27 07:11:03 +00:00
Svilen Markov 9df9673e84 Add alternative include syntax
Also make it the new recommended way for doing includes
2025-02-25 02:25:01 +00:00
Svilen Markov 9c98c6d0c4 Merge branch 'main' into dev 2025-02-22 13:33:17 +00:00
Svilen Markov 4d6600b0a3 Merge pull request #367 from ejsadiarin/dev
feat(monitor): add basic-auth feature for protected sites
2025-02-22 13:30:09 +00:00
Svilen Markov dac0d15e78 Update implementation 2025-02-22 13:29:00 +00:00
ejsadiarin 5b45751c67 docs(monitor): add documentation for basic-auth feature 2025-02-19 17:40:56 +08:00
ejsadiarin c00d937f4c feat(monitor): add basic-auth feature for protected sites
this closes [issue #316](https://github.com/glanceapp/glance/issues/316)

Furthermore, this could be expanded to also pass the configured basic
auth credentials to the request when the user clicks on the specific
monitor widget
2025-02-19 17:28:13 +08:00
Svilen Markov c33fe45d4c Merge branch 'main' into dev 2025-02-17 23:27:42 +00:00
Svilen Markov e355c643f4 Merge pull request #339 from KevinFumbles/dev
Added Technitium Service Option to DNS-Stats Widget
2025-02-17 23:20:54 +00:00
Svilen Markov fcccb7eb38 Update error message 2025-02-17 23:20:38 +00:00
Svilen Markov f9209406fb Reduce duplication of constants 2025-02-17 23:18:27 +00:00
Svilen Markov facbf6f529 Remove mention of env variable syntax 2025-02-17 23:08:36 +00:00
Kevin 94806ed45d Added blocked domains count for Technitium 2025-02-12 16:05:38 -05:00
Kevin baee94ed1d Added configuration documentation for Technitium dns-stats service 2025-02-12 15:58:21 -05:00
Kevin 0c8358beaa Added Technitium as a valid service for dns-stats widget 2025-02-12 15:58:05 -05:00
HECHT Axel 62e9c32082 feat: theme switcher 2025-01-15 12:19:50 +01:00
114 changed files with 12286 additions and 3280 deletions
+1
View File
@@ -1 +1,2 @@
github: [glanceapp]
patreon: glanceapp
+2
View File
@@ -3,3 +3,5 @@
/playground
/.idea
/glance*.yml
*.yml.bak
*.yml.bak.*
+3 -4
View File
@@ -1,4 +1,4 @@
FROM golang:1.24.2-alpine3.21 AS builder
FROM golang:1.24.3-alpine3.21 AS builder
WORKDIR /app
COPY . /app
@@ -6,11 +6,10 @@ RUN CGO_ENABLED=0 go build .
FROM alpine:3.21
LABEL org.opencontainers.image.source=https://github.com/uhlwoogi/modern-glance
WORKDIR /app
COPY --from=builder /app/glance .
HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
CMD wget --spider -q http://localhost:8080/api/healthz
EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
-3
View File
@@ -3,8 +3,5 @@ FROM alpine:3.21
WORKDIR /app
COPY glance .
HEALTHCHECK --timeout=10s --start-period=60s --interval=60s \
CMD wget --spider -q http://localhost:8080/api/healthz
EXPOSE 8080/tcp
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
+83 -396
View File
@@ -1,436 +1,123 @@
<p align="center"><em>What if you could see everything at a...</em></p>
<h1 align="center">Glance</h1>
<p align="center"><a href="#installation">Install</a> • <a href="docs/configuration.md">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></p>
<p align="center"><img src="docs/logo.png"></p>
<h1 align="center">modern-glance</h1>
<p align="center">
<a href="#installation">Install</a> •
<a href="#web-based-editor">Editor</a> •
<a href="docs/configuration.md#configuring-glance">Configuration</a> •
<a href="docs/themes.md">Themes</a>
</p>
![](docs/images/readme-main-image.png)
<p align="center">A fork of <a href="https://github.com/glanceapp/glance">glanceapp/glance</a> that adds a web-based dashboard editor so you can manage your config without touching YAML.</p>
## Features
### 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)
> **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).
### 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)
> **⚠️ 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.
### Tons of customizability
* Different layouts
* As many pages/tabs as you need
* Numerous configuration options for each widget
* Multiple styles for some widgets
* Custom CSS
---
### Optimized for mobile devices
Because you'll want to take it with you on the go.
## What's added
![](docs/images/mobile-preview.png)
### In-place edit mode
A pencil button in the dashboard header toggles edit mode. While active you can:
- **Drag and drop** widgets between columns and reorder them
- **Add widgets** via a form dialog (for most widget types) or a YAML editor fallback
- **Edit widgets** inline with per-field forms — includes live lookup for weather locations, market symbols, and RSS feed validation
- **Delete widgets**
- **Add, delete, resize, and reorder columns**
- **Add and manage head widgets** (the row above the columns)
### Themeable
Easily create your own theme by tweaking a few numbers or choose from one of the [already available themes](docs/themes.md).
### Page management (`/edit`)
- Add, delete, and reorder pages
- Per-page settings: name, slug, width, navigation options, mobile header
![](docs/images/themes-example.png)
### Site settings (`/edit/site-settings`)
- App name, logo, favicon, footer — all editable without touching YAML
<br>
### Theme settings (`/edit/theme-settings`)
- Color pickers for background, primary, positive, and negative colors (hex input, stored as HSL)
- Contrast and text saturation multipliers
- Light mode toggle, hide-picker toggle, custom CSS file path
- **Preset management**: add, edit, and delete named theme presets with live preview swatches
- **Built-in theme catalog**: 14 themes from the upstream docs (Dracula, Catppuccin variants, Gruvbox, and more) importable with one click
## Configuration
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).
### 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.
<details>
<summary><strong>Preview example configuration file</strong></summary>
<br>
### Auth gate
`/edit` returns 403 unless `auth.users` is configured **or** `admin.allow-without-auth: true` is set. See `docs/glance.yml` for examples of both.
```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
Choose one of the following methods:
<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:
### Docker (recommended)
```yaml
# docker-compose.yml
services:
glance:
container_name: glance
image: glanceapp/glance
restart: unless-stopped
volumes:
- ./config:/app/config
image: ghcr.io/uhlwoogi/modern-glance:latest
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
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
docker compose up -d
```
## Original Glance features
If you encounter any issues, you can check the logs by running:
Everything from upstream is intact:
```bash
docker logs glance
```
- **Widgets**: RSS, Reddit, Hacker News, weather, YouTube, Twitch, markets, Docker containers, server stats, bookmarks, calendar, monitors, and [many more](docs/configuration.md#configuring-glance)
- **Fast and lightweight**: low memory, minimal JS, single ~20 MB binary, pages load in ~1s
- **Highly customizable**: multiple layouts, pages, themes, custom CSS
- **Mobile optimized**
- **Themeable**: tweak a few numbers or pick from the [theme gallery](docs/themes.md)
<hr>
</details>
Full configuration reference: [docs/configuration.md](docs/configuration.md#configuring-glance)
<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
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
# Run locally
go run .
```
<hr>
</details>
<details>
<summary><strong>Build project and Docker image with Docker</strong></summary>
<br>
# Build binary
go build -o glance .
Requirements: [Docker](https://docs.docker.com/engine/install/)
To build the project and image using just Docker, run:
*(replace `owner` with your name or organization)*
```bash
docker build -t owner/glance:latest .
# Build and push Docker image
docker build -t ghcr.io/uhlwoogi/modern-glance:latest .
docker push ghcr.io/uhlwoogi/modern-glance:latest
```
If you wish to push the image to a registry (by default Docker Hub), run:
```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.
Requires Go ≥ 1.23.
+17
View File
@@ -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
+506 -45
View File
@@ -4,7 +4,11 @@
- [The config file](#the-config-file)
- [Auto reload](#auto-reload)
- [Environment variables](#environment-variables)
- [Other ways of providing tokens/passwords/secrets](#other-ways-of-providing-tokenspasswordssecrets)
- [Including other config files](#including-other-config-files)
- [Icons](#icons)
- [Config schema](#config-schema)
- [Authentication](#authentication)
- [Server](#server)
- [Document](#document)
- [Branding](#branding)
@@ -23,6 +27,7 @@
- [Custom API](#custom-api)
- [Extension](#extension)
- [Weather](#weather)
- [Todo](#todo)
- [Monitor](#monitor)
- [Releases](#releases)
- [Docker Containers](#docker-containers)
@@ -92,14 +97,46 @@ If you need to use the syntax `${NAME}` in your config without it being interpre
something: \${NOT_AN_ENV_VAR}
```
#### Other ways of providing tokens/passwords/secrets
You can use [Docker secrets](https://docs.docker.com/compose/how-tos/use-secrets/) with the following syntax:
```yaml
# This will be replaced with the contents of the file /run/secrets/github_token
# so long as the secret `github_token` is provided to the container
token: ${secret:github_token}
```
Alternatively, you can load the contents of a file who's path is provided by an environment variable:
`docker-compose.yml`
```yaml
services:
glance:
image: glanceapp/glance
environment:
- TOKEN_FILE=/home/user/token
volumes:
- /home/user/token:/home/user/token
```
`glance.yml`
```yaml
token: ${readFileFromEnv:TOKEN_FILE}
```
> [!NOTE]
>
> The contents of the file will be stripped of any leading/trailing whitespace before being used.
### Including other config files
Including config files from within your main config file is supported. This is done via the `!include` directive along with a relative or absolute path to the file you want to include. If the path is relative, it will be relative to the main config file. Additionally, environment variables can be used within included files, and changes to the included files will trigger an automatic reload. Example:
Including config files from within your main config file is supported. This is done via the `$include` directive along with a relative or absolute path to the file you want to include. If the path is relative, it will be relative to the main config file. Additionally, environment variables can be used within included files, and changes to the included files will trigger an automatic reload. Example:
```yaml
pages:
!include: home.yml
!include: videos.yml
!include: homelab.yml
- $include: home.yml
- $include: videos.yml
- $include: homelab.yml
```
The file you are including should not have any additional indentation, its values should be at the top level and the appropriate amount of indentation will be added automatically depending on where the file is included. Example:
@@ -112,14 +149,14 @@ pages:
columns:
- size: full
widgets:
!include: rss.yml
- $include: rss.yml
- name: News
columns:
- size: full
widgets:
- type: group
widgets:
!include: rss.yml
- $include: rss.yml
- type: reddit
subreddit: news
```
@@ -133,9 +170,9 @@ pages:
- url: ${RSS_URL}
```
The `!include` directive can be used anywhere in the config file, not just in the `pages` property, however it must be on its own line and have the appropriate indentation.
The `$include` directive can be used anywhere in the config file, not just in the `pages` property, however it must be on its own line and have the appropriate indentation.
If you encounter YAML parsing errors when using the `!include` directive, the reported line numbers will likely be incorrect. This is because the inclusion of files is done before the YAML is parsed, as YAML itself does not support file inclusion. To help with debugging in cases like this, you can use the `config:print` command and pipe it into `less -N` to see the full config file with includes resolved and line numbers added:
If you encounter YAML parsing errors when using the `$include` directive, the reported line numbers will likely be incorrect. This is because the inclusion of files is done before the YAML is parsed, as YAML itself does not support file inclusion. To help with debugging in cases like this, you can use the `config:print` command and pipe it into `less -N` to see the full config file with includes resolved and line numbers added:
```sh
glance --config /path/to/glance.yml config:print | less -N
@@ -149,6 +186,95 @@ docker run --rm -v ./glance.yml:/app/config/glance.yml glanceapp/glance config:p
This assumes that the config you want to print is in your current working directory and is named `glance.yml`.
## Icons
For widgets which provide you with the ability to specify icons such as the monitor, bookmarks, docker containers, etc, you can use the `icon` property to specify a URL to an image or use icon names from multiple libraries via prefixes:
```yml
icon: si:immich # si for Simple icons https://simpleicons.org/
icon: sh:immich # sh for selfh.st icons https://selfh.st/icons/
icon: di:immich # di for Dashboard icons https://github.com/homarr-labs/dashboard-icons
icon: mdi:camera # mdi for Material Design icons https://pictogrammers.com/library/mdi/
```
> [!NOTE]
>
> The icons are loaded externally and are hosted on `cdn.jsdelivr.net`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
Icons from the Simple icons library as well as Material Design icons will automatically invert their color to match your light or dark theme, however you may want to enable this manually for other icons. To do this, you can use the `auto-invert` prefix:
```yaml
icon: auto-invert https://example.com/path/to/icon.png # with a URL
icon: auto-invert sh:glance-dark # with a selfh.st icon
```
This expects the icon to be black and will automatically invert it to white when using a dark theme.
## Config schema
For property descriptions, validation and autocompletion of the config within your IDE, @not-first has kindly created a [schema](https://github.com/not-first/glance-schema). Massive thanks to them for this, go check it out and give them a star!
## Authentication
To make sure that only you and the people you want to share your dashboard with have access to it, you can set up authentication via username and password. This is done through a top level `auth` property. Example:
```yaml
auth:
secret-key: # this must be set to a random value generated using the secret:make CLI command
users:
admin:
password: 123456
svilen:
password: 123456
```
To generate a secret key, run the following command:
```sh
./glance secret:make
```
Or with Docker:
```sh
docker run --rm glanceapp/glance secret:make
```
### Using hashed passwords
If you do not want to store plain passwords in your config file or in environment variables, you can hash your password and provide its hash instead:
```sh
./glance password:hash mysecretpassword
```
Or with Docker:
```sh
docker run --rm glanceapp/glance password:hash mysecretpassword
```
Then, in your config file use the `password-hash` property instead of `password`:
```yaml
auth:
secret-key: # this must be set to a random value generated using the secret:make CLI command
users:
admin:
password-hash: $2a$10$o6SXqiccI3DDP2dN4ADumuOeIHET6Q4bUMYZD6rT2Aqt6XQ3DyO.6
```
### Preventing brute-force attacks
Glance will automatically block IP addresses of users who fail to authenticate 5 times in a row in the span of 5 minutes. In order for this feature to work correctly, Glance must know the real IP address of requests. If you're using a reverse proxy such as nginx, Traefik, NPM, etc, you must set the `proxied` property in the `server` configuration to `true`:
```yaml
server:
proxied: true
```
When set to `true`, Glance will use the `X-Forwarded-For` header to determine the original IP address of the request, so make sure that your reverse proxy is correctly configured to send that header.
## Server
Server configuration is done through a top level `server` property. Example:
@@ -164,6 +290,7 @@ server:
| ---- | ---- | -------- | ------- |
| host | string | no | |
| port | number | no | 8080 |
| proxied | boolean | no | false |
| base-url | string | no | |
| assets-path | string | no | |
@@ -173,6 +300,9 @@ The address which the server will listen on. Setting it to `localhost` means tha
#### `port`
A number between 1 and 65,535, so long as that port isn't already used by anything else.
#### `proxied`
Set to `true` if you're using a reverse proxy in front of Glance. This will make Glance use the `X-Forwarded-*` headers to determine the original request details.
#### `base-url`
The base URL that Glance is hosted under. No need to specify this unless you're using a reverse proxy and are hosting Glance under a directory. If that's the case then you can set this value to `/glance` or whatever the directory is called. Note that the forward slash (`/`) in the beginning is required unless you specify the full domain and path.
@@ -235,6 +365,9 @@ branding:
<p>Powered by <a href="https://github.com/glanceapp/glance">Glance</a></p>
logo-url: /assets/logo.png
favicon-url: /assets/logo.png
app-name: "My Dashboard"
app-icon-url: "/assets/app-icon.png"
app-background-color: "#151519"
```
### Properties
@@ -246,6 +379,9 @@ branding:
| logo-text | string | no | G |
| logo-url | string | no | |
| favicon-url | string | no | |
| app-name | string | no | Glance |
| app-icon-url | string | no | Glance's default icon |
| app-background-color | string | no | Glance's default background color |
#### `hide-footer`
Hides the footer when set to `true`.
@@ -262,6 +398,15 @@ Specify a URL to a custom image to use instead of the "G" found in the navigatio
#### `favicon-url`
Specify a URL to a custom image to use for the favicon.
#### `app-name`
Specify the name of the web app shown in browser tab and PWA.
#### `app-icon-url`
Specify URL for PWA and browser tab icon (512x512 PNG).
#### `app-background-color`
Specify background color for PWA. Must be a valid CSS color.
## Theme
Theming is done through a top level `theme` property. Values for the colors are in [HSL](https://giggster.com/guide/basics/hue-saturation-lightness/) (hue, saturation, lightness) format. You can use a color picker [like this one](https://hslpicker.com/) to convert colors from other formats to HSL. The values are separated by a space and `%` is not required for any of the numbers.
@@ -269,9 +414,24 @@ Example:
```yaml
theme:
# This will be the default theme
background-color: 100 20 10
primary-color: 40 90 40
contrast-multiplier: 1.1
disable-picker: false
presets:
gruvbox-dark:
background-color: 0 0 16
primary-color: 43 59 81
positive-color: 61 66 44
negative-color: 6 96 59
zebra:
light: true
background-color: 0 0 95
primary-color: 0 0 10
negative-color: 0 90 50
```
### Available themes
@@ -288,6 +448,8 @@ If you don't want to spend time configuring your own theme, there are [several a
| contrast-multiplier | number | no | 1 |
| text-saturation-multiplier | number | no | 1 |
| custom-css-file | string | no | |
| disable-picker | bool | false | |
| presets | object | no | |
#### `light`
Whether the scheme is light or dark. This does not change the background color, it inverts the text colors so that they look appropriately on a light background.
@@ -332,6 +494,33 @@ theme:
>
> In addition, you can also use the `css-class` property which is available on every widget to set custom class names for individual widgets.
#### `disable-picker`
When set to `true` hides the theme picker and disables the abiltity to switch between themes. All users who previously picked a non-default theme will be switched over to the default theme.
#### `presets`
Define additional theme presets that can be selected from the theme picker on the page. For each preset, you can specify the same properties as for the default theme, such as `background-color`, `primary-color`, `positive-color`, `negative-color`, `contrast-multiplier`, etc., except for the `custom-css-file` property.
Example:
```yaml
theme:
presets:
my-custom-dark-theme:
background-color: 229 19 23
contrast-multiplier: 1.2
primary-color: 222 74 74
positive-color: 96 44 68
negative-color: 359 68 71
my-custom-light-theme:
light: true
background-color: 220 23 95
contrast-multiplier: 1.1
primary-color: 220 91 54
positive-color: 109 58 40
negative-color: 347 87 44
```
To override the default dark and light themes, use the key names `default-dark` and `default-light`.
## Pages & Columns
![illustration of pages and columns](images/pages-and-columns-illustration.png)
@@ -359,10 +548,11 @@ pages:
| name | string | yes | |
| slug | string | no | |
| width | string | no | |
| desktop-navigation-width | string | no | |
| center-vertically | boolean | no | false |
| hide-desktop-navigation | boolean | no | false |
| expand-mobile-page-navigation | boolean | no | false |
| show-mobile-header | boolean | no | false |
| head-widgets | array | no | |
| columns | array | yes | |
#### `name`
@@ -372,9 +562,14 @@ The name of the page which gets shown in the navigation bar.
The URL friendly version of the title which is used to access the page. For example if the title of the page is "RSS Feeds" you can make the page accessible via `localhost:8080/feeds` by setting the slug to `feeds`. If not defined, it will automatically be generated from the title.
#### `width`
The maximum width of the page on desktop. Possible values are `slim` and `wide`.
The maximum width of the page on desktop. Possible values are `default`, `slim` and `wide`.
* default: `1600px` (when no value is specified)
#### `desktop-navigation-width`
The maximum width of the desktop navigation. Useful if you have a few pages that use a different width than the rest and don't want the navigation to jump abruptly when going to and away from those pages. Possible values are `default`, `slim` and `wide`.
Here are the pixel equivalents for each value:
* default: `1600px`
* slim: `1100px`
* wide: `1920px`
@@ -388,9 +583,6 @@ When set to `true`, vertically centers the content on the page. Has no effect if
#### `hide-desktop-navigation`
Whether to show the navigation links at the top of the page on desktop.
#### `expand-mobile-page-navigation`
Whether the mobile page navigation should be expanded by default.
#### `show-mobile-header`
Whether to show a header displaying the name of the page on mobile. The header purposefully has a lot of vertical whitespace in order to push the content down and make it easier to reach on tall devices.
@@ -398,6 +590,43 @@ Preview:
![](images/mobile-header-preview.png)
#### `head-widgets`
Head widgets will be shown at the top of the page, above the columns, and take up the combined width of all columns. You can specify any widget, though some will look better than others, such as the markets, RSS feed with `horizontal-cards` style, and videos widgets. Example:
![](images/head-widgets-preview.png)
```yaml
pages:
- name: Home
head-widgets:
- type: markets
hide-header: true
markets:
- symbol: SPY
name: S&P 500
- symbol: BTC-USD
name: Bitcoin
- symbol: NVDA
name: NVIDIA
- symbol: AAPL
name: Apple
- symbol: MSFT
name: Microsoft
columns:
- size: small
widgets:
- type: calendar
- size: full
widgets:
- type: hacker-news
- size: small
widgets:
- type: weather
location: London, United Kingdom
```
### Columns
Columns are defined for each page using a `columns` property. There are two types of columns - `full` and `small`, which refers to their width. A small column takes up a fixed amount of width (300px) and a full column takes up the all of the remaining width. You can have up to 3 columns per page and you must have either 1 or 2 full columns. Example:
@@ -476,6 +705,7 @@ pages:
| type | string | yes |
| title | string | no |
| title-url | string | no |
| hide-header | boolean | no | false |
| cache | string | no |
| css-class | string | no |
@@ -488,6 +718,13 @@ The title of the widget. If left blank it will be defined by the widget.
#### `title-url`
The URL to go to when clicking on the widget's title. If left blank it will be defined by the widget (if available).
#### `hide-header`
When set to `true`, the header (title) of the widget will be hidden. You cannot hide the header of the group widget.
> [!NOTE]
>
> If a widget fails to update, a red dot or circle is shown next to the title of that widget indicating that the it is not working. You will not be able to see this if you hide the header.
#### `cache`
How long to keep the fetched data in memory. The value is a string and must be a number followed by one of s, m, h, d. Examples:
@@ -660,6 +897,11 @@ A list of playlist IDs:
- PL8mG-RkN2uTxTK4m_Vl2dYR9yE41kRdBg
```
The playlist ID can be found in its link which is in the form of
```
https://www.youtube.com...&list={ID}&...
```
##### `limit`
The maximum number of videos to show.
@@ -789,7 +1031,10 @@ Display a list of posts from a specific subreddit.
> [!WARNING]
>
> Reddit does not allow unauthorized API access from VPS IPs, if you're hosting Glance on a VPS you will get a 403 response. As a workaround you can route the traffic from Glance through a VPN or your own HTTP proxy using the `request-url-template` property.
> Reddit does not allow unauthorized API access from VPS IPs, if you're hosting Glance on a VPS you will get a 403
> response. As a workaround you can either [register an app on Reddit](https://ssl.reddit.com/prefs/apps/) and use the
> generated ID and secret in the widget configuration to authenticate your requests (see `app-auth` property), use a proxy
> (see `proxy` property) or route the traffic from Glance through a VPN.
Example:
@@ -814,6 +1059,7 @@ Example:
| top-period | string | no | day |
| search | string | no | |
| extra-sort-by | string | no | |
| app-auth | object | no | |
##### `subreddit`
The subreddit for which to fetch the posts from.
@@ -921,6 +1167,19 @@ Can be used to specify an additional sort which will be applied on top of the al
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
##### `app-auth`
```yaml
widgets:
- type: reddit
subreddit: technology
app-auth:
name: ${REDDIT_APP_NAME}
id: ${REDDIT_APP_CLIENT_ID}
secret: ${REDDIT_APP_SECRET}
```
To register an app on Reddit, go to [this page](https://ssl.reddit.com/prefs/apps/).
### Search Widget
Display a search bar that can be used to search for specific terms on various search engines.
@@ -969,6 +1228,10 @@ Either a value from the table below or a URL to a custom search engine. Use `{QU
| ---- | --- |
| duckduckgo | `https://duckduckgo.com/?q={QUERY}` |
| google | `https://www.google.com/search?q={QUERY}` |
| bing | `https://www.bing.com/search?q={QUERY}` |
| perplexity | `https://www.perplexity.ai/search?q={QUERY}` |
| kagi | `https://kagi.com/search?q={QUERY}` |
| startpage | `https://www.startpage.com/search?q={QUERY}` |
##### `new-tab`
When set to `true`, swaps the shortcuts for showing results in the same or new tab, defaulting to showing results in a new tab.
@@ -1294,7 +1557,7 @@ Examples:
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| url | string | yes | |
| url | string | no | |
| headers | key (string) & value (string) | no | |
| method | string | no | GET |
| body-type | string | no | json |
@@ -1303,6 +1566,7 @@ Examples:
| allow-insecure | boolean | no | false |
| skip-json-validation | boolean | no | false |
| template | string | yes | |
| options | map | no | |
| parameters | key (string) & value (string|array) | no | |
| subrequests | map of requests | no | |
@@ -1355,6 +1619,95 @@ When set to `true`, skips the JSON validation step. This is useful when the API
##### `template`
The template that will be used to display the data. It relies on Go's `html/template` package so it's recommended to go through [its documentation](https://pkg.go.dev/text/template) to understand how to do basic things such as conditionals, loops, etc. In addition, it also uses [tidwall's gjson](https://github.com/tidwall/gjson) package to parse the JSON data so it's worth going through its documentation if you want to use more advanced JSON selectors. You can view additional examples with explanations and function definitions [here](custom-api.md).
##### `options`
A map of options that will be passed to the template and can be used to modify the behavior of the widget.
<details>
<summary>View examples</summary>
<br>
Instead of defining options within the template and having to modify the template itself like such:
```yaml
- type: custom-api
template: |
{{ /* User configurable options */ }}
{{ $collapseAfter := 5 }}
{{ $showThumbnails := true }}
{{ $showFlairs := false }}
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ $collapseAfter }}">
{{ if $showThumbnails }}
<li>
<img src="{{ .JSON.String "thumbnail" }}" alt="thumbnail" />
</li>
{{ end }}
{{ if $showFlairs }}
<li>
<span class="flair">{{ .JSON.String "flair" }}</span>
</li>
{{ end }}
</ul>
```
You can use the `options` property to retrieve and define default values for these variables:
```yaml
- type: custom-api
template: |
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .Options.IntOr "collapse-after" 5 }}">
{{ if (.Options.BoolOr "show-thumbnails" true) }}
<li>
<img src="{{ .JSON.String "thumbnail" }}" alt="thumbnail" />
</li>
{{ end }}
{{ if (.Options.BoolOr "show-flairs" false) }}
<li>
<span class="flair">{{ .JSON.String "flair" }}</span>
</li>
{{ end }}
</ul>
```
This way, you can optionally specify the `collapse-after`, `show-thumbnails` and `show-flairs` properties in the widget configuration:
```yaml
- type: custom-api
options:
collapse-after: 5
show-thumbnails: true
show-flairs: false
```
Which means you can reuse the same template for multiple widgets with different options:
```yaml
# Note that `custom-widgets` isn't a special property, it's just used to define the reusable "anchor", see https://support.atlassian.com/bitbucket-cloud/docs/yaml-anchors/
custom-widgets:
- &example-widget
type: custom-api
template: |
{{ .Options.StringOr "custom-option" "not defined" }}
pages:
- name: Home
columns:
- size: full
widgets:
- <<: *example-widget
options:
custom-option: "Value 1"
- <<: *example-widget
options:
custom-option: "Value 2"
```
Currently, the available methods on the `.Options` object are: `StringOr`, `IntOr`, `BoolOr` and `FloatOr`.
</details>
##### `parameters`
A list of keys and values that will be sent to the custom-api as query paramters.
@@ -1510,6 +1863,44 @@ Otherwise, if set to `false` (which is the default) it'll be displayed as:
Greenville, United States
```
### Todo
A simple to-do list that allows you to add, edit and delete tasks. The tasks are stored in the browser's local storage.
Example:
```yaml
- type: to-do
```
Preview:
![](images/todo-widget-preview.png)
To reorder tasks, drag and drop them by grabbing the top side of the task:
![](images/reorder-todo-tasks-prevew.gif)
To delete a task, hover over it and click on the trash icon.
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| id | string | no | |
##### `id`
The ID of the todo list. If you want to have multiple todo lists, you must specify a different ID for each one. The ID is used to store the tasks in the browser's local storage. This means that if you have multiple todo lists with the same ID, they will share the same tasks.
#### Keyboard shortcuts
| Keys | Action | Condition |
| ---- | ------ | --------- |
| <kbd>Enter</kbd> | Add a task to the bottom of the list | When the "Add a task" field is focused |
| <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Add a task to the top of the list | When the "Add a task" field is focused |
| <kbd>Down Arrow</kbd> | Focus the last task that was added | When the "Add a task" field is focused |
| <kbd>Escape</kbd> | Focus the "Add a task" field | When a task is focused |
### Monitor
Display a list of sites and whether they are reachable (online) or not. This is determined by sending a GET request to the specified URL, if the response is 200 then the site is OK. The time it took to receive a response is also shown in milliseconds.
@@ -1535,7 +1926,6 @@ Example:
- title: Vaultwarden
url: https://vault.yourdomain.com
icon: /assets/vaultwarden-logo.png
```
Preview:
@@ -1573,9 +1963,11 @@ Properties for each site:
| check-url | string | no | |
| error-url | string | no | |
| icon | string | no | |
| timeout | string | no | 3s |
| allow-insecure | boolean | no | false |
| same-tab | boolean | no | false |
| alt-status-codes | array | no | |
| basic-auth | object | no | |
`title`
@@ -1583,7 +1975,7 @@ The title used to indicate the site.
`url`
The public facing URL of a monitored service, the user will be redirected here. If `check-url` is not specified, this is used as the status check.
The URL of the monitored service, which must be reachable by Glance, and will be used as the link to go to when clicking on the title. If `check-url` is not specified, this is used as the status check.
`check-url`
@@ -1595,17 +1987,11 @@ If the monitored service returns an error, the user will be redirected here. If
`icon`
Optional URL to an image which will be used as the icon for the site. Can be an external URL or internal via [server configured assets](#assets-path). You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix or [Dashboard Icons](https://github.com/walkxcode/dashboard-icons) via a `di:` prefix:
See [Icons](#icons) for more information on how to specify icons.
```yaml
icon: si:jellyfin
icon: si:gitea
icon: si:adguard
```
`timeout`
> [!WARNING]
>
> Simple Icons are loaded externally and are hosted on `cdn.jsdelivr.net`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
How long to wait for a response from the server before considering it unreachable. The value is a string and must be a number followed by one of s, m, h, d. Example: `5s` for 5 seconds, `1m` for 1 minute, etc.
`allow-insecure`
@@ -1624,6 +2010,16 @@ alt-status-codes:
- 403
```
`basic-auth`
HTTP Basic Authentication credentials for protected sites.
```yaml
basic-auth:
username: your-username
password: your-password
```
### Releases
Display a list of latest releases for specific repositories on Github, GitLab, Codeberg or Docker Hub.
@@ -1765,6 +2161,19 @@ Configuration of the containers is done via labels applied to each container:
glance.description: Movies & shows
```
Alternatively, you can also define the values within your `glance.yml` via the `containers` property, where the key is the container name and each value is the same as the labels but without the "glance." prefix:
```yaml
- type: docker-containers
containers:
container_name_1:
name: Container Name
description: Description of the container
url: https://container.domain.com
icon: si:container-icon
hide: false
```
For services with multiple containers you can specify a `glance.id` on the "main" container and `glance.parent` on each "child" container:
<details>
@@ -1816,28 +2225,87 @@ If any of the child containers are down, their status will propagate up to the p
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| hide-by-default | boolean | no | false |
| format-container-names | boolean | no | false |
| sock-path | string | no | /var/run/docker.sock |
| category | string | no | |
| running-only | boolean | no | false |
##### `hide-by-default`
Whether to hide the containers by default. If set to `true` you'll have to manually add a `glance.hide: false` label to each container you want to display. By default all containers will be shown and if you want to hide a specific container you can add a `glance.hide: true` label.
##### `format-container-names`
When set to `true`, automatically converts container names such as `container_name_1` into `Container Name 1`.
##### `sock-path`
The path to the Docker socket.
The path to the Docker socket. This can also be a [remote socket](https://docs.docker.com/engine/daemon/remote-access/) or proxied socket using something like [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy).
###### `category`
Filter to only the containers which have this category specified via the `glance.category` label. Useful if you want to have multiple containers widgets, each showing a different set of containers.
<details>
<summary>View example</summary>
<br>
```yaml
services:
jellyfin:
image: jellyfin/jellyfin:latest
labels:
glance.name: Jellyfin
glance.icon: si:jellyfin
glance.url: https://jellyfin.domain.com
glance.category: media
gitea:
image: gitea/gitea:latest
labels:
glance.name: Gitea
glance.icon: si:gitea
glance.url: https://gitea.domain.com
glance.category: dev-tools
vaultwarden:
image: vaultwarden/server:latest
labels:
glance.name: Vaultwarden
glance.icon: si:vaultwarden
glance.url: https://vaultwarden.domain.com
glance.category: dev-tools
```
Then you can use the `category` property to filter the containers:
```yaml
- type: docker-containers
title: Dev tool containers
category: dev-tools
- type: docker-containers
title: Media containers
category: media
```
</details>
##### `running-only`
Whether to only show running containers. If set to `true` only containers that are currently running will be displayed. If set to `false` all containers will be displayed regardless of their state.
#### Labels
| Name | Description |
| ---- | ----------- |
| glance.name | The name displayed in the UI. If not specified, the name of the container will be used. |
| glance.icon | The icon displayed in the UI. Can be an external URL or an icon prefixed with si:, sh: or di: like with the bookmarks and monitor widgets |
| glance.icon | See [Icons](#icons) for more information on how to specify icons |
| glance.url | The URL that the user will be redirected to when clicking on the container. |
| glance.same-tab | Whether to open the link in the same or a new tab. Default is `false`. |
| glance.description | A short description displayed in the UI. Default is empty. |
| glance.hide | Whether to hide the container. If set to `true` the container will not be displayed. Defaults to `false`. |
| glance.id | The custom ID of the container. Used to group containers under a single parent. |
| glance.parent | The ID of the parent container. Used to group containers under a single parent. |
| glance.category | The category of the container. Used to filter containers by category. |
### DNS Stats
Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home or Pi-hole.
Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home, Pi-hole, or Technitium.
Example:
@@ -1855,7 +2323,7 @@ Preview:
> [!NOTE]
>
> When using AdGuard Home the 3rd statistic on top will be the average latency and when using Pi-hole it will be the total number of blocked domains from all adlists.
> When using AdGuard Home the 3rd statistic on top will be the average latency and when using Pi-hole or Technitium it will be the total number of blocked domains from all adlists.
#### Properties
@@ -1872,7 +2340,7 @@ Preview:
| hour-format | string | no | 12h |
##### `service`
Either `adguard`, or `pihole` (major version 5 and below) or `pihole-v6` (major version 6 and above).
Either `adguard`, `technitium`, or `pihole` (major version 5 and below) or `pihole-v6` (major version 6 and above).
##### `allow-insecure`
Whether to allow invalid/self-signed certificates when making the request to the service.
@@ -1886,10 +2354,12 @@ Only required when using AdGuard Home. The username used to log into the admin d
##### `password`
Required when using AdGuard Home, where the password is the one used to log into the admin dashboard.
Also requried when using Pi-hole major version 6 and above, where the password is the one used to log into the admin dashboard or the application password, which can be found in `Settings -> Web Interface / API -> Configure app password`.
Also required when using Pi-hole major version 6 and above, where the password is the one used to log into the admin dashboard or the application password, which can be found in `Settings -> Web Interface / API -> Configure app password`.
##### `token`
Only required when using Pi-hole major version 5 or earlier. The API token which can be found in `Settings -> API -> Show API token`.
Required when using Pi-hole major version 5 or earlier. The API token which can be found in `Settings -> API -> Show API token`.
Also required when using Technitium, an API token can be generated at `Administration -> Sessions -> Create Token`.
##### `hide-graph`
Whether to hide the graph showing the number of queries over time.
@@ -2132,6 +2602,7 @@ An array of groups which can optionally have a title and a custom color.
| ---- | ---- | -------- | ------- |
| title | string | yes | |
| url | string | yes | |
| description | string | no | |
| icon | string | no | |
| same-tab | boolean | no | false |
| hide-arrow | boolean | no | false |
@@ -2139,17 +2610,7 @@ An array of groups which can optionally have a title and a custom color.
`icon`
URL pointing to an image. You can also directly use [Simple Icons](https://simpleicons.org/) via a `si:` prefix or [Dashboard Icons](https://github.com/walkxcode/dashboard-icons) via a `di:` prefix:
```yaml
icon: si:gmail
icon: si:youtube
icon: si:reddit
```
> [!WARNING]
>
> Simple Icons are loaded externally and are hosted on `cdn.jsdelivr.net`, if you do not wish to depend on a 3rd party you are free to download the icons individually and host them locally.
See [Icons](#icons) for more information on how to specify icons.
`same-tab`
+64 -5
View File
@@ -238,7 +238,7 @@ Output:
<div>90</div>
```
Other operations include `add`, `mul`, and `div`.
Other operations include `add`, `mul`, `div` and `mod`.
<hr>
@@ -358,6 +358,52 @@ Output:
<p>John</p>
```
<hr>
In some instances, you may need to make two consecutive API calls, where you use the result of the first call in the second call. To achieve this, you can make additional HTTP requests from within the template itself using the following syntax:
```yaml
- type: custom-api
url: https://api.example.com/get-id-of-something
template: |
{{ $theID := .JSON.String "id" }}
{{
$something := newRequest (concat "https://api.example.com/something/" $theID)
| withParameter "key" "value"
| withHeader "Authorization" "Bearer token"
| getResponse
}}
{{ $something.JSON.String "title" }}
```
Here, `$theID` gets retrieved from the result of the first API call and used in the second API call. The `newRequest` function creates a new request, and the `getResponse` function executes it. You can also use `withParameter` and `withHeader` to optionally add parameters and headers to the request.
If you need to make a request to a URL that requires dynamic parameters, you can omit the `url` property in the YAML and run the request entirely from within the template itself:
```yaml
- type: custom-api
title: Events from the last 24h
template: |
{{
$events := newRequest "https://api.example.com/events"
| withParameter "after" (offsetNow "-24h" | formatTime "rfc3339")
| getResponse
}}
{{ if eq $events.Response.StatusCode 200 }}
{{ range $events.JSON.Array "events" }}
<div>{{ .String "title" }}</div>
<div {{ .String "date" | parseTime "rfc3339" | toRelativeTime }}></div>
{{ end }}
{{ else }}
<p>Failed to fetch data: {{ $events.Response.Status }}</p>
{{ end }}
```
*Note that you need to manually check for the correct status code.*
## Functions
The following functions are available on the `JSON` object:
@@ -369,6 +415,14 @@ The following functions are available on the `JSON` object:
- `Array(key string) []JSON`: Returns the value of the key as an array of `JSON` objects.
- `Exists(key string) bool`: Returns true if the key exists in the JSON object.
The following functions are available on the `Options` object:
- `StringOr(key string, default string) string`: Returns the value of the key as a string, or the default value if the key does not exist.
- `IntOr(key string, default int) int`: Returns the value of the key as an integer, or the default value if the key does not exist.
- `FloatOr(key string, default float) float`: Returns the value of the key as a float, or the default value if the key does not exist.
- `BoolOr(key string, default bool) bool`: Returns the value of the key as a boolean, or the default value if the key does not exist.
- `JSON(key string) JSON`: Returns the value of the key as a stringified `JSON` object, or throws an error if the key does not exist.
The following helper functions provided by Glance are available:
- `toFloat(i int) float`: Converts an integer to a float.
@@ -378,12 +432,14 @@ The following helper functions provided by Glance are available:
- `offsetNow(offset string) time.Time`: Returns the current time with an offset. The offset can be positive or negative and must be in the format "3h" "-1h" or "2h30m10s".
- `duration(str string) time.Duration`: Parses a string such as `1h`, `24h`, `5h30m`, etc into a `time.Duration`.
- `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly".
- `formatTime(layout string, s string) time.Time`: Formats a `time.Time` into a string. The layout uses the same format as `parseTime`.
- `parseLocalTime(layout string, s string) time.Time`: Same as the above, except in the absence of a timezone, it will use the local timezone instead of UTC.
- `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`.
- `add(a, b float) float`: Adds two numbers.
- `sub(a, b float) float`: Subtracts two numbers.
- `mul(a, b float) float`: Multiplies two numbers.
- `div(a, b float) float`: Divides two numbers.
- `mod(a, b int) int`: Remainder after dividing a by b (a % b).
- `formatApproxNumber(n int) string`: Formats a number to be more human-readable, e.g. 1000 -> 1k.
- `formatNumber(n float|int) string`: Formats a number with commas, e.g. 1000 -> 1,000.
- `trimPrefix(prefix string, str string) string`: Trims the prefix from a string.
@@ -399,17 +455,20 @@ The following helper functions provided by Glance are available:
- `sortByTime(key string, layout string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a time key in either ascending or descending order. The format must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants).
- `concat(strings ...string) string`: Concatenates multiple strings together.
- `unique(key string, arr []JSON) []JSON`: Returns a unique array of JSON objects based on the given key.
- `percentChange(current float, previous float) float`: Calculates the percentage change between two numbers.
- `startOfDay(t time.Time) time.Time`: Returns the start of the day for a given time.
- `endOfDay(t time.Time) time.Time`: Returns the end of the day for a given time.
The following helper functions provided by Go's `text/template` are available:
- `eq(a, b any) bool`: Compares two values for equality.
- `ne(a, b any) bool`: Compares two values for inequality.
- `lt(a, b any) bool`: Compares two values for less than.
- `lte(a, b any) bool`: Compares two values for less than or equal to.
- `le(a, b any) bool`: Compares two values for less than or equal to.
- `gt(a, b any) bool`: Compares two values for greater than.
- `gte(a, b any) bool`: Compares two values for greater than or equal to.
- `and(a, b bool) bool`: Returns true if both values are true.
- `or(a, b bool) bool`: Returns true if either value is true.
- `ge(a, b any) bool`: Compares two values for greater than or equal to.
- `and(args ...bool) bool`: Returns true if **all** arguments are true; accepts two or more boolean values.
- `or(args ...bool) bool`: Returns true if **any** argument is true; accepts two or more boolean values.
- `not(a bool) bool`: Returns the opposite of the value.
- `index(a any, b int) any`: Returns the value at the specified index of an array.
- `len(a any) int`: Returns the length of an array.
+74 -100
View File
@@ -1,105 +1,79 @@
# --- 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:
- name: Home
# Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look
# hide-desktop-navigation: true
columns:
- size: small
widgets:
- type: calendar
first-day-of-week: monday
- name: Home
# Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look
# hide-desktop-navigation: true
columns:
- size: small
widgets:
- type: calendar
first-day-of-week: monday
- type: group
widgets:
- type: hacker-news
- size: full
widgets:
- type: rss
limit: 10
collapse-after: 3
cache: 12h
feeds:
- url: https://www.techmeme.com/feed.xml
title: TechMeme
- size: small
widgets:
- type: markets
markets:
- symbol: AMZN
name: Amazon.com, Inc.
- type: weather
location: Houston, US
units: imperial
hour-format: 12h
# Optionally hide the location from being displayed in the widget
# hide-location: true
- 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
hide-location: true
show-area-name: false
- type: group
widgets:
- type: reddit
subreddit: technology
show-thumbnails: true
- type: reddit
subreddit: selfhosted
show-thumbnails: true
- type: twitch-channels
channels:
- theprimeagen
- j_blow
- piratesoftware
- cohhcarnage
- christitustech
- EJ_SA
# Add more pages here:
# - name: Your page name
# columns:
# - size: small
# widgets:
# # Add widgets here
- size: full
widgets:
- type: group
widgets:
- type: hacker-news
- type: lobsters
# - size: full
# widgets:
# # Add widgets here
- 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 # alternatively "imperial"
hour-format: 12h # alternatively "24h"
# Optionally hide the location from being displayed in the widget
# hide-location: true
- 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
# Without authentication the Github API allows for up to 60 requests per hour. You can create a
# read-only token from your Github account settings and use it here to increase the limit.
# token: ...
repositories:
- glanceapp/glance
- go-gitea/gitea
- immich-app/immich
- syncthing/syncthing
# Add more pages here:
# - name: Your page name
# columns:
# - size: small
# widgets:
# # Add widgets here
# - size: full
# widgets:
# # Add widgets here
# - size: small
# widgets:
# # Add widgets here
# - size: small
# widgets:
# # Add widgets here
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+80 -80
View File
@@ -86,92 +86,92 @@ Pull requests with your page configurations are welcome!
<summary>View config (requires Glance <code>v0.6.0</code> or higher)</summary>
```yaml
- name: Markets
columns:
- size: small
widgets:
- type: markets
title: Indices
markets:
- symbol: SPY
name: S&P 500
- symbol: DX-Y.NYB
name: Dollar Index
- name: Markets
columns:
- size: small
widgets:
- type: markets
title: Indices
markets:
- symbol: SPY
name: S&P 500
- symbol: DX-Y.NYB
name: Dollar Index
- type: markets
title: Crypto
markets:
- symbol: BTC-USD
name: Bitcoin
- symbol: ETH-USD
name: Ethereum
- type: markets
title: Crypto
markets:
- symbol: BTC-USD
name: Bitcoin
- symbol: ETH-USD
name: Ethereum
- type: markets
title: Stocks
sort-by: absolute-change
markets:
- symbol: NVDA
name: NVIDIA
- symbol: AAPL
name: Apple
- symbol: MSFT
name: Microsoft
- symbol: GOOGL
name: Google
- symbol: AMD
name: AMD
- symbol: RDDT
name: Reddit
- symbol: AMZN
name: Amazon
- symbol: TSLA
name: Tesla
- symbol: INTC
name: Intel
- symbol: META
name: Meta
- type: markets
title: Stocks
sort-by: absolute-change
markets:
- symbol: NVDA
name: NVIDIA
- symbol: AAPL
name: Apple
- symbol: MSFT
name: Microsoft
- symbol: GOOGL
name: Google
- symbol: AMD
name: AMD
- symbol: RDDT
name: Reddit
- symbol: AMZN
name: Amazon
- symbol: TSLA
name: Tesla
- symbol: INTC
name: Intel
- symbol: META
name: Meta
- size: full
widgets:
- type: rss
title: News
style: horizontal-cards
feeds:
- url: https://feeds.bloomberg.com/markets/news.rss
title: Bloomberg
- url: https://moxie.foxbusiness.com/google-publisher/markets.xml
title: Fox Business
- url: https://moxie.foxbusiness.com/google-publisher/technology.xml
title: Fox Business
- size: full
widgets:
- type: rss
title: News
style: horizontal-cards
feeds:
- url: https://feeds.bloomberg.com/markets/news.rss
title: Bloomberg
- url: https://moxie.foxbusiness.com/google-publisher/markets.xml
title: Fox Business
- url: https://moxie.foxbusiness.com/google-publisher/technology.xml
title: Fox Business
- type: group
widgets:
- type: reddit
show-thumbnails: true
subreddit: technology
- type: reddit
show-thumbnails: true
subreddit: wallstreetbets
- type: group
widgets:
- type: reddit
show-thumbnails: true
subreddit: technology
- type: reddit
show-thumbnails: true
subreddit: wallstreetbets
- type: videos
style: grid-cards
collapse-after-rows: 3
channels:
- UCvSXMi2LebwJEM1s4bz5IBA # New Money
- UCV6KDgJskWaEckne5aPA0aQ # Graham Stephan
- UCAzhpt9DmG6PnHXjmJTvRGQ # Federal Reserve
- type: videos
style: grid-cards
collapse-after-rows: 3
channels:
- UCvSXMi2LebwJEM1s4bz5IBA # New Money
- UCV6KDgJskWaEckne5aPA0aQ # Graham Stephan
- UCAzhpt9DmG6PnHXjmJTvRGQ # Federal Reserve
- size: small
widgets:
- type: rss
title: News
limit: 30
collapse-after: 13
feeds:
- url: https://www.ft.com/technology?format=rss
title: Financial Times
- url: https://feeds.a.dj.com/rss/RSSMarketsMain.xml
title: Wall Street Journal
- size: small
widgets:
- type: rss
title: News
limit: 30
collapse-after: 13
feeds:
- url: https://www.ft.com/technology?format=rss
title: Financial Times
- url: https://feeds.a.dj.com/rss/RSSMarketsMain.xml
title: Wall Street Journal
```
</details>
+22
View File
@@ -93,6 +93,28 @@ theme:
negative-color: 0 100 67
```
### Shades of Purple
![screenshot](images/themes/shades-of-purple.png)
```yaml
theme:
background-color: 243 33 25
contrast-multiplier: 1.2
primary-color: 50 100 49
positive-color: 98 82 71
negative-color: 12 77 52
```
### Neon Pink
![screenshot](images/themes/neon-pink.png)
```yaml
theme:
background-color: 240 27 11
contrast-multiplier: 1.5
primary-color: 321 100 71
positive-color: 165 78 51
negative-color: 360 100 71
```
## Light
### Catppuccin Latte
+8 -7
View File
@@ -1,20 +1,21 @@
module github.com/glanceapp/glance
go 1.24.2
go 1.24.3
require (
github.com/fsnotify/fsnotify v1.9.0
github.com/mmcdole/gofeed v1.3.0
github.com/shirou/gopsutil/v4 v4.25.3
github.com/shirou/gopsutil/v4 v4.25.4
github.com/tidwall/gjson v1.18.0
golang.org/x/text v0.24.0
golang.org/x/crypto v0.38.0
golang.org/x/text v0.25.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/PuerkitoBio/goquery v1.10.2 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
@@ -27,6 +28,6 @@ require (
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.33.0 // indirect
)
+15 -31
View File
@@ -1,29 +1,23 @@
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
@@ -39,10 +33,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw=
github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
@@ -54,12 +46,8 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -71,6 +59,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -85,10 +75,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -110,10 +98,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -132,10 +118,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
File diff suppressed because it is too large Load Diff
+250
View File
@@ -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},
},
}
+333
View File
@@ -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})
}
+913
View File
@@ -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())
}
+343
View File
@@ -0,0 +1,343 @@
package glance
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log"
mathrand "math/rand/v2"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const AUTH_SESSION_COOKIE_NAME = "session_token"
const AUTH_RATE_LIMIT_WINDOW = 5 * time.Minute
const AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5
const AUTH_TOKEN_SECRET_LENGTH = 32
const AUTH_USERNAME_HASH_LENGTH = 32
const AUTH_SECRET_KEY_LENGTH = AUTH_TOKEN_SECRET_LENGTH + AUTH_USERNAME_HASH_LENGTH
const AUTH_TIMESTAMP_LENGTH = 4 // uint32
const AUTH_TOKEN_DATA_LENGTH = AUTH_USERNAME_HASH_LENGTH + AUTH_TIMESTAMP_LENGTH
// How long the token will be valid for
const AUTH_TOKEN_VALID_PERIOD = 14 * 24 * time.Hour // 14 days
// How long the token has left before it should be regenerated
const AUTH_TOKEN_REGEN_BEFORE = 7 * 24 * time.Hour // 7 days
var loginPageTemplate = mustParseTemplate("login.html", "document.html", "footer.html")
type doWhenUnauthorized int
const (
redirectToLogin doWhenUnauthorized = iota
showUnauthorizedJSON
)
type failedAuthAttempt struct {
attempts int
first time.Time
}
func generateSessionToken(username string, secret []byte, now time.Time) (string, error) {
if len(secret) != AUTH_SECRET_KEY_LENGTH {
return "", fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
}
usernameHash, err := computeUsernameHash(username, secret)
if err != nil {
return "", err
}
data := make([]byte, AUTH_TOKEN_DATA_LENGTH)
copy(data, usernameHash)
expires := now.Add(AUTH_TOKEN_VALID_PERIOD).Unix()
binary.LittleEndian.PutUint32(data[AUTH_USERNAME_HASH_LENGTH:], uint32(expires))
h := hmac.New(sha256.New, secret[0:AUTH_TOKEN_SECRET_LENGTH])
h.Write(data)
signature := h.Sum(nil)
encodedToken := base64.StdEncoding.EncodeToString(append(data, signature...))
// encodedToken ends up being (hashed username + expiration timestamp + signature) encoded as base64
return encodedToken, nil
}
func computeUsernameHash(username string, secret []byte) ([]byte, error) {
if len(secret) != AUTH_SECRET_KEY_LENGTH {
return nil, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
}
h := hmac.New(sha256.New, secret[AUTH_TOKEN_SECRET_LENGTH:])
h.Write([]byte(username))
return h.Sum(nil), nil
}
func verifySessionToken(token string, secretBytes []byte, now time.Time) ([]byte, bool, error) {
tokenBytes, err := base64.StdEncoding.DecodeString(token)
if err != nil {
return nil, false, err
}
if len(tokenBytes) != AUTH_TOKEN_DATA_LENGTH+32 {
return nil, false, fmt.Errorf("token length is invalid")
}
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
return nil, false, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
}
usernameHashBytes := tokenBytes[0:AUTH_USERNAME_HASH_LENGTH]
timestampBytes := tokenBytes[AUTH_USERNAME_HASH_LENGTH : AUTH_USERNAME_HASH_LENGTH+AUTH_TIMESTAMP_LENGTH]
providedSignatureBytes := tokenBytes[AUTH_TOKEN_DATA_LENGTH:]
h := hmac.New(sha256.New, secretBytes[0:32])
h.Write(tokenBytes[0:AUTH_TOKEN_DATA_LENGTH])
expectedSignatureBytes := h.Sum(nil)
if !hmac.Equal(expectedSignatureBytes, providedSignatureBytes) {
return nil, false, fmt.Errorf("signature does not match")
}
expiresTimestamp := int64(binary.LittleEndian.Uint32(timestampBytes))
if now.Unix() > expiresTimestamp {
return nil, false, fmt.Errorf("token has expired")
}
return usernameHashBytes,
// True if the token should be regenerated
time.Unix(expiresTimestamp, 0).Add(-AUTH_TOKEN_REGEN_BEFORE).Before(now),
nil
}
func makeAuthSecretKey(length int) (string, error) {
key := make([]byte, length)
_, err := rand.Read(key)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(key), nil
}
func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
return
}
waitOnFailure := 1*time.Second - time.Duration(mathrand.IntN(500))*time.Millisecond
ip := a.addressOfRequest(r)
a.authAttemptsMu.Lock()
exceededRateLimit, retryAfter := func() (bool, int) {
attempt, exists := a.failedAuthAttempts[ip]
if !exists {
a.failedAuthAttempts[ip] = &failedAuthAttempt{
attempts: 1,
first: time.Now(),
}
return false, 0
}
elapsed := time.Since(attempt.first)
if elapsed < AUTH_RATE_LIMIT_WINDOW && attempt.attempts >= AUTH_RATE_LIMIT_MAX_ATTEMPTS {
return true, max(1, int(AUTH_RATE_LIMIT_WINDOW.Seconds()-elapsed.Seconds()))
}
attempt.attempts++
return false, 0
}()
if exceededRateLimit {
a.authAttemptsMu.Unlock()
time.Sleep(waitOnFailure)
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
w.WriteHeader(http.StatusTooManyRequests)
return
} else {
// Clean up old failed attempts
for ipOfAttempt := range a.failedAuthAttempts {
if time.Since(a.failedAuthAttempts[ipOfAttempt].first) > AUTH_RATE_LIMIT_WINDOW {
delete(a.failedAuthAttempts, ipOfAttempt)
}
}
a.authAttemptsMu.Unlock()
}
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
var creds struct {
Username string `json:"username"`
Password string `json:"password"`
}
err = json.Unmarshal(body, &creds)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
logAuthFailure := func() {
log.Printf(
"Failed login attempt for user '%s' from %s",
creds.Username, ip,
)
}
if len(creds.Username) == 0 || len(creds.Password) == 0 {
time.Sleep(waitOnFailure)
w.WriteHeader(http.StatusUnauthorized)
return
}
if len(creds.Username) > 50 || len(creds.Password) > 100 {
logAuthFailure()
time.Sleep(waitOnFailure)
w.WriteHeader(http.StatusUnauthorized)
return
}
u, exists := a.Config.Auth.Users[creds.Username]
if !exists {
logAuthFailure()
time.Sleep(waitOnFailure)
w.WriteHeader(http.StatusUnauthorized)
return
}
if err := bcrypt.CompareHashAndPassword(u.PasswordHash, []byte(creds.Password)); err != nil {
logAuthFailure()
time.Sleep(waitOnFailure)
w.WriteHeader(http.StatusUnauthorized)
return
}
token, err := generateSessionToken(creds.Username, a.authSecretKey, time.Now())
if err != nil {
log.Printf("Could not compute session token during login attempt: %v", err)
time.Sleep(waitOnFailure)
w.WriteHeader(http.StatusUnauthorized)
return
}
a.setAuthSessionCookie(w, r, token, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
a.authAttemptsMu.Lock()
delete(a.failedAuthAttempts, ip)
a.authAttemptsMu.Unlock()
w.WriteHeader(http.StatusOK)
}
func (a *application) isAuthorized(w http.ResponseWriter, r *http.Request) bool {
if !a.RequiresAuth {
return true
}
token, err := r.Cookie(AUTH_SESSION_COOKIE_NAME)
if err != nil || token.Value == "" {
return false
}
usernameHash, shouldRegenerate, err := verifySessionToken(token.Value, a.authSecretKey, time.Now())
if err != nil {
return false
}
username, exists := a.usernameHashToUsername[string(usernameHash)]
if !exists {
return false
}
_, exists = a.Config.Auth.Users[username]
if !exists {
return false
}
if shouldRegenerate {
newToken, err := generateSessionToken(username, a.authSecretKey, time.Now())
if err != nil {
log.Printf("Could not compute session token during regeneration: %v", err)
return false
}
a.setAuthSessionCookie(w, r, newToken, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
}
return true
}
// Handles sending the appropriate response for an unauthorized request and returns true if the request was unauthorized
func (a *application) handleUnauthorizedResponse(w http.ResponseWriter, r *http.Request, fallback doWhenUnauthorized) bool {
if a.isAuthorized(w, r) {
return false
}
switch fallback {
case redirectToLogin:
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
case showUnauthorizedJSON:
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": "Unauthorized"}`))
}
return true
}
// Maybe this should be a POST request instead?
func (a *application) handleLogoutRequest(w http.ResponseWriter, r *http.Request) {
a.setAuthSessionCookie(w, r, "", time.Now().Add(-1*time.Hour))
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
}
func (a *application) setAuthSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: AUTH_SESSION_COOKIE_NAME,
Value: token,
Expires: expires,
Secure: strings.ToLower(r.Header.Get("X-Forwarded-Proto")) == "https",
Path: a.Config.Server.BaseURL + "/",
SameSite: http.SameSiteLaxMode,
HttpOnly: true,
})
}
func (a *application) handleLoginPageRequest(w http.ResponseWriter, r *http.Request) {
if a.isAuthorized(w, r) {
http.Redirect(w, r, a.Config.Server.BaseURL+"/", http.StatusSeeOther)
return
}
data := &templateData{
App: a,
}
a.populateTemplateRequestData(&data.Request, r)
var responseBytes bytes.Buffer
err := loginPageTemplate.Execute(&responseBytes, data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
w.Write(responseBytes.Bytes())
}
+85
View File
@@ -0,0 +1,85 @@
package glance
import (
"bytes"
"encoding/base64"
"testing"
"time"
)
func TestAuthTokenGenerationAndVerification(t *testing.T) {
secret, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH)
if err != nil {
t.Fatalf("Failed to generate secret key: %v", err)
}
secretBytes, err := base64.StdEncoding.DecodeString(secret)
if err != nil {
t.Fatalf("Failed to decode secret key: %v", err)
}
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
t.Fatalf("Secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
}
now := time.Now()
username := "admin"
token, err := generateSessionToken(username, secretBytes, now)
if err != nil {
t.Fatalf("Failed to generate session token: %v", err)
}
usernameHashBytes, shouldRegen, err := verifySessionToken(token, secretBytes, now)
if err != nil {
t.Fatalf("Failed to verify session token: %v", err)
}
if shouldRegen {
t.Fatal("Token should not need to be regenerated immediately after generation")
}
computedUsernameHash, err := computeUsernameHash(username, secretBytes)
if err != nil {
t.Fatalf("Failed to compute username hash: %v", err)
}
if !bytes.Equal(usernameHashBytes, computedUsernameHash) {
t.Fatal("Username hash does not match the expected value")
}
// Test token regeneration
timeRightAfterRegenPeriod := now.Add(AUTH_TOKEN_VALID_PERIOD - AUTH_TOKEN_REGEN_BEFORE + 2*time.Second)
_, shouldRegen, err = verifySessionToken(token, secretBytes, timeRightAfterRegenPeriod)
if err != nil {
t.Fatalf("Token verification should not fail during regeneration period, err: %v", err)
}
if !shouldRegen {
t.Fatal("Token should have been marked for regeneration")
}
// Test token expiration
_, _, err = verifySessionToken(token, secretBytes, now.Add(AUTH_TOKEN_VALID_PERIOD+2*time.Second))
if err == nil {
t.Fatal("Expected token verification to fail after token expiration")
}
// Test tampered token
decodedToken, err := base64.StdEncoding.DecodeString(token)
if err != nil {
t.Fatalf("Failed to decode token: %v", err)
}
// If any of the bytes are off by 1, the token should be considered invalid
for i := range len(decodedToken) {
tampered := make([]byte, len(decodedToken))
copy(tampered, decodedToken)
tampered[i] += 1
_, _, err = verifySessionToken(base64.StdEncoding.EncodeToString(tampered), secretBytes, now)
if err == nil {
t.Fatalf("Expected token verification to fail for tampered token at index %d", i)
}
}
}
+18 -7
View File
@@ -20,6 +20,8 @@ const (
cliIntentDiagnose
cliIntentSensorsPrint
cliIntentMountpointInfo
cliIntentSecretMake
cliIntentPasswordHash
)
type cliOptions struct {
@@ -46,12 +48,15 @@ func parseCliOptions() (*cliOptions, error) {
flags.PrintDefaults()
fmt.Println("\nCommands:")
fmt.Println(" config:validate Validate the config file")
fmt.Println(" config:print Print the parsed config file with embedded includes")
fmt.Println(" sensors:print List all sensors")
fmt.Println(" mountpoint:info Print information about a given mountpoint path")
fmt.Println(" diagnose Run diagnostic checks")
fmt.Println(" config:validate Validate the config file")
fmt.Println(" config:print Print the parsed config file with embedded includes")
fmt.Println(" password:hash <pwd> Hash a password")
fmt.Println(" secret:make Generate a random secret key")
fmt.Println(" sensors:print List all sensors")
fmt.Println(" mountpoint:info Print information about a given mountpoint path")
fmt.Println(" diagnose Run diagnostic checks")
}
configPath := flags.String("config", "glance.yml", "Set config path")
err := flags.Parse(os.Args[1:])
if err != nil {
@@ -73,6 +78,14 @@ func parseCliOptions() (*cliOptions, error) {
intent = cliIntentSensorsPrint
} else if args[0] == "diagnose" {
intent = cliIntentDiagnose
} else if args[0] == "secret:make" {
intent = cliIntentSecretMake
} else {
return nil, unknownCommandErr
}
} else if len(args) == 2 {
if args[0] == "password:hash" {
intent = cliIntentPasswordHash
} else {
return nil, unknownCommandErr
}
@@ -106,8 +119,6 @@ func cliSensorsPrint() int {
fmt.Printf("Failed to retrieve sensor information: %v\n", err)
return 1
}
return 1
}
if len(tempSensors) == 0 {
+55 -41
View File
@@ -3,6 +3,7 @@ package glance
import (
"crypto/tls"
"fmt"
"html/template"
"net/http"
"net/url"
"regexp"
@@ -13,7 +14,7 @@ import (
"gopkg.in/yaml.v3"
)
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?([\d\.]+)(?: |,)+([\d\.]+)%?(?: |,)+([\d\.]+)%?\)?$`)
const (
hslHueMax = 360
@@ -22,13 +23,27 @@ const (
)
type hslColorField struct {
Hue uint16
Saturation uint8
Lightness uint8
H float64
S float64
L float64
}
func (c *hslColorField) String() string {
return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness)
return fmt.Sprintf("hsl(%.1f, %.1f%%, %.1f%%)", c.H, c.S, c.L)
}
func (c *hslColorField) ToHex() string {
return hslToHex(c.H, c.S, c.L)
}
func (c1 *hslColorField) SameAs(c2 *hslColorField) bool {
if c1 == nil && c2 == nil {
return true
}
if c1 == nil || c2 == nil {
return false
}
return c1.H == c2.H && c1.S == c2.S && c1.L == c2.L
}
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
@@ -44,7 +59,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
return fmt.Errorf("invalid HSL color format: %s", value)
}
hue, err := strconv.ParseUint(matches[1], 10, 16)
hue, err := strconv.ParseFloat(matches[1], 64)
if err != nil {
return err
}
@@ -53,7 +68,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax)
}
saturation, err := strconv.ParseUint(matches[2], 10, 8)
saturation, err := strconv.ParseFloat(matches[2], 64)
if err != nil {
return err
}
@@ -62,7 +77,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax)
}
lightness, err := strconv.ParseUint(matches[3], 10, 8)
lightness, err := strconv.ParseFloat(matches[3], 64)
if err != nil {
return err
}
@@ -71,9 +86,9 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
}
c.Hue = uint16(hue)
c.Saturation = uint8(saturation)
c.Lightness = uint8(lightness)
c.H = hue
c.S = saturation
c.L = lightness
return nil
}
@@ -115,49 +130,48 @@ func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
}
type customIconField struct {
URL string
IsFlatIcon bool
// TODO: along with whether the icon is flat, we also need to know
// whether the icon is black or white by default in order to properly
// invert the color based on the theme being light or dark
URL template.URL
AutoInvert bool
}
func newCustomIconField(value string) customIconField {
const autoInvertPrefix = "auto-invert "
field := customIconField{}
if strings.HasPrefix(value, autoInvertPrefix) {
field.AutoInvert = true
value = strings.TrimPrefix(value, autoInvertPrefix)
}
prefix, icon, found := strings.Cut(value, ":")
if !found {
field.URL = value
field.URL = template.URL(value)
return field
}
basename, ext, found := strings.Cut(icon, ".")
if !found {
ext = "svg"
basename = icon
}
if ext != "svg" && ext != "png" {
ext = "svg"
}
switch prefix {
case "si":
field.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg"
field.IsFlatIcon = true
case "di", "sh":
// syntax: di:<icon_name>[.svg|.png]
// syntax: sh:<icon_name>[.svg|.png]
// if the icon name is specified without extension, it is assumed to be wanting the SVG icon
// otherwise, specify the extension of either .svg or .png to use either of the CDN offerings
// any other extension will be interpreted as .svg
basename, ext, found := strings.Cut(icon, ".")
if !found {
ext = "svg"
basename = icon
}
if ext != "svg" && ext != "png" {
ext = "svg"
}
if prefix == "di" {
field.URL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext
} else {
field.URL = "https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext
}
field.AutoInvert = true
field.URL = template.URL("https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + basename + ".svg")
case "di":
field.URL = template.URL("https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext)
case "mdi":
field.AutoInvert = true
field.URL = template.URL("https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/" + basename + ".svg")
case "sh":
field.URL = template.URL("https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext)
default:
field.URL = value
field.URL = template.URL(value)
}
return field
+305 -57
View File
@@ -2,8 +2,10 @@ package glance
import (
"bytes"
"errors"
"fmt"
"html/template"
"iter"
"log"
"maps"
"os"
@@ -17,50 +19,77 @@ import (
"gopkg.in/yaml.v3"
)
const CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT = 20
const (
configVarTypeEnv = "env"
configVarTypeSecret = "secret"
configVarTypeFileFromEnv = "readFileFromEnv"
)
type config struct {
Server struct {
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
AssetsPath string `yaml:"assets-path"`
BaseURL string `yaml:"base-url"`
StartedAt time.Time `yaml:"-"` // used in custom css file
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
Proxied bool `yaml:"proxied"`
AssetsPath string `yaml:"assets-path"`
BaseURL string `yaml:"base-url"`
} `yaml:"server"`
Auth struct {
SecretKey string `yaml:"secret-key"`
Users map[string]*user `yaml:"users"`
} `yaml:"auth"`
Document struct {
Head template.HTML `yaml:"head"`
} `yaml:"document"`
Theme struct {
BackgroundColor *hslColorField `yaml:"background-color"`
PrimaryColor *hslColorField `yaml:"primary-color"`
PositiveColor *hslColorField `yaml:"positive-color"`
NegativeColor *hslColorField `yaml:"negative-color"`
Light bool `yaml:"light"`
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
CustomCSSFile string `yaml:"custom-css-file"`
themeProperties `yaml:",inline"`
CustomCSSFile string `yaml:"custom-css-file"`
DisablePicker bool `yaml:"disable-picker"`
Presets orderedYAMLMap[string, *themeProperties] `yaml:"presets"`
} `yaml:"theme"`
Branding struct {
HideFooter bool `yaml:"hide-footer"`
CustomFooter template.HTML `yaml:"custom-footer"`
LogoText string `yaml:"logo-text"`
LogoURL string `yaml:"logo-url"`
FaviconURL string `yaml:"favicon-url"`
HideFooter bool `yaml:"hide-footer"`
CustomFooter template.HTML `yaml:"custom-footer"`
LogoText string `yaml:"logo-text"`
LogoURL string `yaml:"logo-url"`
FaviconURL string `yaml:"favicon-url"`
FaviconType string `yaml:"-"`
AppName string `yaml:"app-name"`
AppIconURL string `yaml:"app-icon-url"`
AppBackgroundColor string `yaml:"app-background-color"`
} `yaml:"branding"`
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"`
}
type user struct {
Password string `yaml:"password"`
PasswordHashString string `yaml:"password-hash"`
PasswordHash []byte `yaml:"-"`
}
type page struct {
Title string `yaml:"name"`
Slug string `yaml:"slug"`
Width string `yaml:"width"`
ShowMobileHeader bool `yaml:"show-mobile-header"`
ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"`
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
CenterVertically bool `yaml:"center-vertically"`
Columns []struct {
Title string `yaml:"name"`
Slug string `yaml:"slug"`
Width string `yaml:"width"`
DesktopNavigationWidth string `yaml:"desktop-navigation-width"`
ShowMobileHeader bool `yaml:"show-mobile-header"`
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
CenterVertically bool `yaml:"center-vertically"`
HeadWidgets widgets `yaml:"head-widgets"`
Columns []struct {
Size string `yaml:"size"`
Widgets widgets `yaml:"widgets"`
} `yaml:"columns"`
@@ -69,7 +98,7 @@ type page struct {
}
func newConfigFromYAML(contents []byte) (*config, error) {
contents, err := parseConfigEnvVariables(contents)
contents, err := parseConfigVariables(contents)
if err != nil {
return nil, err
}
@@ -87,6 +116,12 @@ func newConfigFromYAML(contents []byte) (*config, error) {
}
for p := range config.Pages {
for w := range config.Pages[p].HeadWidgets {
if err := config.Pages[p].HeadWidgets[w].initialize(); err != nil {
return nil, formatWidgetInitError(err, config.Pages[p].HeadWidgets[w])
}
}
for c := range config.Pages[p].Columns {
for w := range config.Pages[p].Columns[c].Widgets {
if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil {
@@ -99,23 +134,33 @@ func newConfigFromYAML(contents []byte) (*config, error) {
return config, nil
}
// TODO: change the pattern so that it doesn't match commented out lines
var configEnvVariablePattern = regexp.MustCompile(`(^|.)\$\{([A-Z0-9_]+)\}`)
var envVariableNamePattern = regexp.MustCompile(`^[A-Z0-9_]+$`)
var configVariablePattern = regexp.MustCompile(`(^|.)\$\{(?:([a-zA-Z]+):)?([a-zA-Z0-9_-]+)\}`)
func parseConfigEnvVariables(contents []byte) ([]byte, error) {
// Parses variables defined in the config such as:
// ${API_KEY} - gets replaced with the value of the API_KEY environment variable
// \${API_KEY} - escaped, gets used as is without the \ in the config
// ${secret:api_key} - value gets loaded from /run/secrets/api_key
// ${readFileFromEnv:PATH_TO_SECRET} - value gets loaded from the file path specified in the environment variable PATH_TO_SECRET
//
// TODO: don't match against commented out sections, not sure exactly how since
// variables can be placed anywhere and used to modify the YAML structure itself
func parseConfigVariables(contents []byte) ([]byte, error) {
var err error
replaced := configEnvVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte {
replaced := configVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte {
if err != nil {
return nil
}
groups := configEnvVariablePattern.FindSubmatch(match)
if len(groups) != 3 {
groups := configVariablePattern.FindSubmatch(match)
if len(groups) != 4 {
// we can't handle this match, this shouldn't happen unless the number of groups
// in the regex has been changed without updating the below code
return match
}
prefix, key := string(groups[1]), string(groups[2])
prefix := string(groups[1])
if prefix == `\` {
if len(match) >= 2 {
return match[1:]
@@ -124,13 +169,20 @@ func parseConfigEnvVariables(contents []byte) ([]byte, error) {
}
}
value, found := os.LookupEnv(key)
if !found {
err = fmt.Errorf("environment variable %s not found", key)
typeAsString, variableName := string(groups[2]), string(groups[3])
variableType := ternary(typeAsString == "", configVarTypeEnv, typeAsString)
parsedValue, returnOriginal, localErr := parseConfigVariableOfType(variableType, variableName)
if localErr != nil {
err = fmt.Errorf("parsing variable: %v", localErr)
return nil
}
return []byte(prefix + value)
if returnOriginal {
return match
}
return []byte(prefix + parsedValue)
})
if err != nil {
@@ -140,33 +192,90 @@ func parseConfigEnvVariables(contents []byte) ([]byte, error) {
return replaced, nil
}
// When the bool return value is true, it indicates that the caller should use the original value
func parseConfigVariableOfType(variableType, variableName string) (string, bool, error) {
switch variableType {
case configVarTypeEnv:
if !envVariableNamePattern.MatchString(variableName) {
return "", true, nil
}
v, found := os.LookupEnv(variableName)
if !found {
return "", false, fmt.Errorf("environment variable %s not found", variableName)
}
return v, false, nil
case configVarTypeSecret:
secretPath := filepath.Join("/run/secrets", variableName)
secret, err := os.ReadFile(secretPath)
if err != nil {
return "", false, fmt.Errorf("reading secret file: %v", err)
}
return strings.TrimSpace(string(secret)), false, nil
case configVarTypeFileFromEnv:
if !envVariableNamePattern.MatchString(variableName) {
return "", true, nil
}
filePath, found := os.LookupEnv(variableName)
if !found {
return "", false, fmt.Errorf("readFileFromEnv: environment variable %s not found", variableName)
}
if !filepath.IsAbs(filePath) {
return "", false, fmt.Errorf("readFileFromEnv: file path %s is not absolute", filePath)
}
fileContents, err := os.ReadFile(filePath)
if err != nil {
return "", false, fmt.Errorf("readFileFromEnv: reading file from %s: %v", variableName, err)
}
return strings.TrimSpace(string(fileContents)), false, nil
default:
return "", true, nil
}
}
func formatWidgetInitError(err error, w widget) error {
return fmt.Errorf("%s widget: %v", w.GetType(), err)
}
var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`)
var configIncludePattern = regexp.MustCompile(`(?m)^([ \t]*)(?:-[ \t]*)?(?:!|\$)include:[ \t]*(.+)$`)
func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
return recursiveParseYAMLIncludes(mainFilePath, nil, 0)
}
func recursiveParseYAMLIncludes(mainFilePath string, includes map[string]struct{}, depth int) ([]byte, map[string]struct{}, error) {
if depth > CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT {
return nil, nil, fmt.Errorf("recursion depth limit of %d reached", CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT)
}
mainFileContents, err := os.ReadFile(mainFilePath)
if err != nil {
return nil, nil, fmt.Errorf("reading main YAML file: %w", err)
return nil, nil, fmt.Errorf("reading %s: %w", mainFilePath, err)
}
mainFileAbsPath, err := filepath.Abs(mainFilePath)
if err != nil {
return nil, nil, fmt.Errorf("getting absolute path of main YAML file: %w", err)
return nil, nil, fmt.Errorf("getting absolute path of %s: %w", mainFilePath, err)
}
mainFileDir := filepath.Dir(mainFileAbsPath)
includes := make(map[string]struct{})
if includes == nil {
includes = make(map[string]struct{})
}
var includesLastErr error
mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
mainFileContents = configIncludePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
if includesLastErr != nil {
return nil
}
matches := includePattern.FindSubmatch(match)
matches := configIncludePattern.FindSubmatch(match)
if len(matches) != 3 {
includesLastErr = fmt.Errorf("invalid include match: %v", matches)
return nil
@@ -181,13 +290,14 @@ func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error)
var fileContents []byte
var err error
fileContents, err = os.ReadFile(includeFilePath)
includes[includeFilePath] = struct{}{}
fileContents, includes, err = recursiveParseYAMLIncludes(includeFilePath, includes, depth+1)
if err != nil {
includesLastErr = fmt.Errorf("reading included file %s: %w", includeFilePath, err)
includesLastErr = err
return nil
}
includes[includeFilePath] = struct{}{}
return []byte(prefixStringLines(indent, string(fileContents)))
})
@@ -308,7 +418,7 @@ func configFilesWatcher(
// wait for file to maybe get created again
// see https://github.com/glanceapp/glance/pull/358
for i := 0; i < 10; i++ {
for range 10 {
if _, err := os.Stat(event.Name); err == nil {
break
}
@@ -340,11 +450,39 @@ func configFilesWatcher(
}, nil
}
// TODO: Refactor, we currently validate in two different places, this being
// one of them, which doesn't modify the data and only checks for logical errors
// and then again when creating the application which does modify the data and do
// further validation. Would be better if validation was done in a single place.
func isConfigStateValid(config *config) error {
if len(config.Pages) == 0 {
return fmt.Errorf("no pages configured")
}
if len(config.Auth.Users) > 0 && config.Auth.SecretKey == "" {
return fmt.Errorf("secret-key must be set when users are configured")
}
for username := range config.Auth.Users {
if username == "" {
return fmt.Errorf("user has no name")
}
if len(username) < 3 {
return errors.New("usernames must be at least 3 characters")
}
user := config.Auth.Users[username]
if user.Password == "" {
if user.PasswordHashString == "" {
return fmt.Errorf("user %s must have a password or a password-hash set", username)
}
} else if len(user.Password) < 6 {
return fmt.Errorf("the password for %s must be at least 6 characters", username)
}
}
if config.Server.AssetsPath != "" {
if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) {
return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath)
@@ -352,36 +490,46 @@ func isConfigStateValid(config *config) error {
}
for i := range config.Pages {
if config.Pages[i].Title == "" {
page := &config.Pages[i]
if page.Title == "" {
return fmt.Errorf("page %d has no name", i+1)
}
if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") {
if page.Width != "" && (page.Width != "wide" && page.Width != "slim" && page.Width != "default") {
return fmt.Errorf("page %d: width can only be either wide or slim", i+1)
}
if len(config.Pages[i].Columns) == 0 {
if page.DesktopNavigationWidth != "" {
if page.DesktopNavigationWidth != "wide" && page.DesktopNavigationWidth != "slim" && page.DesktopNavigationWidth != "default" {
return fmt.Errorf("page %d: desktop-navigation-width can only be either wide or slim", i+1)
}
}
if len(page.Columns) == 0 {
return fmt.Errorf("page %d has no columns", i+1)
}
if config.Pages[i].Width == "slim" {
if len(config.Pages[i].Columns) > 2 {
if page.Width == "slim" {
if len(page.Columns) > 2 {
return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1)
}
} else {
if len(config.Pages[i].Columns) > 3 {
if len(page.Columns) > 3 {
return fmt.Errorf("page %d has more than 3 columns", i+1)
}
}
columnSizesCount := make(map[string]int)
for j := range config.Pages[i].Columns {
if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" {
for j := range page.Columns {
column := &page.Columns[j]
if column.Size != "small" && column.Size != "full" {
return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1)
}
columnSizesCount[config.Pages[i].Columns[j].Size]++
columnSizesCount[page.Columns[j].Size]++
}
full := columnSizesCount["full"]
@@ -393,3 +541,103 @@ func isConfigStateValid(config *config) error {
return nil
}
// Read-only way to store ordered maps from a YAML structure
type orderedYAMLMap[K comparable, V any] struct {
keys []K
data map[K]V
}
func newOrderedYAMLMap[K comparable, V any](keys []K, values []V) (*orderedYAMLMap[K, V], error) {
if len(keys) != len(values) {
return nil, fmt.Errorf("keys and values must have the same length")
}
om := &orderedYAMLMap[K, V]{
keys: make([]K, len(keys)),
data: make(map[K]V, len(keys)),
}
copy(om.keys, keys)
for i := range keys {
om.data[keys[i]] = values[i]
}
return om, nil
}
func (om *orderedYAMLMap[K, V]) Items() iter.Seq2[K, V] {
return func(yield func(K, V) bool) {
for _, key := range om.keys {
value, ok := om.data[key]
if !ok {
continue
}
if !yield(key, value) {
return
}
}
}
}
func (om *orderedYAMLMap[K, V]) Get(key K) (V, bool) {
value, ok := om.data[key]
return value, ok
}
func (self *orderedYAMLMap[K, V]) Merge(other *orderedYAMLMap[K, V]) *orderedYAMLMap[K, V] {
merged := &orderedYAMLMap[K, V]{
keys: make([]K, 0, len(self.keys)+len(other.keys)),
data: make(map[K]V, len(self.data)+len(other.data)),
}
merged.keys = append(merged.keys, self.keys...)
maps.Copy(merged.data, self.data)
for _, key := range other.keys {
if _, exists := self.data[key]; !exists {
merged.keys = append(merged.keys, key)
}
}
maps.Copy(merged.data, other.data)
return merged
}
func (om *orderedYAMLMap[K, V]) UnmarshalYAML(node *yaml.Node) error {
if node.Kind != yaml.MappingNode {
return fmt.Errorf("orderedMap: expected mapping node, got %d", node.Kind)
}
if len(node.Content)%2 != 0 {
return fmt.Errorf("orderedMap: expected even number of content items, got %d", len(node.Content))
}
om.keys = make([]K, len(node.Content)/2)
om.data = make(map[K]V, len(node.Content)/2)
for i := 0; i < len(node.Content); i += 2 {
keyNode := node.Content[i]
valueNode := node.Content[i+1]
var key K
if err := keyNode.Decode(&key); err != nil {
return fmt.Errorf("orderedMap: decoding key: %v", err)
}
if _, ok := om.data[key]; ok {
return fmt.Errorf("orderedMap: duplicate key %v", key)
}
var value V
if err := valueNode.Decode(&value); err != nil {
return fmt.Errorf("orderedMap: decoding value: %v", err)
}
(*om).keys[i/2] = key
(*om).data[key] = value
}
return nil
}
+5 -3
View File
@@ -12,7 +12,7 @@ import (
"time"
)
const httpTestRequestTimeout = 10 * time.Second
const httpTestRequestTimeout = 15 * time.Second
var diagnosticSteps = []diagnosticStep{
{
@@ -75,7 +75,9 @@ var diagnosticSteps = []diagnosticStep{
{
name: "fetch data from Reddit API",
fn: func() (string, error) {
return testHttpRequest("GET", "https://www.reddit.com/search.json", 200)
return testHttpRequestWithHeaders("GET", "https://www.reddit.com/search.json", map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0",
}, 200)
},
},
{
@@ -165,7 +167,7 @@ func testHttpRequestWithHeaders(method, url string, headers map[string]string, e
request.Header.Add(key, value)
}
response, err := http.DefaultClient.Do(request)
response, err := defaultHTTPClient.Do(request)
if err != nil {
return "", err
}
+89
View File
@@ -1,13 +1,19 @@
package glance
import (
"bytes"
"crypto/md5"
"embed"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"log"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
@@ -20,6 +26,19 @@ var _templateFS embed.FS
var staticFS, _ = fs.Sub(_staticFS, "static")
var templateFS, _ = fs.Sub(_templateFS, "templates")
func readAllFromStaticFS(path string) ([]byte, error) {
// For some reason fs.FS only works with forward slashes, so in case we're
// running on Windows or pass paths with backslashes we need to replace them.
path = strings.ReplaceAll(path, "\\", "/")
file, err := staticFS.Open(path)
if err != nil {
return nil, err
}
return io.ReadAll(file)
}
var staticFSHash = func() string {
hash, err := computeFSHash(staticFS)
if err != nil {
@@ -60,3 +79,73 @@ func computeFSHash(files fs.FS) (string, error) {
return hex.EncodeToString(hash.Sum(nil))[:10], nil
}
var cssImportPattern = regexp.MustCompile(`(?m)^@import "(.*?)";$`)
var cssSingleLineCommentPattern = regexp.MustCompile(`(?m)^\s*\/\*.*?\*\/$`)
// Yes, we bundle at runtime, give comptime pls
var bundledCSSContents = func() []byte {
const mainFilePath = "css/main.css"
var recursiveParseImports func(path string, depth int) ([]byte, error)
recursiveParseImports = func(path string, depth int) ([]byte, error) {
if depth > 20 {
return nil, errors.New("maximum import depth reached, is one of your imports circular?")
}
mainFileContents, err := readAllFromStaticFS(path)
if err != nil {
return nil, err
}
// Normalize line endings, otherwise the \r's make the regex not match
mainFileContents = bytes.ReplaceAll(mainFileContents, []byte("\r\n"), []byte("\n"))
mainFileDir := filepath.Dir(path)
var importLastErr error
parsed := cssImportPattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
if importLastErr != nil {
return nil
}
matches := cssImportPattern.FindSubmatch(match)
if len(matches) != 2 {
importLastErr = fmt.Errorf(
"import didn't return expected number of capture groups: %s, expected 2, got %d",
match, len(matches),
)
return nil
}
importFilePath := filepath.Join(mainFileDir, string(matches[1]))
importContents, err := recursiveParseImports(importFilePath, depth+1)
if err != nil {
importLastErr = err
return nil
}
return importContents
})
if importLastErr != nil {
return nil, importLastErr
}
return parsed, nil
}
contents, err := recursiveParseImports(mainFilePath, 0)
if err != nil {
panic(fmt.Sprintf("building CSS bundle: %v", err))
}
// We could strip a bunch more unnecessary characters, but the biggest
// win comes from removing the whitespace at the beginning of lines
// since that's at least 4 bytes per property, which yielded a ~20% reduction.
contents = cssSingleLineCommentPattern.ReplaceAll(contents, nil)
contents = whitespaceAtBeginningOfLinePattern.ReplaceAll(contents, nil)
contents = bytes.ReplaceAll(contents, []byte("\n"), []byte(""))
return contents
}()
+337 -51
View File
@@ -3,50 +3,153 @@ package glance
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"html/template"
"log"
"net/http"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
var (
pageTemplate = mustParseTemplate("page.html", "document.html")
pageContentTemplate = mustParseTemplate("page-content.html")
pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
pageTemplate = mustParseTemplate("page.html", "document.html", "footer.html")
pageContentTemplate = mustParseTemplate("page-content.html")
manifestTemplate = mustParseTemplate("manifest.json")
)
const STATIC_ASSETS_CACHE_DURATION = 24 * time.Hour
var reservedPageSlugs = []string{"login", "logout"}
type application struct {
Version string
Config config
ParsedThemeStyle template.HTML
Version string
CreatedAt time.Time
Config config
ConfigPath string
parsedManifest []byte
slugToPage map[string]*page
widgetByID map[uint64]widget
RequiresAuth bool
authSecretKey []byte
usernameHashToUsername map[string]string
authAttemptsMu sync.Mutex
failedAuthAttempts map[string]*failedAuthAttempt
}
func newApplication(config *config) (*application, error) {
func newApplication(c *config, configPath string) (*application, error) {
app := &application{
Version: buildVersion,
Config: *config,
CreatedAt: time.Now(),
Config: *c,
ConfigPath: configPath,
slugToPage: make(map[string]*page),
widgetByID: make(map[uint64]widget),
}
config := &app.Config
//
// Init auth
//
if len(config.Auth.Users) > 0 {
secretBytes, err := base64.StdEncoding.DecodeString(config.Auth.SecretKey)
if err != nil {
return nil, fmt.Errorf("decoding secret-key: %v", err)
}
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
return nil, fmt.Errorf("secret-key must be exactly %d bytes", AUTH_SECRET_KEY_LENGTH)
}
app.usernameHashToUsername = make(map[string]string)
app.failedAuthAttempts = make(map[string]*failedAuthAttempt)
app.RequiresAuth = true
for username := range config.Auth.Users {
user := config.Auth.Users[username]
usernameHash, err := computeUsernameHash(username, secretBytes)
if err != nil {
return nil, fmt.Errorf("computing username hash for user %s: %v", username, err)
}
app.usernameHashToUsername[string(usernameHash)] = username
if user.PasswordHashString != "" {
user.PasswordHash = []byte(user.PasswordHashString)
user.PasswordHashString = ""
} else {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hashing password for user %s: %v", username, err)
}
user.Password = ""
user.PasswordHash = hashedPassword
}
}
app.authSecretKey = secretBytes
}
//
// Init themes
//
if !config.Theme.DisablePicker {
themeKeys := make([]string, 0, 2)
themeProps := make([]*themeProperties, 0, 2)
defaultDarkTheme, ok := config.Theme.Presets.Get("default-dark")
if ok && !config.Theme.SameAs(defaultDarkTheme) || !config.Theme.SameAs(&themeProperties{}) {
themeKeys = append(themeKeys, "default-dark")
themeProps = append(themeProps, &themeProperties{})
}
themeKeys = append(themeKeys, "default-light")
themeProps = append(themeProps, &themeProperties{
Light: true,
BackgroundColor: &hslColorField{240, 13, 95},
PrimaryColor: &hslColorField{230, 100, 30},
NegativeColor: &hslColorField{0, 70, 50},
ContrastMultiplier: 1.3,
TextSaturationMultiplier: 0.5,
})
themePresets, err := newOrderedYAMLMap(themeKeys, themeProps)
if err != nil {
return nil, fmt.Errorf("creating theme presets: %v", err)
}
config.Theme.Presets = *themePresets.Merge(&config.Theme.Presets)
for key, properties := range config.Theme.Presets.Items() {
properties.Key = key
if err := properties.init(); err != nil {
return nil, fmt.Errorf("initializing preset theme %s: %v", key, err)
}
}
}
config.Theme.Key = "default"
if err := config.Theme.init(); err != nil {
return nil, fmt.Errorf("initializing default theme: %v", err)
}
//
// Init pages
//
app.slugToPage[""] = &config.Pages[0]
providers := &widgetProviders{
assetResolver: app.AssetPath,
}
var err error
app.ParsedThemeStyle, err = executeTemplateToHTML(pageThemeStyleTemplate, &app.Config.Theme)
if err != nil {
return nil, fmt.Errorf("parsing theme style: %v", err)
assetResolver: app.StaticAssetPath,
}
for p := range config.Pages {
@@ -57,8 +160,26 @@ func newApplication(config *config) (*application, error) {
page.Slug = titleToSlug(page.Title)
}
if slices.Contains(reservedPageSlugs, page.Slug) {
return nil, fmt.Errorf("page slug \"%s\" is reserved", page.Slug)
}
app.slugToPage[page.Slug] = page
if page.Width == "default" {
page.Width = ""
}
if page.DesktopNavigationWidth == "" && page.DesktopNavigationWidth != "default" {
page.DesktopNavigationWidth = page.Width
}
for i := range page.HeadWidgets {
widget := page.HeadWidgets[i]
app.widgetByID[widget.GetID()] = widget
widget.setProviders(providers)
}
for c := range page.Columns {
column := &page.Columns[c]
@@ -69,24 +190,44 @@ func newApplication(config *config) (*application, error) {
for w := range column.Widgets {
widget := column.Widgets[w]
app.widgetByID[widget.GetID()] = widget
widget.setProviders(providers)
}
}
}
config = &app.Config
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
config.Theme.CustomCSSFile = app.transformUserDefinedAssetPath(config.Theme.CustomCSSFile)
config.Theme.CustomCSSFile = app.resolveUserDefinedAssetPath(config.Theme.CustomCSSFile)
config.Branding.LogoURL = app.resolveUserDefinedAssetPath(config.Branding.LogoURL)
if config.Branding.FaviconURL == "" {
config.Branding.FaviconURL = app.AssetPath("favicon.png")
} else {
config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL)
config.Branding.FaviconURL = ternary(
config.Branding.FaviconURL == "",
app.StaticAssetPath("favicon.svg"),
app.resolveUserDefinedAssetPath(config.Branding.FaviconURL),
)
config.Branding.FaviconType = ternary(
strings.HasSuffix(config.Branding.FaviconURL, ".svg"),
"image/svg+xml",
"image/png",
)
if config.Branding.AppName == "" {
config.Branding.AppName = "Glance"
}
config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL)
if config.Branding.AppIconURL == "" {
config.Branding.AppIconURL = app.StaticAssetPath("app-icon.png")
}
if config.Branding.AppBackgroundColor == "" {
config.Branding.AppBackgroundColor = config.Theme.BackgroundColorAsHex
}
manifest, err := executeTemplateToString(manifestTemplate, templateData{App: app})
if err != nil {
return nil, fmt.Errorf("parsing manifest.json: %v", err)
}
app.parsedManifest = []byte(manifest)
return app, nil
}
@@ -97,6 +238,20 @@ func (p *page) updateOutdatedWidgets() {
var wg sync.WaitGroup
context := context.Background()
for w := range p.HeadWidgets {
widget := p.HeadWidgets[w]
if !widget.requiresUpdate(&now) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
widget.update(context)
}()
}
for c := range p.Columns {
for w := range p.Columns[c].Widgets {
widget := p.Columns[c].Widgets[w]
@@ -116,7 +271,7 @@ func (p *page) updateOutdatedWidgets() {
wg.Wait()
}
func (a *application) transformUserDefinedAssetPath(path string) string {
func (a *application) resolveUserDefinedAssetPath(path string) string {
if strings.HasPrefix(path, "/assets/") {
return a.Config.Server.BaseURL + path
}
@@ -124,26 +279,51 @@ func (a *application) transformUserDefinedAssetPath(path string) string {
return path
}
type pageTemplateData struct {
App *application
Page *page
type templateRequestData struct {
Theme *themeProperties
}
type templateData struct {
App *application
Page *page
Request templateRequestData
}
func (a *application) populateTemplateRequestData(data *templateRequestData, r *http.Request) {
theme := &a.Config.Theme.themeProperties
if !a.Config.Theme.DisablePicker {
selectedTheme, err := r.Cookie("theme")
if err == nil {
preset, exists := a.Config.Theme.Presets.Get(selectedTheme.Value)
if exists {
theme = preset
}
}
}
data.Theme = theme
}
func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) {
page, exists := a.slugToPage[r.PathValue("page")]
if !exists {
a.handleNotFound(w, r)
return
}
pageData := pageTemplateData{
if a.handleUnauthorizedResponse(w, r, redirectToLogin) {
return
}
data := templateData{
Page: page,
App: a,
}
a.populateTemplateRequestData(&data.Request, r)
var responseBytes bytes.Buffer
err := pageTemplate.Execute(&responseBytes, pageData)
err := pageTemplate.Execute(&responseBytes, data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
@@ -155,13 +335,16 @@ func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request)
func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) {
page, exists := a.slugToPage[r.PathValue("page")]
if !exists {
a.handleNotFound(w, r)
return
}
pageData := pageTemplateData{
if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) {
return
}
pageData := templateData{
Page: page,
}
@@ -185,6 +368,35 @@ func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Re
w.Write(responseBytes.Bytes())
}
func (a *application) addressOfRequest(r *http.Request) string {
remoteAddrWithoutPort := func() string {
for i := len(r.RemoteAddr) - 1; i >= 0; i-- {
if r.RemoteAddr[i] == ':' {
return r.RemoteAddr[:i]
}
}
return r.RemoteAddr
}
if !a.Config.Server.Proxied {
return remoteAddrWithoutPort()
}
// This should probably be configurable or look for multiple headers, not just this one
forwardedFor := r.Header.Get("X-Forwarded-For")
if forwardedFor == "" {
return remoteAddrWithoutPort()
}
ips := strings.Split(forwardedFor, ",")
if len(ips) == 0 || ips[0] == "" {
return remoteAddrWithoutPort()
}
return ips[0]
}
func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
// TODO: add proper not found page
w.WriteHeader(http.StatusNotFound)
@@ -192,47 +404,122 @@ func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
}
func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) {
widgetValue := r.PathValue("widget")
// TODO: this requires a rework of the widget update logic so that rather
// than locking the entire page we lock individual widgets
w.WriteHeader(http.StatusNotImplemented)
widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
if err != nil {
a.handleNotFound(w, r)
return
}
// widgetValue := r.PathValue("widget")
widget, exists := a.widgetByID[widgetID]
// widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
// if err != nil {
// a.handleNotFound(w, r)
// return
// }
if !exists {
a.handleNotFound(w, r)
return
}
// widget, exists := a.widgetByID[widgetID]
widget.handleRequest(w, r)
// if !exists {
// a.handleNotFound(w, r)
// return
// }
// widget.handleRequest(w, r)
}
func (a *application) AssetPath(asset string) string {
func (a *application) StaticAssetPath(asset string) string {
return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset
}
func (a *application) VersionedAssetPath(asset string) string {
return a.Config.Server.BaseURL + asset +
"?v=" + strconv.FormatInt(a.CreatedAt.Unix(), 10)
}
func (a *application) server() (func() error, func() error) {
// TODO: add gzip support, static files must have their gzipped contents cached
// TODO: add HTTPS support
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", a.handlePageRequest)
mux.HandleFunc("GET /{page}", a.handlePageRequest)
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest)
if !a.Config.Theme.DisablePicker {
mux.HandleFunc("POST /api/set-theme/{key}", a.handleThemeChangeRequest)
}
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /edit", a.handleAdminIndex)
mux.HandleFunc("GET /edit/pages/{page}", a.handleAdminPage)
mux.HandleFunc("GET /edit/pages/{page}/settings", a.handleAdminPageSettings)
mux.HandleFunc("GET /edit/site-settings", a.handleAdminSiteSettings)
mux.HandleFunc("GET /edit/theme-settings", a.handleAdminThemeSettings)
mux.HandleFunc("GET /edit/theme-settings/presets/new", a.handleAdminThemePreset)
mux.HandleFunc("GET /edit/theme-settings/presets/{key}", a.handleAdminThemePreset)
mux.HandleFunc("GET /edit/pages/{page}/widgets/{col}/{idx}", a.handleAdminWidget)
mux.HandleFunc("POST /edit/api/pages", a.handleAdminAddPage)
mux.HandleFunc("POST /edit/api/pages/{page}/delete", a.handleAdminDeletePage)
mux.HandleFunc("GET /edit/pages/{page}/widgets/{col}/new", a.handleAdminNewWidget)
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/new", a.handleAdminCreateWidget)
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/{idx}", a.handleAdminEditWidget)
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/{idx}/delete", a.handleAdminDeleteWidget)
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/{idx}/move", a.handleAdminMoveWidget)
mux.HandleFunc("POST /edit/api/pages/{page}/layout", a.handleAdminLayout)
mux.HandleFunc("GET /edit/api/pages/{page}/widgets/{col}/{idx}/fields", a.handleAdminGetFields)
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/{idx}/fields", a.handleAdminUpdateFields)
mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/create", a.handleAdminCreateFromFields)
mux.HandleFunc("GET /edit/api/widget-schemas", a.handleAdminWidgetSchemas)
mux.HandleFunc("POST /edit/api/validate/{kind}", a.handleAdminValidate)
mux.HandleFunc("POST /edit/api/lookup/{kind}", a.handleAdminLookup)
mux.HandleFunc("POST /edit/api/pages/{page}/columns", a.handleAdminAddColumn)
mux.HandleFunc("POST /edit/api/pages/{page}/columns/{col}/delete", a.handleAdminDeleteColumn)
mux.HandleFunc("POST /edit/api/pages/{page}/columns/{col}/move", a.handleAdminMoveColumn)
mux.HandleFunc("POST /edit/api/pages/{page}/columns/{col}/size", a.handleAdminColumnSize)
mux.HandleFunc("POST /edit/api/pages/{page}/fields", a.handleAdminUpdatePageFields)
mux.HandleFunc("POST /edit/api/pages/{page}/move", a.handleAdminMovePage)
mux.HandleFunc("POST /edit/api/restore", a.handleAdminRestore)
mux.HandleFunc("POST /edit/api/restore/{n}", a.handleAdminRestoreFromBackup)
mux.HandleFunc("POST /edit/api/site-settings", a.handleAdminUpdateSiteSettings)
mux.HandleFunc("POST /edit/api/theme-settings", a.handleAdminUpdateTheme)
mux.HandleFunc("POST /edit/api/theme-presets", a.handleAdminCreateOrUpdatePreset)
mux.HandleFunc("POST /edit/api/theme-presets/{key}", a.handleAdminCreateOrUpdatePreset)
mux.HandleFunc("POST /edit/api/theme-presets/{key}/delete", a.handleAdminDeletePreset)
if a.RequiresAuth {
mux.HandleFunc("GET /login", a.handleLoginPageRequest)
mux.HandleFunc("GET /logout", a.handleLogoutRequest)
mux.HandleFunc("POST /api/authenticate", a.handleAuthenticationAttempt)
}
mux.Handle(
fmt.Sprintf("GET /static/%s/{path...}", staticFSHash),
http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)),
http.StripPrefix(
"/static/"+staticFSHash,
fileServerWithCache(http.FS(staticFS), STATIC_ASSETS_CACHE_DURATION),
),
)
assetCacheControlValue := fmt.Sprintf(
"public, max-age=%d",
int(STATIC_ASSETS_CACHE_DURATION.Seconds()),
)
mux.HandleFunc(fmt.Sprintf("GET /static/%s/css/bundle.css", staticFSHash), func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Cache-Control", assetCacheControlValue)
w.Header().Add("Content-Type", "text/css; charset=utf-8")
w.Write(bundledCSSContents)
})
mux.HandleFunc("GET /manifest.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Cache-Control", assetCacheControlValue)
w.Header().Add("Content-Type", "application/json")
w.Write(a.parsedManifest)
})
var absAssetsPath string
if a.Config.Server.AssetsPath != "" {
absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
@@ -246,7 +533,6 @@ func (a *application) server() (func() error, func() error) {
}
start := func() error {
a.Config.Server.StartedAt = time.Now()
log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n",
a.Config.Server.Host,
a.Config.Server.Port,
+44 -4
View File
@@ -6,6 +6,8 @@ import (
"log"
"net/http"
"os"
"golang.org/x/crypto/bcrypt"
)
var buildVersion = "dev"
@@ -55,12 +57,43 @@ func Main() int {
return cliMountpointInfo(options.args[1])
case cliIntentDiagnose:
runDiagnostic()
case cliIntentSecretMake:
key, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH)
if err != nil {
fmt.Printf("Failed to make secret key: %v\n", err)
return 1
}
fmt.Println(key)
case cliIntentPasswordHash:
password := options.args[1]
if password == "" {
fmt.Println("Password cannot be empty")
return 1
}
if len(password) < 6 {
fmt.Println("Password must be at least 6 characters long")
return 1
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
fmt.Printf("Failed to hash password: %v\n", err)
return 1
}
fmt.Println(string(hashedPassword))
}
return 0
}
func serveApp(configPath string) error {
// TODO: refactor if this gets any more complex, the current implementation is
// difficult to reason about due to all of the callbacks and simultaneous operations,
// use a single goroutine and a channel to initiate synchronous changes to the server
exitChannel := make(chan struct{})
hadValidConfigOnStartup := false
var stopServer func() error
@@ -79,16 +112,23 @@ func serveApp(configPath string) error {
}
return
} else if !hadValidConfigOnStartup {
hadValidConfigOnStartup = true
}
app, err := newApplication(config)
app, err := newApplication(config, configPath)
if err != nil {
log.Printf("Failed to create application: %v", err)
if !hadValidConfigOnStartup {
close(exitChannel)
}
return
}
if !hadValidConfigOnStartup {
hadValidConfigOnStartup = true
}
if stopServer != nil {
if err := stopServer(); err != nil {
log.Printf("Error while trying to stop server: %v", err)
@@ -125,7 +165,7 @@ func serveApp(configPath string) error {
return fmt.Errorf("validating config file: %w", err)
}
app, err := newApplication(config)
app, err := newApplication(config, configPath)
if err != nil {
return fmt.Errorf("creating application: %w", err)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

+590
View File
@@ -0,0 +1,590 @@
/* Edit mode — in-place dashboard editing UI. Hidden unless body[data-edit-mode]. */
#edit-mode-toggle {
background: transparent;
border: none;
padding: 0;
cursor: pointer;
color: var(--color-text-subdue);
}
#edit-mode-toggle.active {
color: var(--color-primary);
}
body[data-edit-mode] .widget {
position: relative;
outline: 1px dashed var(--color-text-subdue);
outline-offset: 4px;
transition: outline-color 0.15s;
}
body[data-edit-mode] .widget:hover {
outline-color: var(--color-primary);
}
body[data-edit-mode] .widget.sortable-ghost {
opacity: 0.4;
}
body[data-edit-mode] .widget.sortable-drag {
cursor: grabbing;
}
.edit-mode-handles {
display: none;
position: absolute;
top: -10px;
right: 8px;
z-index: 10;
gap: 4px;
background: var(--color-popover-background);
border: 1px solid var(--color-popover-border);
border-radius: 4px;
padding: 2px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
body[data-edit-mode] .edit-mode-handles {
display: flex;
}
.edit-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--color-text-base);
cursor: pointer;
border-radius: 3px;
font-size: 14px;
line-height: 1;
text-decoration: none;
}
.edit-handle:hover {
background: var(--color-widget-background-highlight);
color: var(--color-primary);
}
.edit-handle-drag {
cursor: grab;
}
.edit-handle-drag:active {
cursor: grabbing;
}
.edit-handle-delete:hover {
color: var(--color-negative);
}
#edit-mode-status {
display: none;
position: fixed;
bottom: 1rem;
right: 1rem;
background: var(--color-popover-background);
border: 1px solid var(--color-popover-border);
border-radius: 4px;
padding: 0.5rem 1rem;
font-size: var(--font-size-h5);
color: var(--color-text-base);
z-index: 100;
}
body[data-edit-mode] #edit-mode-status {
display: block;
}
#edit-mode-status.saving {
color: var(--color-primary);
}
#edit-mode-status.error {
color: var(--color-negative);
border-color: var(--color-negative);
}
.edit-add-widget {
display: block;
width: 100%;
margin-top: var(--widget-gap);
padding: 0.75rem;
background: transparent;
border: 1px dashed var(--color-text-subdue);
border-radius: var(--border-radius);
color: var(--color-text-subdue);
cursor: pointer;
font: inherit;
font-size: var(--font-size-h4);
}
.edit-add-widget:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* ---------- Dialog ---------- */
.edit-dialog-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
overflow-y: auto;
}
.edit-dialog {
background: var(--color-popover-background);
border: 1px solid var(--color-popover-border);
border-radius: 6px;
width: 100%;
max-width: 640px;
max-height: calc(100vh - 2rem);
display: flex;
flex-direction: column;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.edit-dialog-narrow {
max-width: 480px;
}
.edit-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-widget-content-border);
}
.edit-dialog-header h2 {
margin: 0;
font-size: var(--font-size-h2);
color: var(--color-text-highlight);
}
.edit-dialog-close {
background: transparent;
border: none;
color: var(--color-text-subdue);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
width: 28px;
height: 28px;
border-radius: 3px;
}
.edit-dialog-close:hover {
background: var(--color-widget-background-highlight);
color: var(--color-text-base);
}
.edit-dialog-body {
padding: 1rem 1.25rem;
overflow-y: auto;
flex: 1;
color: var(--color-text-base);
}
.edit-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--color-widget-content-border);
}
.edit-dialog-actions button {
padding: 0.5rem 1rem;
border-radius: 4px;
border: 1px solid var(--color-popover-border);
background: var(--color-widget-background);
color: var(--color-text-base);
cursor: pointer;
font: inherit;
font-size: var(--font-size-h4);
}
.edit-dialog-actions .edit-dialog-save {
background: var(--color-primary);
color: var(--color-background);
border-color: var(--color-primary);
font-weight: bold;
}
.edit-dialog-actions button:hover {
filter: brightness(1.1);
}
.edit-dialog-error {
margin: 0 1.25rem 1rem;
padding: 0.75rem;
background: hsla(0, 70%, 50%, 0.15);
border: 1px solid var(--color-negative);
border-radius: 4px;
color: var(--color-negative);
font-size: var(--font-size-h5);
white-space: pre-wrap;
}
/* ---------- Form fields ---------- */
.edit-field {
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.edit-label {
font-size: var(--font-size-h5);
color: var(--color-text-highlight);
font-weight: 600;
}
.edit-required {
color: var(--color-negative);
margin-left: 0.25rem;
}
.edit-help {
font-size: var(--font-size-h6);
color: var(--color-text-subdue);
line-height: 1.4;
}
.edit-input {
background: var(--color-widget-background);
border: 1px solid var(--color-widget-content-border);
border-radius: 3px;
padding: 0.5rem 0.6rem;
color: var(--color-text-base);
font: inherit;
font-size: var(--font-size-h4);
width: 100%;
box-sizing: border-box;
}
.edit-input:focus {
outline: none;
border-color: var(--color-primary);
}
textarea.edit-input {
resize: vertical;
font-family: monospace;
font-size: var(--font-size-h5);
}
.edit-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: var(--font-size-h4);
color: var(--color-text-highlight);
font-weight: 600;
}
.edit-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
/* ---------- List editors ---------- */
.edit-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.edit-list-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.edit-list-strings .edit-list-item {
display: flex;
gap: 0.5rem;
align-items: center;
}
.edit-list-strings .edit-list-item .edit-input {
flex: 1;
}
.edit-list-object {
border: 1px solid var(--color-widget-content-border);
border-radius: 4px;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.1);
position: relative;
}
.edit-list-object .edit-list-remove {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem 0.6rem;
font-size: var(--font-size-h6);
}
.edit-list-remove {
background: transparent;
border: 1px solid var(--color-popover-border);
color: var(--color-text-subdue);
border-radius: 3px;
cursor: pointer;
font: inherit;
padding: 0.25rem 0.5rem;
}
.edit-list-remove:hover {
color: var(--color-negative);
border-color: var(--color-negative);
}
.edit-list-add {
align-self: flex-start;
background: transparent;
border: 1px dashed var(--color-text-subdue);
color: var(--color-text-subdue);
border-radius: 3px;
padding: 0.4rem 0.8rem;
cursor: pointer;
font: inherit;
font-size: var(--font-size-h5);
}
.edit-list-add:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
/* ---------- Type picker ---------- */
.edit-type-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.5rem;
}
.edit-type-option {
padding: 0.75rem 0.5rem;
background: var(--color-widget-background);
border: 1px solid var(--color-widget-content-border);
border-radius: 4px;
color: var(--color-text-base);
cursor: pointer;
font: inherit;
font-size: var(--font-size-h5);
text-align: center;
}
.edit-type-option:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-widget-background-highlight);
}
/* ---------- Autocomplete ---------- */
.edit-autocomplete {
position: relative;
}
.edit-autocomplete-list {
position: absolute;
top: calc(100% + 2px);
left: 0;
right: 0;
z-index: 10;
background: var(--color-popover-background);
border: 1px solid var(--color-popover-border);
border-radius: 4px;
max-height: 18rem;
overflow-y: auto;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
}
.edit-autocomplete-item {
display: block;
width: 100%;
text-align: left;
background: transparent;
border: none;
border-bottom: 1px solid var(--color-widget-content-border);
padding: 0.5rem 0.6rem;
color: var(--color-text-base);
cursor: pointer;
font: inherit;
}
.edit-autocomplete-item:last-child {
border-bottom: none;
}
.edit-autocomplete-item:hover,
.edit-autocomplete-item:focus {
background: var(--color-widget-background-highlight);
}
.edit-autocomplete-display {
font-size: var(--font-size-h5);
color: var(--color-text-highlight);
}
.edit-autocomplete-hint {
font-size: var(--font-size-h6);
color: var(--color-text-subdue);
margin-top: 2px;
}
/* ---------- Per-field validation state ---------- */
.edit-validate-state {
font-size: var(--font-size-h6);
margin-top: 0.25rem;
min-height: 1em;
}
.edit-validate-checking {
color: var(--color-text-subdue);
font-style: italic;
}
.edit-validate-ok {
color: var(--color-positive);
}
.edit-validate-error {
color: var(--color-negative);
}
.edit-input-invalid {
border-color: var(--color-negative);
}
/* ---------- Column controls ---------- */
.edit-column-header {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.5rem;
padding: 0.35rem 0.5rem;
background: var(--color-popover-background);
border: 1px solid var(--color-popover-border);
border-radius: 4px;
font-size: var(--font-size-h5);
}
.edit-column-label {
color: var(--color-text-subdue);
font-weight: 600;
}
.edit-column-spacer {
flex: 1;
}
.edit-column-size {
background: var(--color-widget-background);
border: 1px solid var(--color-widget-content-border);
border-radius: 3px;
color: var(--color-text-base);
font: inherit;
font-size: var(--font-size-h6);
padding: 0.15rem 0.3rem;
}
.edit-column-move,
.edit-column-delete {
background: transparent;
border: 1px solid var(--color-popover-border);
color: var(--color-text-subdue);
border-radius: 3px;
padding: 0.15rem 0.5rem;
cursor: pointer;
font: inherit;
font-size: var(--font-size-h6);
}
.edit-column-move:hover,
.edit-column-delete:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
.edit-column-move:disabled,
.edit-column-delete:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.edit-column-delete:hover:not(:disabled) {
color: var(--color-negative);
border-color: var(--color-negative);
}
.edit-add-column {
display: block;
margin: var(--widget-gap) auto 0;
padding: 0.6rem 1.5rem;
background: transparent;
border: 1px dashed var(--color-text-subdue);
border-radius: var(--border-radius);
color: var(--color-text-subdue);
cursor: pointer;
font: inherit;
font-size: var(--font-size-h4);
}
.edit-add-column:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* ---------- Nested widget editor (group / split-column) ---------- */
.edit-nested-widget {
border: 1px solid var(--color-widget-content-border);
border-radius: 4px;
padding: 0.6rem;
background: rgba(0, 0, 0, 0.1);
}
.edit-nested-header {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.6rem;
}
.edit-nested-type {
background: var(--color-widget-background);
border: 1px solid var(--color-widget-content-border);
border-radius: 3px;
color: var(--color-text-base);
font: inherit;
font-size: var(--font-size-h5);
padding: 0.2rem 0.4rem;
flex: 1;
}
.edit-nested-body > .edit-field:last-child {
margin-bottom: 0;
}
@@ -0,0 +1,19 @@
.forum-post-list-thumbnail {
flex-shrink: 0;
width: 6rem;
height: 4.1rem;
border-radius: var(--border-radius);
object-fit: cover;
border: 1px solid var(--color-separator);
margin-top: 0.1rem;
}
.forum-post-tags-container {
transform: translateY(-0.15rem);
}
@container widget (max-width: 550px) {
.forum-post-autohide {
display: none;
}
}
+155
View File
@@ -0,0 +1,155 @@
.login-bounds {
max-width: 500px;
padding: 0 2rem;
}
.form-label {
text-transform: uppercase;
margin-bottom: 0.5rem;
}
.form-input {
transition: border-color .2s;
}
.form-input input {
border: 0;
background: none;
width: 100%;
height: 5.2rem;
font: inherit;
outline: none;
color: var(--color-text-highlight);
}
.form-input-icon {
width: 2rem;
height: 2rem;
margin-top: -0.1rem;
opacity: 0.5;
}
.form-input input[type="password"] {
letter-spacing: 0.3rem;
font-size: 0.9em;
}
.form-input input[type="password"]::placeholder {
letter-spacing: 0;
font-size: var(--font-size-base);
}
.form-input:hover {
border-color: var(--color-progress-border);
}
.form-input:focus-within {
border-color: var(--color-primary);
transition-duration: .7s;
}
.login-button {
width: 100%;
display: block;
padding: 1rem;
background: none;
border: 1px solid var(--color-text-subdue);
border-radius: var(--border-radius);
color: var(--color-text-paragraph);
cursor: pointer;
font: inherit;
font-size: var(--font-size-h4);
display: flex;
gap: .5rem;
align-items: center;
justify-content: center;
transition: all .3s, margin-top 0s;
margin-top: 3rem;
}
.login-button:not(:disabled) {
box-shadow: 0 0 10px 1px var(--color-separator);
}
.login-error-message:not(:empty) + .login-button {
margin-top: 2rem;
}
.login-button:focus, .login-button:hover {
outline: none;
border-color: var(--color-primary);
color: var(--color-primary);
}
.login-button:disabled {
border-color: var(--color-separator);
color: var(--color-text-subdue);
cursor: not-allowed;
}
.login-button svg {
width: 1.7rem;
height: 1.7rem;
transition: transform .2s;
}
.login-button:not(:disabled):hover svg, .login-button:not(:disabled):focus svg {
transform: translateX(.5rem);
}
.animate-entrance {
animation: fieldReveal 0.7s backwards;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
.animate-entrance:nth-child(1) { animation-delay: .1s; }
.animate-entrance:nth-child(2) { animation-delay: .2s; }
.animate-entrance:nth-child(4) { animation-delay: .3s; }
@keyframes fieldReveal {
from {
opacity: 0.0001;
transform: translateY(4rem);
}
}
.login-error-message {
color: var(--color-negative);
font-size: var(--font-size-base);
padding: 1.3rem calc(var(--widget-content-horizontal-padding) + 1px);
position: relative;
margin-top: 2rem;
animation: errorMessageEntrance 0.4s backwards cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes errorMessageEntrance {
from {
opacity: 0;
transform: scale(1.1);
}
}
.login-error-message:empty {
display: none;
}
.login-error-message::before {
content: "";
position: absolute;
inset: 0;
border-radius: var(--border-radius);
background: var(--color-negative);
opacity: 0.05;
z-index: -1;
}
.footer {
animation-delay: .4s;
animation-duration: 1s;
}
.toggle-password-visibility {
background: none;
border: none;
cursor: pointer;
}
+67
View File
@@ -0,0 +1,67 @@
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
:root {
font-size: 10px;
--scheme: ;
--bgh: 240;
--bgs: 8%;
--bgl: 9%;
--bghs: var(--bgh), var(--bgs);
--cm: 1;
--tsm: 1;
--widget-gap: 23px;
--widget-content-vertical-padding: 15px;
--widget-content-horizontal-padding: 17px;
--widget-content-padding: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
--content-bounds-padding: 15px;
--border-radius: 5px;
--mobile-navigation-height: 50px;
--color-primary: hsl(43, 50%, 70%);
--color-positive: var(--color-primary);
--color-negative: hsl(0, 70%, 70%);
--color-background: hsl(var(--bghs), var(--bgl));
--color-widget-background-hsl-values: var(--bghs), calc(var(--bgl) + 1%);
--color-widget-background: hsl(var(--color-widget-background-hsl-values));
--color-separator: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 4% * var(--cm))));
--color-widget-content-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
--color-widget-background-highlight: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 4%)));
--color-popover-background: hsl(var(--bgh), calc(var(--bgs) + 3%), calc(var(--bgl) + 3%));
--color-popover-border: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 12%)));
--color-progress-border: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 10% * var(--cm))));
--color-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 26% * var(--cm))));
--color-vertical-progress-value: hsl(var(--bgh), calc(var(--bgs) * var(--tsm)), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 28% * var(--cm))));
--color-graph-gridlines: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 6% * var(--cm))));
--ths: var(--bgh), calc(var(--bgs) * var(--tsm));
--color-text-highlight: hsl(var(--ths), calc(var(--scheme) var(--cm) * 85%));
--color-text-paragraph: hsl(var(--ths), calc(var(--scheme) var(--cm) * 73%));
--color-text-base: hsl(var(--ths), calc(var(--scheme) var(--cm) * 58%));
--color-text-base-muted: hsl(var(--ths), calc(var(--scheme) var(--cm) * 52%));
--color-text-subdue: hsl(var(--ths), calc(var(--scheme) var(--cm) * 35%));
--font-size-h1: 1.7rem;
--font-size-h2: 1.6rem;
--font-size-h3: 1.5rem;
--font-size-h4: 1.4rem;
--font-size-base: 1.3rem;
--font-size-h5: 1.2rem;
--font-size-h6: 1.1rem;
}
/* Do not change the order of the below imports unless you know what you're doing */
@import "site.css";
@import "widgets.css";
@import "popover.css";
@import "utils.css";
@import "mobile.css";
@import "edit-mode.css";
+235
View File
@@ -0,0 +1,235 @@
@media (max-width: 1190px) {
.header-container {
display: none;
}
.page-column-small .size-title-dynamic {
font-size: var(--font-size-h3);
}
.page-column-small {
width: 100%;
flex-shrink: 1;
}
.page-column {
display: none;
animation: columnEntrance .0s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
.page-columns-transitioned .page-column {
animation-duration: .3s;
}
@keyframes columnEntrance {
from {
opacity: 0;
transform: scaleX(0.95);
}
}
.mobile-navigation-offset {
height: var(--mobile-navigation-height);
flex-shrink: 0;
}
.mobile-navigation {
display: block;
position: fixed;
bottom: 0;
transform: translateY(calc(100% - var(--mobile-navigation-height)));
left: var(--content-bounds-padding);
right: var(--content-bounds-padding);
z-index: 11;
background-color: var(--color-widget-background);
border: 1px solid var(--color-widget-content-border);
border-bottom: 0;
border-radius: var(--border-radius) var(--border-radius) 0 0;
transition: transform .3s;
}
.mobile-navigation-actions > * {
padding-block: 1.1rem;
padding-inline: var(--content-bounds-padding);
cursor: pointer;
transition: background-color 50ms;
}
.mobile-navigation-actions > *:active {
background-color: var(--color-widget-background-highlight);
}
.mobile-navigation:has(.mobile-navigation-page-links-input:checked) .hamburger-icon {
--spacing: 7px;
color: var(--color-primary);
height: 2px;
}
.mobile-navigation:has(.mobile-navigation-page-links-input:checked) {
transform: translateY(0);
}
.mobile-navigation-page-links {
border-top: 1px solid var(--color-widget-content-border);
border-bottom: 1px solid var(--color-widget-content-border);
padding: 20px var(--content-bounds-padding);
display: flex;
align-items: center;
overflow-x: auto;
scrollbar-width: thin;
gap: 2.5rem;
}
.mobile-navigation-icons {
display: flex;
justify-content: space-around;
align-items: center;
}
body:has(.mobile-navigation-input[value="0"]:checked) .page-columns > :nth-child(1),
body:has(.mobile-navigation-input[value="1"]:checked) .page-columns > :nth-child(2),
body:has(.mobile-navigation-input[value="2"]:checked) .page-columns > :nth-child(3) {
display: block;
}
.mobile-navigation-label {
display: flex;
flex: 1;
max-width: 50px;
height: var(--mobile-navigation-height);
justify-content: center;
align-items: center;
cursor: pointer;
font-size: 15px;
line-height: var(--mobile-navigation-height);
}
.mobile-navigation-pill {
display: block;
background: var(--color-text-base);
height: 10px;
width: 10px;
border-radius: 10px;
transition: width .3s, background-color .3s;
}
.mobile-navigation-label:hover > .mobile-navigation-pill {
background-color: var(--color-text-highlight);
}
.mobile-navigation-label:hover {
color: var(--color-text-highlight);
}
.mobile-navigation-input:checked + .mobile-navigation-pill {
background: var(--color-primary);
width: 30px;
}
.mobile-navigation-input, .mobile-navigation-page-links-input {
display: none;
}
.hamburger-icon {
--spacing: 4px;
width: 1em;
height: 1px;
background-color: currentColor;
transition: color .3s, box-shadow .3s;
box-shadow: 0 calc(var(--spacing) * -1) 0 0 currentColor, 0 var(--spacing) 0 0 currentColor;
}
.expand-toggle-button.container-expanded {
bottom: var(--mobile-navigation-height);
}
.cards-grid + .expand-toggle-button.container-expanded {
/* hides content that peeks through the rounded borders of the mobile navigation */
box-shadow: 0 var(--border-radius) 0 0 var(--color-background);
}
.weather-column-rain::before {
background-size: 7px 7px;
}
.ios .search-input {
/* so that iOS Safari does not zoom the page when the input is focused */
font-size: 16px;
}
}
@media (max-width: 1190px) and (display-mode: standalone) {
:root {
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0);
}
.ios .body-content {
height: 100dvh;
}
.expand-toggle-button.container-expanded {
bottom: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
}
.mobile-navigation {
transform: translateY(calc(100% - var(--mobile-navigation-height) - var(--safe-area-inset-bottom)));
padding-bottom: var(--safe-area-inset-bottom);
}
.mobile-navigation-icons {
padding-bottom: var(--safe-area-inset-bottom);
transition: padding-bottom .3s;
}
.mobile-navigation-offset {
height: calc(var(--mobile-navigation-height) + var(--safe-area-inset-bottom));
}
.mobile-navigation-icons:has(.mobile-navigation-page-links-input:checked) {
padding-bottom: 0;
}
}
@media (display-mode: standalone) {
body {
padding-top: env(safe-area-inset-top, 0);
}
}
@media (max-width: 550px) {
:root {
font-size: 9.4px;
--widget-gap: 15px;
--widget-content-vertical-padding: 10px;
--widget-content-horizontal-padding: 10px;
--content-bounds-padding: 10px;
}
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
.row-reverse-on-mobile {
flex-direction: row-reverse;
}
.hide-on-mobile, .thumbnail-container:has(> .hide-on-mobile) {
display: none
}
.mobile-reachability-header {
display: block;
font-size: 3rem;
padding: 10vh 1rem;
text-align: center;
color: var(--color-text-highlight);
animation: pageColumnsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
.rss-detailed-thumbnail > * {
height: 6rem;
}
.rss-detailed-description {
line-clamp: 3;
-webkit-line-clamp: 3;
}
}
+65
View File
@@ -0,0 +1,65 @@
.popover-container, [data-popover-html] {
display: none;
}
.popover-container {
--triangle-size: 10px;
--triangle-offset: 50%;
--triangle-margin: calc(var(--triangle-size) + 3px);
--entrance-y-offset: 8px;
--entrance-direction: calc(var(--entrance-y-offset) * -1);
z-index: 20;
position: absolute;
padding-top: var(--triangle-margin);
padding-inline: var(--content-bounds-padding);
}
.popover-container.position-above {
--entrance-direction: var(--entrance-y-offset);
padding-top: 0;
padding-bottom: var(--triangle-margin);
}
.popover-frame {
--shadow-properties: 0 15px 20px -10px;
--shadow-color: hsla(var(--bghs), calc(var(--bgl) * 0.2), 0.5);
position: relative;
padding: 10px;
background: var(--color-popover-background);
border: 1px solid var(--color-popover-border);
border-radius: 5px;
animation: popoverFrameEntrance 0.3s backwards cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: var(--shadow-properties) var(--shadow-color);
}
.popover-frame::before {
content: '';
position: absolute;
width: var(--triangle-size);
height: var(--triangle-size);
transform: rotate(45deg);
background-color: var(--color-popover-background);
border-top-left-radius: 2px;
border-left: 1px solid var(--color-popover-border);
border-top: 1px solid var(--color-popover-border);
left: calc(var(--triangle-offset) - (var(--triangle-size) / 2));
top: calc(var(--triangle-size) / 2 * -1 - 1px);
}
.popover-container.position-above .popover-frame::before {
transform: rotate(-135deg);
top: auto;
bottom: calc(var(--triangle-size) / 2 * -1 - 1px);
}
.popover-container.position-above .popover-frame {
--shadow-properties: 0 10px 20px -10px;
}
@keyframes popoverFrameEntrance {
from {
opacity: 0;
transform: translateY(var(--entrance-direction));
}
}
+398
View File
@@ -0,0 +1,398 @@
:root[data-scheme=light] {
--scheme: 100% -;
}
.page {
height: 100%;
padding-block: var(--widget-gap);
}
.page-content, .page.content-ready .page-loading-container {
display: none;
}
.page.content-ready > .page-content {
display: block;
animation: pageContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
.page-column-small .size-title-dynamic {
font-size: var(--font-size-h4);
}
.page-column-full .size-title-dynamic {
font-size: var(--font-size-h3);
}
pre {
font: inherit;
}
input[type="text"] {
width: 100%;
border: 0;
background: none;
font: inherit;
color: inherit;
}
button {
font: inherit;
border: 0;
cursor: pointer;
background: none;
color: inherit;
}
::selection {
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl) + 20%)));
color: var(--color-text-highlight);
}
::-webkit-scrollbar-thumb {
background: var(--color-text-subdue);
border-radius: var(--border-radius);
}
::-webkit-scrollbar {
background: var(--color-background);
height: 5px;
width: 10px;
}
*:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 0.1rem;
border-radius: var(--border-radius);
}
*, *::before, *::after {
box-sizing: border-box;
}
* {
padding: 0;
margin: 0;
}
hr {
border: 0;
height: 1px;
background-color: var(--color-separator);
}
img, svg {
display: block;
max-width: 100%;
}
img[loading=lazy].loaded:not(.finished-transition) {
transition: opacity .4s;
}
img[loading=lazy].cached:not(.finished-transition) {
transition: none;
}
img[loading=lazy]:not(.loaded, .cached) {
opacity: 0;
}
html {
scrollbar-color: var(--color-text-subdue) transparent;
scroll-behavior: smooth;
}
html, body, .body-content {
height: 100%;
}
h1, h2, h3, h4, h5 {
font: inherit;
}
a {
text-decoration: none;
color: inherit;
overflow-wrap: break-word;
}
ul {
list-style: none;
}
body {
font-size: 1.3rem;
font-family: 'JetBrains Mono', monospace;
font-variant-ligatures: none;
line-height: 1.6;
color: var(--color-text-base);
background-color: var(--color-background);
overflow-y: scroll;
}
.page-column-small {
width: 300px;
flex-shrink: 0;
}
.page-column-full {
width: 100%;
min-width: 0;
}
.page-columns {
display: flex;
gap: var(--widget-gap);
}
@keyframes pageContentEntrance {
from {
opacity: 0;
transform: translateY(10px);
}
}
.page-loading-container {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
animation: loadingContainerEntrance 200ms backwards;
animation-delay: 150ms;
font-size: 2rem;
}
.page-loading-container > .loading-icon {
translate: 0 -250%;
}
@keyframes loadingContainerEntrance {
from {
/* Using 0.001 instead of 0 fixes a random 1s freeze on Chrome on page load when all */
/* elements have opacity 0 and are animated in. I don't want to be a web dev anymore. */
opacity: 0.001;
}
}
.loading-icon {
min-width: 1.5em;
width: 1.5em;
height: 1.5em;
border: 0.25em solid hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 12%)));
border-top-color: hsl(var(--bghs), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 40%)));
border-radius: 50%;
animation: loadingIconSpin 800ms infinite linear;
}
@keyframes loadingIconSpin {
to {
transform: rotate(360deg);
}
}
.notice-icon {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
}
.notice-icon-major {
background: var(--color-negative);
}
.notice-icon-minor {
border: 1px solid var(--color-negative);
}
kbd {
font: inherit;
padding: 0.1rem 0.8rem;
border-radius: var(--border-radius);
border: 2px solid var(--color-widget-background-highlight);
box-shadow: 0 2px 0 var(--color-widget-background-highlight);
user-select: none;
transition: transform .1s, box-shadow .1s;
font-size: var(--font-size-h5);
cursor: pointer;
}
kbd:active {
transform: translateY(2px);
box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
}
.content-bounds {
max-width: 1600px;
width: 100%;
margin-inline: auto;
padding: 0 var(--content-bounds-padding);
}
.content-bounds-wide {
max-width: 1920px;
}
.content-bounds-slim {
max-width: 1100px;
}
.page.center-vertically {
display: flex;
justify-content: center;
flex-direction: column;
}
.header-container {
margin-top: calc(var(--widget-gap) / 2);
--header-height: 45px;
--header-items-gap: 2.5rem;
}
.header {
display: flex;
height: var(--header-height);
gap: var(--header-items-gap);
}
.logo {
height: 100%;
flex-shrink: 0;
line-height: var(--header-height);
font-size: 2rem;
color: var(--color-text-highlight);
border-right: 1px solid var(--color-widget-content-border);
padding-right: var(--widget-content-horizontal-padding);
}
.logo:has(img, svg) {
display: flex;
align-items: center;
}
.logo img {
max-height: 2.7rem;
}
.nav {
overflow-x: auto;
min-width: 0;
height: 100%;
gap: var(--header-items-gap);
}
.nav .nav-item {
line-height: var(--header-height);
}
.footer {
padding-bottom: calc(var(--widget-gap) * 1.5);
padding-top: calc(var(--widget-gap) / 2);
animation: loadingContainerEntrance 200ms backwards;
animation-delay: 150ms;
}
.mobile-navigation, .mobile-reachability-header {
display: none;
}
.nav-item {
display: block;
height: 100%;
border-bottom: 2px solid transparent;
transition: color .3s, border-color .3s;
font-size: var(--font-size-h3);
flex-shrink: 0;
}
.nav-item:not(.nav-item-current):hover {
border-bottom-color: var(--color-text-subdue);
color: var(--color-text-highlight);
}
.nav-item.nav-item-current {
border-bottom-color: var(--color-primary);
color: var(--color-text-highlight);
}
.logout-button {
width: 2rem;
height: 2rem;
stroke: var(--color-text-subdue);
transition: stroke .2s;
}
.logout-button:hover, .logout-button:focus {
stroke: var(--color-text-highlight);
}
.theme-choices {
--presets-per-row: 2;
display: grid;
grid-template-columns: repeat(var(--presets-per-row), 1fr);
align-items: center;
gap: 1.35rem;
}
.theme-choices:has(> :nth-child(3)) {
--presets-per-row: 3;
}
.theme-preset {
background-color: var(--color);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 2rem;
padding-inline: 0.5rem;
border-radius: 0.3rem;
border: none;
cursor: pointer;
position: relative;
}
.theme-choices .theme-preset::before {
content: '';
position: absolute;
inset: -.4rem;
border-radius: .7rem;
border: 2px solid transparent;
transition: border-color .3s;
}
.theme-choices .theme-preset:hover::before {
border-color: var(--color-text-subdue);
}
.theme-choices .theme-preset.current::before {
border-color: var(--color-text-base);
}
.theme-preset-light {
gap: 0.3rem;
height: 1.8rem;
}
.theme-color {
background-color: var(--color);
width: 0.9rem;
height: 0.9rem;
border-radius: 0.2rem;
}
.theme-preset-light .theme-color {
width: 1rem;
height: 1rem;
border-radius: 0.3rem;
}
.current-theme-preview {
opacity: 0.4;
transition: opacity .3s;
}
.theme-picker.popover-active .current-theme-preview, .theme-picker:hover {
opacity: 1;
}
+637
View File
@@ -0,0 +1,637 @@
.masonry {
display: flex;
gap: var(--widget-gap);
}
.masonry-column {
flex: 1;
display: flex;
flex-direction: column;
}
.widget-small-content-bounds {
max-width: 350px;
margin: 0 auto;
}
.visually-hidden {
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
.list-horizontal-text {
display: flex;
list-style: none;
flex-wrap: wrap;
align-items: center;
}
.list-horizontal-text > *:not(:last-child)::after {
content: '•' / "";
color: var(--color-text-subdue);
margin: 0 0.4rem;
position: relative;
top: 0.1rem;
}
.summary {
width: 100%;
cursor: pointer;
word-spacing: -0.18em;
user-select: none;
list-style: none;
position: relative;
display: flex;
z-index: 1;
}
.summary::-webkit-details-marker {
display: none;
}
.details[open] .summary {
margin-bottom: .8rem;
}
.summary::before {
content: "";
position: absolute;
inset: -.3rem -.8rem;
border-radius: var(--border-radius);
background-color: var(--color-widget-background-highlight);
opacity: 0;
transition: opacity 0.2s;
z-index: -1;
}
.details[open] .summary::before, .summary:hover::before {
opacity: 1;
}
.details:not([open]) .list-with-transition {
display: none;
}
.summary::after {
content: "◀" / "";
font-size: 1.2em;
position: absolute;
top: 0;
bottom: 0;
line-height: 1.3em;
right: 0;
transition: rotate .5s cubic-bezier(0.22, 1, 0.36, 1);
}
details[open] .summary::after {
rotate: -90deg;
}
/* TODO: refactor, otherwise I hope I never have to change dynamic columns again */
.dynamic-columns {
--list-half-gap: 0.5rem;
gap: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
display: grid;
grid-template-columns: repeat(var(--columns-per-row), 1fr);
}
.dynamic-columns > * {
padding-left: var(--widget-content-horizontal-padding);
border-left: 1px solid var(--color-separator);
min-width: 0;
}
.dynamic-columns > *:first-child {
padding-top: 0;
border-top: none;
border-left: none;
}
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
.dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
.dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; }
@container widget (max-width: 599px) {
.dynamic-columns { gap: 0; }
.dynamic-columns:has(> :nth-child(1)) { --columns-per-row: 1; }
.dynamic-columns > * {
border-left: none;
padding-left: 0;
}
.dynamic-columns > *:not(:first-child) {
margin-top: calc(var(--list-half-gap) * 2);
}
.dynamic-columns.list-with-separator > *:not(:first-child) {
margin-top: var(--list-half-gap);
border-top: 1px solid var(--color-separator);
padding-top: var(--list-half-gap);
}
}
@container widget (min-width: 600px) and (max-width: 849px) {
.dynamic-columns:has(> :nth-child(2)) { --columns-per-row: 2; }
.dynamic-columns > :nth-child(2n-1) {
border-left: none;
padding-left: 0;
}
}
@container widget (min-width: 850px) and (max-width: 1249px) {
.dynamic-columns:has(> :nth-child(3)) { --columns-per-row: 3; }
.dynamic-columns > :nth-child(3n+1) {
border-left: none;
padding-left: 0;
}
}
@container widget (min-width: 1250px) and (max-width: 1499px) {
.dynamic-columns:has(> :nth-child(4)) { --columns-per-row: 4; }
.dynamic-columns > :nth-child(4n+1) {
border-left: none;
padding-left: 0;
}
}
@container widget (min-width: 1500px) {
.dynamic-columns:has(> :nth-child(5)) { --columns-per-row: 5; }
.dynamic-columns > :nth-child(5n+1) {
border-left: none;
padding-left: 0;
}
}
.cards-vertical {
flex-direction: column;
}
.cards-horizontal {
--cards-per-row: 6.5;
}
.cards-horizontal, .cards-vertical {
--cards-gap: calc(var(--widget-content-vertical-padding) * 0.7);
display: flex;
gap: var(--cards-gap);
}
.card {
display: flex;
flex-direction: column;
}
.cards-horizontal .card {
flex-shrink: 0;
width: calc(100% / var(--cards-per-row) - var(--cards-gap) * (var(--cards-per-row) - 1) / var(--cards-per-row));
}
.cards-grid .card {
min-width: 0;
}
.cards-horizontal {
overflow-x: auto;
scrollbar-width: thin;
padding-bottom: 1rem;
}
.cards-grid {
--cards-per-row: 6;
display: grid;
grid-template-columns: repeat(var(--cards-per-row), 1fr);
gap: calc(var(--widget-content-vertical-padding) * 0.7);
}
@container widget (max-width: 1300px) { .cards-horizontal { --cards-per-row: 5.5; } }
@container widget (max-width: 1100px) { .cards-horizontal { --cards-per-row: 4.5; } }
@container widget (max-width: 850px) { .cards-horizontal { --cards-per-row: 3.5; } }
@container widget (max-width: 750px) { .cards-horizontal { --cards-per-row: 3.5; } }
@container widget (max-width: 650px) { .cards-horizontal { --cards-per-row: 2.5; } }
@container widget (max-width: 450px) { .cards-horizontal { --cards-per-row: 2.3; } }
@container widget (max-width: 1300px) { .cards-grid { --cards-per-row: 5; } }
@container widget (max-width: 1100px) { .cards-grid { --cards-per-row: 4; } }
@container widget (max-width: 850px) { .cards-grid { --cards-per-row: 3; } }
@container widget (max-width: 750px) { .cards-grid { --cards-per-row: 3; } }
@container widget (max-width: 650px) { .cards-grid { --cards-per-row: 2; } }
.text-truncate,
.single-line-titles .title
{
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.single-line-titles .title {
display: block;
}
.text-truncate-2-lines, .text-truncate-3-lines {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
}
.text-truncate-3-lines { line-clamp: 3; -webkit-line-clamp: 3; }
.text-truncate-2-lines { line-clamp: 2; -webkit-line-clamp: 2; }
.visited-indicator:not(.text-truncate)::after,
.visited-indicator.text-truncate::before {
content: '↗' / "";
margin-left: 0.5em;
display: inline-block;
position: relative;
top: 0.15em;
color: var(--color-text-base);
}
.visited-indicator.text-truncate {
direction: rtl;
text-align: left;
}
.visited-indicator:not(:visited)::before, .visited-indicator:not(:visited)::after {
color: var(--color-primary);
}
.page-columns-transitioned .list-with-transition > * { animation: collapsibleItemReveal .25s backwards; }
.list-with-transition > *:nth-child(2) { animation-delay: 30ms; }
.list-with-transition > *:nth-child(3) { animation-delay: 60ms; }
.list-with-transition > *:nth-child(4) { animation-delay: 90ms; }
.list-with-transition > *:nth-child(5) { animation-delay: 120ms; }
.list-with-transition > *:nth-child(6) { animation-delay: 150ms; }
.list-with-transition > *:nth-child(7) { animation-delay: 180ms; }
.list-with-transition > *:nth-child(8) { animation-delay: 210ms; }
.list > *:not(:first-child) {
margin-top: calc(var(--list-half-gap) * 2);
}
.list.list-with-separator > *:not(:first-child) {
margin-top: var(--list-half-gap);
border-top: 1px solid var(--color-separator);
padding-top: var(--list-half-gap);
}
.collapsible-container:not(.container-expanded) > .collapsible-item {
display: none;
}
.collapsible-item {
animation: collapsibleItemReveal .25s backwards;
}
@keyframes collapsibleItemReveal {
from {
opacity: 0;
transform: translateY(10px);
}
}
.expand-toggle-button {
font: inherit;
border: 0;
cursor: pointer;
display: block;
width: 100%;
text-align: left;
color: var(--color-text-base);
text-transform: uppercase;
font-size: var(--font-size-h4);
padding: var(--widget-content-vertical-padding) 0;
background: var(--color-widget-background);
}
.expand-toggle-button.container-expanded {
position: sticky;
/* -1px to hide 1px gap on chrome */
bottom: -1px;
}
.expand-toggle-button-icon {
display: inline-block;
margin-left: 1rem;
position: relative;
top: -.2rem;
}
.expand-toggle-button-icon::before {
content: '' / "";
font-size: 0.8rem;
transform: rotate(90deg);
line-height: 1;
display: inline-block;
transition: transform 0.3s;
}
.expand-toggle-button.container-expanded .expand-toggle-button-icon::before {
transform: rotate(-90deg);
}
.cards-grid.collapsible-container + .expand-toggle-button {
text-align: center;
margin-top: 0.5rem;
background-color: var(--color-background);
}
.widget-content:has(.expand-toggle-button:last-child) {
padding-bottom: 0;
}
.carousel-container {
position: relative;
}
.carousel-container::before, .carousel-container::after {
content: '';
position: absolute;
width: 2rem;
top: 0;
bottom: 1rem;
z-index: 10;
opacity: 0;
pointer-events: none;
transition-duration: 0.2s;
}
.carousel-container::before {
background: linear-gradient(to right, var(--color-background), transparent);
}
.carousel-container::after {
right: 0;
background: linear-gradient(to left, var(--color-background), transparent);
}
.carousel-container.show-left-cutoff::before, .carousel-container.show-right-cutoff::after {
opacity: 1;
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
:root:not([data-scheme=light]) .flat-icon {
filter: invert(1);
}
.attachments > * {
border-radius: var(--border-radius);
padding: 0.1rem 0.5rem;
font-size: var(--font-size-h6);
background-color: var(--color-separator);
}
.progress-bar {
border: 1px solid var(--color-progress-border);
border-radius: var(--border-radius);
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px;
height: 1.5rem;
/* naughty, but oh so beautiful */
margin-inline: -3px;
}
.progress-bar-combined {
height: 3rem;
}
.popover-active > .progress-bar {
transition: border-color .3s;
border-color: var(--color-text-subdue);
}
.progress-value {
--half-border-radius: calc(var(--border-radius) / 2);
border-radius: 0 var(--half-border-radius) var(--half-border-radius) 0;
background: var(--color-progress-value);
width: calc(var(--percent) * 1%);
min-width: 1px;
flex: 1;
}
.progress-value:first-child {
border-top-left-radius: var(--half-border-radius);
}
.progress-value:last-child {
border-bottom-left-radius: var(--half-border-radius);
}
.progress-value-notice {
background: linear-gradient(to right, var(--color-progress-value) 65%, var(--color-negative));
}
.value-separator {
min-width: 2rem;
margin-inline: 0.8rem;
flex: 1;
height: calc(1em * 1.1);
border-bottom: 1px dotted var(--color-text-subdue);
}
.thumbnail {
filter: grayscale(0.2) contrast(0.9);
opacity: 0.8;
transition: filter 0.2s, opacity .2s;
}
.thumbnail-container {
flex-shrink: 0;
border: 1px solid var(--color-separator);
border-radius: var(--border-radius);
}
.thumbnail-container > * {
border-radius: var(--border-radius);
object-fit: cover;
}
.thumbnail-parent:hover .thumbnail {
opacity: 1;
filter: none;
}
.hide-scrollbars {
scrollbar-width: none;
}
/* Hide on Safari and Chrome */
.hide-scrollbars::-webkit-scrollbar {
display: none;
}
.ui-icon {
width: 2.3rem;
height: 2.3rem;
display: block;
flex-shrink: 0;
}
.size-h1 { font-size: var(--font-size-h1); }
.size-h2 { font-size: var(--font-size-h2); }
.size-h3 { font-size: var(--font-size-h3); }
.size-h4 { font-size: var(--font-size-h4); }
.size-base { font-size: var(--font-size-base); }
.size-h5 { font-size: var(--font-size-h5); }
.size-h6 { font-size: var(--font-size-h6); }
.color-highlight { color: var(--color-text-highlight); }
.color-paragraph { color: var(--color-text-paragraph); }
.color-base { color: var(--color-text-base); }
.color-subdue { color: var(--color-text-subdue); }
.color-negative { color: var(--color-negative); }
.color-positive { color: var(--color-positive); }
.color-primary { color: var(--color-primary); }
.color-primary-if-not-visited:not(:visited) {
color: var(--color-primary);
}
.drag-and-drop-container {
position: relative;
}
.drag-and-drop-decoy {
outline: 1px dashed var(--color-primary);
opacity: 0.25;
border-radius: var(--border-radius);
}
.drag-and-drop-draggable {
position: absolute;
cursor: grabbing !important;
}
.drag-and-drop-draggable:empty {
display: none;
}
.drag-and-drop-draggable * {
cursor: grabbing !important;
}
.auto-scaling-textarea-container {
position: relative;
}
.auto-scaling-textarea {
position: absolute;
inset: 0;
background: none;
border: none;
font: inherit;
resize: none;
color: inherit;
overflow: hidden;
}
.auto-scaling-textarea:focus {
outline: none;
}
.auto-scaling-textarea-mimic {
white-space: pre-wrap;
min-height: 1lh;
user-select: none;
word-wrap: break-word;
font: inherit;
visibility: hidden;
}
.cursor-help { cursor: help; }
.rounded { border-radius: var(--border-radius); }
.break-all { word-break: break-all; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.text-center { text-align: center; }
.text-elevate { margin-top: -0.2em; }
.text-compact { word-spacing: -0.18em; }
.text-very-compact { word-spacing: -0.35em; }
.rtl { direction: rtl; }
.shrink { flex-shrink: 1; }
.shrink-0 { flex-shrink: 0; }
.min-width-0 { min-width: 0; }
.max-width-100 { max-width: 100%; }
.block { display: block; }
.inline-block { display: inline-block; }
.overflow-hidden { overflow: hidden; }
.relative { position: relative; }
.flex { display: flex; }
.flex-1 { flex: 1; }
.flex-wrap { flex-wrap: wrap; }
.flex-nowrap { flex-wrap: nowrap; }
.justify-between { justify-content: space-between; }
.justify-stretch { justify-content: stretch; }
.justify-evenly { justify-content: space-evenly; }
.justify-center { justify-content: center; }
.justify-end { justify-content: end; }
.uppercase { text-transform: uppercase; }
.grow { flex-grow: 1; }
.flex-column { flex-direction: column; }
.items-center { align-items: center; }
.self-center { align-self: center; }
.items-start { align-items: start; }
.items-end { align-items: end; }
.gap-5 { gap: 0.5rem; }
.gap-7 { gap: 0.7rem; }
.gap-10 { gap: 1rem; }
.gap-12 { gap: 1.2rem; }
.gap-15 { gap: 1.5rem; }
.gap-20 { gap: 2rem; }
.gap-25 { gap: 2.5rem; }
.gap-35 { gap: 3.5rem; }
.gap-45 { gap: 4.5rem; }
.gap-55 { gap: 5.5rem; }
.margin-left-auto { margin-left: auto; }
.margin-top-3 { margin-top: 0.3rem; }
.margin-top-5 { margin-top: 0.5rem; }
.margin-top-7 { margin-top: 0.7rem; }
.margin-top-10 { margin-top: 1rem; }
.margin-top-15 { margin-top: 1.5rem; }
.margin-top-20 { margin-top: 2rem; }
.margin-top-25 { margin-top: 2.5rem; }
.margin-top-35 { margin-top: 3.5rem; }
.margin-top-40 { margin-top: 4rem; }
.margin-top-auto { margin-top: auto; }
.margin-block-3 { margin-block: 0.3rem; }
.margin-block-5 { margin-block: 0.5rem; }
.margin-block-7 { margin-block: 0.7rem; }
.margin-block-8 { margin-block: 0.8rem; }
.margin-block-10 { margin-block: 1rem; }
.margin-block-15 { margin-block: 1.5rem; }
.margin-bottom-3 { margin-bottom: 0.3rem; }
.margin-bottom-5 { margin-bottom: 0.5rem; }
.margin-bottom-7 { margin-bottom: 0.7rem; }
.margin-bottom-10 { margin-bottom: 1rem; }
.margin-bottom-15 { margin-bottom: 1.5rem; }
.margin-bottom-auto { margin-bottom: auto; }
.margin-bottom-widget { margin-bottom: var(--widget-content-vertical-padding); }
.padding-widget { padding: var(--widget-content-padding); }
.padding-block-widget { padding-block: var(--widget-content-vertical-padding); }
.padding-inline-widget { padding-inline: var(--widget-content-horizontal-padding); }
.pointer-events-none { pointer-events: none; }
.select-none { user-select: none; }
.padding-block-5 { padding-block: 0.5rem; }
.scale-half { transform: scale(0.5); }
.list { --list-half-gap: 0rem; }
.list-gap-2 { --list-half-gap: 0.1rem; }
.list-gap-4 { --list-half-gap: 0.2rem; }
.list-gap-8 { --list-half-gap: 0.4rem; }
.list-gap-10 { --list-half-gap: 0.5rem; }
.list-gap-14 { --list-half-gap: 0.7rem; }
.list-gap-20 { --list-half-gap: 1rem; }
.list-gap-24 { --list-half-gap: 1.2rem; }
.list-gap-34 { --list-half-gap: 1.7rem; }
@media (max-width: 1190px) {
.size-base-on-mobile { font-size: var(--font-size-base); }
}
@@ -0,0 +1,31 @@
.bookmarks-group {
--bookmarks-group-color: var(--color-primary);
}
.bookmarks-group-title {
color: var(--bookmarks-group-color);
}
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
content: '↗' / "";
margin-left: 0.5em;
display: inline-block;
position: relative;
top: 0.15em;
color: var(--bookmarks-group-color);
}
.bookmarks-icon-container {
margin-block: 0.1rem;
background-color: var(--color-widget-background-highlight);
border-radius: var(--border-radius);
padding: 0.5rem;
opacity: 0.7;
flex-shrink: 0;
}
.bookmarks-icon {
width: 20px;
height: 20px;
opacity: 0.8;
}
@@ -0,0 +1,71 @@
.old-calendar-day {
width: calc(100% / 7);
text-align: center;
padding: 0.6rem 0;
}
.old-calendar-day-today {
border-radius: var(--border-radius);
background-color: hsl(var(--bghs), calc(var(--scheme) (var(--scheme) (var(--bgl)) + 6%)));
color: var(--color-text-highlight);
}
.calendar-dates {
text-align: center;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.calendar-date {
padding: 0.4rem 0;
color: var(--color-text-base);
position: relative;
border-radius: var(--border-radius);
background: none;
border: none;
font: inherit;
}
.calendar-current-date {
border-radius: var(--border-radius);
background-color: var(--color-popover-border);
color: var(--color-text-highlight);
}
.calendar-spillover-date {
color: var(--color-text-subdue);
}
.calendar-header-button {
position: relative;
cursor: pointer;
width: 2rem;
height: 2rem;
z-index: 1;
background: none;
border: none;
}
.calendar-header-button::before {
content: '';
position: absolute;
inset: -0.2rem;
border-radius: var(--border-radius);
background-color: var(--color-text-subdue);
opacity: 0;
transition: opacity 0.2s;
z-index: -1;
}
.calendar-header-button:hover::before {
opacity: 0.4;
}
.calendar-undo-button {
display: inline-block;
vertical-align: text-top;
width: 2rem;
height: 2rem;
margin-left: 0.7rem;
}
@@ -0,0 +1,7 @@
.clock-time {
min-width: 8ch;
}
.clock-time span {
color: var(--color-text-highlight);
}
@@ -0,0 +1,120 @@
.dns-stats-totals {
transition: opacity .3s;
transition-delay: 50ms;
}
.dns-stats:has(.dns-stats-graph .popover-active) .dns-stats-totals {
opacity: 0.1;
transition-delay: 0s;
}
.dns-stats-graph {
--graph-height: 70px;
height: var(--graph-height);
position: relative;
margin-bottom: 2.5rem;
}
.dns-stats-graph-gridlines-container {
position: absolute;
inset: 0;
}
.dns-stats-graph-gridlines {
height: 100%;
width: 100%;
}
.dns-stats-graph-columns {
display: flex;
height: 100%;
}
.dns-stats-graph-column {
display: flex;
justify-content: flex-end;
align-items: center;
flex-direction: column;
width: calc(100% / 8);
position: relative;
}
.dns-stats-graph-column::before {
content: '';
position: absolute;
inset: 1px 0;
opacity: 0;
background: var(--color-text-base);
transition: opacity .2s;
}
.dns-stats-graph-column:hover::before {
opacity: 0.05;
}
.dns-stats-graph-bar {
width: 14px;
height: calc((var(--bar-height) / 100) * var(--graph-height));
border: 1px solid var(--color-progress-border);
border-radius: var(--border-radius) var(--border-radius) 0 0;
display: flex;
background: var(--color-widget-background);
padding: 2px 2px 0 2px;
flex-direction: column;
gap: 2px;
transition: border-color .2s;
min-height: 10px;
}
.dns-stats-graph-column.popover-active .dns-stats-graph-bar {
border-color: var(--color-text-subdue);
border-bottom-color: var(--color-progress-border);
}
.dns-stats-graph-bar > * {
border-radius: 2px;
background: var(--color-vertical-progress-value);
min-height: 1px;
}
.dns-stats-graph-bar > .queries {
flex-grow: 1;
}
.dns-stats-graph-bar > *:last-child {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.dns-stats-graph-bar > .blocked {
background-color: var(--color-negative);
flex-basis: calc(var(--percent) - 1px);
}
.dns-stats-graph-column:nth-child(even) .dns-stats-graph-time {
opacity: 1;
transform: translateY(0);
}
.dns-stats-graph-time, .dns-stats-graph-columns:hover .dns-stats-graph-time {
position: absolute;
font-size: var(--font-size-h6);
inset-inline: 0;
text-align: center;
height: 2.5rem;
line-height: 2.5rem;
top: 100%;
user-select: none;
opacity: 0;
transform: translateY(-0.5rem);
transition: opacity .2s, transform .2s;
}
.dns-stats-graph-column:hover .dns-stats-graph-time {
opacity: 1;
transform: translateY(0);
}
.dns-stats-graph-columns:hover .dns-stats-graph-column:not(:hover) .dns-stats-graph-time {
opacity: 0;
}
@@ -0,0 +1,26 @@
.docker-container-icon {
display: block;
filter: grayscale(0.4);
object-fit: contain;
aspect-ratio: 1 / 1;
width: 2.7rem;
opacity: 0.8;
transition: filter 0.3s, opacity 0.3s;
}
.docker-container-icon.flat-icon {
opacity: 0.7;
}
.docker-container:hover .docker-container-icon {
opacity: 1;
}
.docker-container:hover .docker-container-icon:not(.flat-icon) {
filter: grayscale(0);
}
.docker-container-status-icon {
width: 2rem;
height: 2rem;
}
@@ -0,0 +1,49 @@
.widget-group-header {
overflow-x: auto;
scrollbar-width: thin;
}
.widget-group-title {
background: none;
font: inherit;
border: none;
text-transform: uppercase;
border-bottom: 1px dotted transparent;
cursor: pointer;
flex-shrink: 0;
transition: color .3s, border-color .3s;
color: var(--color-text-subdue);
line-height: calc(1.6em - 1px);
}
.widget-group-title:hover:not(.widget-group-title-current) {
color: var(--color-text-base);
}
.widget-group-title-current {
border-bottom-color: var(--color-text-base-muted);
color: var(--color-text-base);
}
.widget-group-content {
animation: widgetGroupContentEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
.widget-group-content[data-direction="right"] {
--direction: 5px;
}
.widget-group-content[data-direction="left"] {
--direction: -5px;
}
@keyframes widgetGroupContentEntrance {
from {
opacity: 0;
transform: translateX(var(--direction));
}
}
.widget-group-content:not(.widget-group-content-current) {
display: none;
}
@@ -0,0 +1,13 @@
.market-chart {
margin-left: auto;
width: 6.5rem;
flex-shrink: 0;
}
.market-chart svg {
width: 100%;
}
.market-values {
min-width: 8rem;
}
@@ -0,0 +1,36 @@
.monitor-site-icon {
display: block;
opacity: 0.8;
filter: grayscale(0.4);
object-fit: contain;
aspect-ratio: 1 / 1;
width: 3.2rem;
position: relative;
top: -0.1rem;
transition: filter 0.3s, opacity 0.3s;
}
.monitor-site-icon.flat-icon {
opacity: 0.7;
}
.monitor-site:hover .monitor-site-icon {
opacity: 1;
}
.monitor-site:hover .monitor-site-icon:not(.flat-icon) {
filter: grayscale(0);
}
.monitor-site-status-icon {
flex-shrink: 0;
margin-left: auto;
width: 2rem;
height: 2rem;
}
.monitor-site-status-icon-compact {
width: 1.8rem;
height: 1.8rem;
flex-shrink: 0;
}
@@ -0,0 +1,22 @@
.reddit-card-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 0% 20%;
opacity: 0.15;
filter: blur(1px);
}
.reddit-card-thumbnail-container {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: var(--border-radius);
}
.reddit-card-thumbnail-container::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(0deg, var(--color-widget-background) 10%, transparent);
}
@@ -0,0 +1,6 @@
.release-source-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
opacity: 0.4;
}
+56
View File
@@ -0,0 +1,56 @@
.rss-card-image {
height: var(--rss-thumbnail-height, 10rem);
object-fit: cover;
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
.rss-card-2 {
position: relative;
height: var(--rss-card-height, 27rem);
overflow: hidden;
}
.rss-card-2::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image: linear-gradient(
0deg,
var(--color-widget-background),
hsla(var(--color-widget-background-hsl-values), 0.8) 6rem, transparent 14rem
);
z-index: 2;
}
.rss-card-2-image {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
/* +1px is required to fix some weird graphical bug where the image overflows on the bottom in firefox */
border-radius: calc(var(--border-radius) + 1px);
opacity: 0.9;
z-index: 1;
}
.rss-card-2-content {
position: absolute;
inset-inline: 0;
bottom: var(--widget-content-vertical-padding);
z-index: 3;
}
.rss-detailed-description {
max-width: 55rem;
color: var(--color-text-base-muted);
}
.rss-detailed-thumbnail {
margin-top: 0.3rem;
}
.rss-detailed-thumbnail > * {
aspect-ratio: 3 / 2;
height: 8.7rem;
}
@@ -0,0 +1,79 @@
.search-icon {
width: 2.3rem;
}
.search-icon-container {
position: relative;
flex-shrink: 0;
}
/* gives a wider hit area for the 3 people that will notice the animation : ) */
.search-icon-container::before {
content: '';
position: absolute;
inset: -1rem;
}
.search-icon-container:hover > .search-icon {
animation: searchIconHover 2.9s forwards;
}
@keyframes searchIconHover {
0%, 39% { translate: 0 0; }
20% { scale: 1.3; }
40% { scale: 1; }
50% { translate: -30% 30%; }
70% { translate: 30% -30%; }
90% { translate: -30% -30%; }
100% { translate: 0 0; }
}
.search {
transition: border-color .2s;
position: relative;
}
.search:hover {
border-color: var(--color-text-subdue);
}
.search:focus-within {
border-color: var(--color-primary);
}
.search-input {
border: 0;
background: none;
width: 100%;
height: 6rem;
font: inherit;
outline: none;
color: var(--color-text-highlight);
}
.search-input::placeholder {
color: var(--color-text-base-muted);
opacity: 1;
}
.search-bangs { display: none; }
.search-bang {
border-radius: calc(var(--border-radius) * 2);
background: var(--color-widget-background-highlight);
padding: 0.3rem 1rem;
flex-shrink: 0;
font-size: var(--font-size-h5);
animation: searchBangsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
}
@keyframes searchBangsEntrance {
0% {
opacity: 0;
transform: translateX(-10px);
}
}
.search-bang:empty {
display: none;
}
@@ -0,0 +1,81 @@
.widget-type-server-info {
position: relative;
}
.server + .server {
margin-top: 3rem;
}
.server {
gap: 1rem;
display: flex;
flex-direction: column;
}
.server-info {
align-items: center;
display: flex;
justify-content: space-between;
gap: 1.5rem;
flex-shrink: 1;
min-width: 0;
}
.server-details {
min-width: 0;
}
.server-icon {
height: 3rem;
width: 3rem;
}
.server-spicy-cpu-icon {
height: 1em;
align-self: center;
margin-left: 0.4em;
margin-bottom: 0.2rem;
}
.server-stats {
display: flex;
gap: 1.5rem;
margin-top: 0.5rem;
}
.server-stat-unavailable {
opacity: 0.5;
}
@container widget (min-width: 650px) {
.server {
gap: 2rem;
flex-direction: row;
align-items: center;
}
.server + .server {
margin-top: 1rem;
}
.server-info {
flex-direction: row-reverse;
justify-content: unset;
margin-right: auto;
z-index: 1;
}
.server-stats {
flex-direction: row;
justify-content: right;
min-width: 450px;
margin-top: 0;
gap: 2rem;
padding-bottom: 0.8rem;
z-index: 1;
}
.server-stats > * {
max-width: 200px;
}
}
+129
View File
@@ -0,0 +1,129 @@
.todo-widget {
padding-top: 4rem;
}
.todo-plus-icon {
--icon-color: var(--color-text-subdue);
position: relative;
width: 1.4rem;
height: 1.4rem;
}
.todo-plus-icon::before, .todo-plus-icon::after {
content: "";
position: absolute;
background-color: var(--icon-color);
transition: background-color .2s;
}
.todo-plus-icon::before {
width: 2px;
inset-block: 0.2rem;
left: 50%;
transform: translateX(-50%);
}
.todo-plus-icon::after {
height: 2px;
inset-inline: 0.2rem;
top: 50%;
transform: translateY(-50%);
}
.todo-input textarea::placeholder {
color: var(--color-text-base-muted);
}
.todo-input {
position: relative;
color: var(--color-text-highlight);
}
.todo-input:focus-within .todo-plus-icon {
--icon-color: var(--color-text-base);
}
.todo-item {
transform-origin: center;
padding: 0.5rem 0;
}
.todo-item-checkbox {
-webkit-appearance: none;
appearance: none;
border: 2px solid var(--color-text-subdue);
width: 1.4rem;
height: 1.4rem;
position: relative;
cursor: pointer;
border-radius: 0.3rem;
transition: border-color .2s;
}
.todo-item-checkbox::before {
content: "";
inset: -1rem;
position: absolute;
}
.todo-item-checkbox::after {
content: '';
position: absolute;
inset: 0.3rem;
border-radius: 0.1rem;
opacity: 0;
transition: opacity .2s;
}
.todo-item-checkbox:checked::after {
background: var(--color-primary);
opacity: 1;
}
.todo-item-checkbox:focus-visible {
outline: none;
border-color: var(--color-primary);
}
.todo-item-text {
color: var(--color-text-base);
transition: color .35s;
}
.todo-item-text:focus {
color: var(--color-text-highlight);
}
.todo-item-drag-handle {
position: absolute;
top: -0.5rem;
inset-inline: 0;
height: 1rem;
cursor: grab;
}
.todo-item.is-being-dragged .todo-item-drag-handle {
height: 3rem;
top: -1.5rem;
}
.todo-item:has(.todo-item-checkbox:checked) .todo-item-text {
text-decoration: line-through;
color: var(--color-text-subdue);
}
.todo-item-delete {
width: 1.5rem;
height: 1.5rem;
opacity: 0;
transition: opacity .2s;
outline-offset: .5rem;
}
.todo-item:hover .todo-item-delete, .todo-item:focus-within .todo-item-delete {
opacity: 1;
}
.todo-item.is-being-dragged .todo-item-delete {
opacity: 0;
}
@@ -0,0 +1,47 @@
.twitch-category-thumbnail {
width: 5rem;
aspect-ratio: 3 / 4;
border-radius: var(--border-radius);
}
.twitch-channel-avatar {
aspect-ratio: 1;
border-radius: 50%;
}
.twitch-channel-avatar-container {
width: 4.4rem;
height: 4.4rem;
border: 2px solid var(--color-text-subdue);
padding: 2px;
border-radius: 50%;
position: relative;
flex-shrink: 0;
}
.twitch-channel-live .twitch-channel-avatar-container {
border: 2px solid var(--color-positive);
margin-bottom: 1rem;
}
.twitch-channel-live .twitch-channel-avatar-container::after {
content: 'LIVE';
position: absolute;
background: var(--color-positive);
color: var(--color-widget-background);
font-size: var(--font-size-h6);
left: 50%;
bottom: -35%;
border-radius: var(--border-radius);
padding-inline: 0.3rem;
transform: translate(-50%);
border: 2px solid var(--color-widget-background);
}
.twitch-stream-preview {
max-width: 100%;
width: 400px;
aspect-ratio: 16 / 9;
border-radius: var(--border-radius);
object-fit: cover;
}
@@ -0,0 +1,13 @@
.video-thumbnail {
width: 100%;
aspect-ratio: 16 / 8.9;
object-fit: cover;
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
.video-horizontal-list-thumbnail {
height: 4rem;
aspect-ratio: 16 / 8.9;
object-fit: cover;
border-radius: var(--border-radius);
}
@@ -0,0 +1,139 @@
.weather-column {
position: relative;
display: flex;
align-items: center;
justify-content: end;
flex-direction: column;
width: calc(100% / 12);
padding-top: 3px;
}
.weather-column-value, .weather-columns:hover .weather-column-value {
font-size: 13px;
color: var(--color-text-highlight);
letter-spacing: -0.1rem;
margin-right: 0.1rem;
position: relative;
margin-bottom: 0.3rem;
opacity: 0;
transform: translateY(0.5rem);
transition: opacity .2s, transform .2s;
user-select: none;
}
.weather-column-current .weather-column-value, .weather-column:hover .weather-column-value {
opacity: 1;
transform: translateY(0);
}
.weather-column-value::after {
position: absolute;
content: '°';
left: 100%;
color: var(--color-text-subdue);
}
.weather-column-value.weather-column-value-negative::before {
position: absolute;
content: '-';
right: 100%;
}
.weather-bar, .weather-columns:hover .weather-bar {
height: calc(20px + var(--weather-bar-height) * 40px);
width: 6px;
background-color: hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 18%)));
border: 1px solid hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 24%)));
border-bottom: 0;
border-radius: 6px 6px 0 0;
mask-image: linear-gradient(0deg, transparent 0, #000 10px);
-webkit-mask-image: linear-gradient(0deg, transparent 0, #000 10px);
transition: background-color .2s, border-color .2s, width .2s;
}
.weather-column-current .weather-bar, .weather-column:hover .weather-bar {
width: 10px;
background-color: hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 40%)));
border: 1px solid hsl(var(--ths), calc(var(--scheme) ((var(--scheme) var(--bgl)) + 50%)));
}
.weather-column-rain {
position: absolute;
inset: 0;
bottom: 20%;
overflow: hidden;
mask-image: linear-gradient(0deg, transparent 40%, #000);
-webkit-mask-image: linear-gradient(0deg, transparent 40%, #000);
}
.weather-column-rain::before {
content: '';
position: absolute;
/* TODO: figure out a way to make it look continuous between columns, right now */
/* depending on the width of the page the rain inside two columns next to each other */
/* can overlap and look bad */
background: radial-gradient(circle at 4px 4px, hsl(200, 90%, 70%, 0.4) 1px, transparent 0);
background-size: 8px 8px;
transform: rotate(45deg) translate(-50%, 25%);
height: 130%;
aspect-ratio: 1;
left: 55%;
}
.weather-column:nth-child(3) .weather-column-time,
.weather-column:nth-child(7) .weather-column-time,
.weather-column:nth-child(11) .weather-column-time {
opacity: 1;
transform: translateY(0);
}
.weather-column-time, .weather-columns:hover .weather-column-time {
margin-top: 0.3rem;
font-size: var(--font-size-h6);
opacity: 0;
transform: translateY(-0.5rem);
transition: opacity .2s, transform .2s;
user-select: none;
}
.weather-column:hover .weather-column-time {
opacity: 1;
transform: translateY(0);
}
.weather-column-daylight {
position: absolute;
inset: 0;
background: linear-gradient(0deg, transparent 30px, hsl(50, 50%, 30%, 0.2));
}
.weather-column-daylight-sunrise {
border-radius: 20px 0 0 0;
}
.weather-column-daylight-sunset {
border-radius: 0 20px 0 0;
}
.location-icon {
width: 0.8em;
height: 0.8em;
border-radius: 0 50% 50% 50%;
background-color: currentColor;
transform: rotate(225deg) translate(.1em, .1em);
position: relative;
flex-shrink: 0;
}
.location-icon::after {
content: '';
position: absolute;
z-index: 2;
width: .4em;
height: .4em;
border-radius: 50%;
background-color: var(--color-widget-background);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
+93
View File
@@ -0,0 +1,93 @@
@import "widget-bookmarks.css";
@import "widget-calendar.css";
@import "widget-clock.css";
@import "widget-dns-stats.css";
@import "widget-docker-containers.css";
@import "widget-group.css";
@import "widget-markets.css";
@import "widget-monitor.css";
@import "widget-reddit.css";
@import "widget-releases.css";
@import "widget-rss.css";
@import "widget-search.css";
@import "widget-server-stats.css";
@import "widget-twitch.css";
@import "widget-videos.css";
@import "widget-weather.css";
@import "widget-todo.css";
@import "forum-posts.css";
.widget-error-header {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
margin-bottom: 1.8rem;
z-index: 1;
}
.widget-error-header::before {
content: '';
position: absolute;
inset: calc(0rem - (var(--widget-content-vertical-padding) / 2)) calc(0rem - (var(--widget-content-horizontal-padding) / 2));
background: var(--color-negative);
opacity: 0.05;
border-radius: var(--border-radius);
z-index: -1;
}
.widget-error-icon {
width: 2.4rem;
height: 2.4rem;
flex-shrink: 0;
stroke: var(--color-negative);
opacity: 0.6;
}
.head-widgets {
margin-bottom: var(--widget-gap);
}
.widget-content {
container-type: inline-size;
container-name: widget;
}
.widget-content:not(.widget-content-frameless) {
padding: var(--widget-content-padding);
}
.widget-content:not(.widget-content-frameless), .widget-content-frame {
background: var(--color-widget-background);
border-radius: var(--border-radius);
border: 1px solid var(--color-widget-content-border);
box-shadow: 0px 3px 0px 0px hsl(var(--bghs), calc(var(--scheme) (var(--scheme) var(--bgl)) - 0.5%));
}
.widget-header {
padding: 0 calc(var(--widget-content-horizontal-padding) + 1px);
font-size: var(--font-size-h4);
margin-bottom: 0.9rem;
display: flex;
align-items: center;
gap: 1rem;
}
.widget-beta-icon {
width: 1.6rem;
height: 1.6rem;
flex-shrink: 0;
transition: transform .45s, opacity .45s, stroke .45s;
opacity: 0.7;
}
.widget-beta-icon:hover, .widget-header .popover-active > .widget-beta-icon {
fill: var(--color-text-highlight);
transform: translateY(-10%) scale(1.3);
opacity: 1;
}
.widget + .widget {
margin-top: var(--widget-gap);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 607 B

+7
View File
@@ -0,0 +1,7 @@
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="26" height="26" rx="3" fill="#151519"/>
<rect x="2" y="2" width="10" height="22" rx="2" fill="#ededed"/>
<rect x="14" y="2" width="10" height="10" rx="2" fill="#ededed"/>
<path d="M16.3018 5.04032L17.328 4H22V8.72984L20.9014 9.81855V6.49193C20.9014 6.35484 20.9095 6.21774 20.9256 6.08065C20.9497 5.93548 20.9859 5.81855 21.0342 5.72984L16.7847 10L16 9.2379L20.3099 4.93145C20.2294 4.97984 20.1167 5.0121 19.9718 5.02823C19.827 5.03629 19.674 5.04032 19.5131 5.04032H16.3018Z" fill="#151519"/>
<rect x="14" y="14" width="10" height="10" rx="2" fill="#ededed"/>
</svg>

After

Width:  |  Height:  |  Size: 677 B

+25
View File
@@ -31,3 +31,28 @@ export function slideFade({
},
};
}
export function animateReposition(
element,
onAnimEnd,
animOptions = { duration: 400, easing: easeOutQuint }
) {
const rectBefore = element.getBoundingClientRect();
return () => {
const rectAfter = element.getBoundingClientRect();
const offsetY = rectBefore.y - rectAfter.y;
const offsetX = rectBefore.x - rectAfter.x;
element.animate({
keyframes: [
{ transform: `translate(${offsetX}px, ${offsetY}px)` },
{ transform: 'none' }
],
options: animOptions
}, onAnimEnd);
return rectAfter;
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ const [datesEntranceLeft, datesEntranceRight] = directions(
const undoEntrance = slideFade({ direction: "left", distance: "100%", duration: 300 });
export default function(element) {
element.swap(Calendar(
element.swapWith(Calendar(
Number(element.dataset.firstDayOfWeek ?? 1)
));
}
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
import { find } from "./templating.js";
const AUTH_ENDPOINT = pageData.baseURL + "/api/authenticate";
const showPasswordSVG = `<svg class="form-input-icon" stroke="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>`;
const hidePasswordSVG = `<svg class="form-input-icon" stroke="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>`;
const container = find("#login-container");
const usernameInput = find("#username");
const passwordInput = find("#password");
const errorMessage = find("#error-message");
const loginButton = find("#login-button");
const toggleVisibilityButton = find("#toggle-password-visibility");
const state = {
lastUsername: "",
lastPassword: "",
isLoading: false,
isRateLimited: false
};
const lang = {
showPassword: "Show password",
hidePassword: "Hide password",
incorrectCredentials: "Incorrect username or password",
rateLimited: "Too many login attempts, try again in a few minutes",
unknownError: "An error occurred, please try again",
};
container.clearStyles("display");
setTimeout(() => usernameInput.focus(), 200);
toggleVisibilityButton
.html(showPasswordSVG)
.attr("title", lang.showPassword)
.on("click", function() {
if (passwordInput.type === "password") {
passwordInput.type = "text";
toggleVisibilityButton.html(hidePasswordSVG).attr("title", lang.hidePassword);
return;
}
passwordInput.type = "password";
toggleVisibilityButton.html(showPasswordSVG).attr("title", lang.showPassword);
});
function enableLoginButtonIfCriteriaMet() {
const usernameValue = usernameInput.value.trim();
const passwordValue = passwordInput.value.trim();
const usernameValid = usernameValue.length >= 3;
const passwordValid = passwordValue.length >= 6;
const isUsingLastCredentials =
usernameValue === state.lastUsername
&& passwordValue === state.lastPassword;
loginButton.disabled = !(
usernameValid
&& passwordValid
&& !isUsingLastCredentials
&& !state.isLoading
&& !state.isRateLimited
);
}
usernameInput.on("input", enableLoginButtonIfCriteriaMet);
passwordInput.on("input", enableLoginButtonIfCriteriaMet);
async function handleLoginAttempt() {
state.lastUsername = usernameInput.value;
state.lastPassword = passwordInput.value;
errorMessage.text("");
loginButton.disable();
state.isLoading = true;
const response = await fetch(AUTH_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username: usernameInput.value,
password: passwordInput.value
}),
});
state.isLoading = false;
if (response.status === 200) {
setTimeout(() => { window.location.href = pageData.baseURL + "/"; }, 300);
container.animate({
keyframes: [{ offset: 1, transform: "scale(0.95)", opacity: 0 }],
options: { duration: 300, easing: "ease", fill: "forwards" }}
);
find("footer")?.animate({
keyframes: [{ offset: 1, opacity: 0 }],
options: { duration: 300, easing: "ease", fill: "forwards", delay: 50 }
});
} else if (response.status === 401) {
errorMessage.text(lang.incorrectCredentials);
passwordInput.focus();
} else if (response.status === 429) {
errorMessage.text(lang.rateLimited);
state.isRateLimited = true;
const retryAfter = response.headers.get("Retry-After") || 30;
setTimeout(() => {
state.lastUsername = "";
state.lastPassword = "";
state.isRateLimited = false;
enableLoginButtonIfCriteriaMet();
}, retryAfter * 1000);
} else {
errorMessage.text(lang.unknownError);
passwordInput.focus();
}
}
loginButton.disable().on("click", handleLoginAttempt);
-3
View File
@@ -37,9 +37,6 @@ export function setupMasonries() {
columnsFragment.append(column);
}
// poor man's masonry
// TODO: add an option that allows placing items in the
// shortest column instead of iterating the columns in order
for (let i = 0; i < items.length; i++) {
columnsFragment.children[i % columnsCount].appendChild(items[i]);
}
@@ -1,6 +1,7 @@
import { setupPopovers } from './popover.js';
import { setupMasonries } from './masonry.js';
import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js';
import { elem, find, findAll } from './templating.js';
async function fetchPageContent(pageData) {
// TODO: handle non 200 status codes/time outs
@@ -193,7 +194,7 @@ function setupSearchBoxes() {
document.addEventListener("keydown", (event) => {
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
if (event.key != "s") return;
if (event.code != "KeyS") return;
inputElement.focus();
event.preventDefault();
@@ -641,6 +642,17 @@ async function setupCalendars() {
calendar.default(elems[i]);
}
async function setupTodos() {
const elems = Array.from(document.getElementsByClassName("todo"));
if (elems.length == 0) return;
const todo = await import ('./todo.js');
for (let i = 0; i < elems.length; i++){
todo.default(elems[i]);
}
}
function setupTruncatedElementTitles() {
const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines");
@@ -650,11 +662,90 @@ function setupTruncatedElementTitles() {
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
if (element.getAttribute("title") === null) element.title = element.textContent;
if (element.getAttribute("title") === null)
element.title = element.innerText.trim().replace(/\s+/g, " ");
}
}
async function changeTheme(key, onChanged) {
const themeStyleElem = find("#theme-style");
const response = await fetch(`${pageData.baseURL}/api/set-theme/${key}`, {
method: "POST",
});
if (response.status != 200) {
alert("Failed to set theme: " + response.statusText);
return;
}
const newThemeStyle = await response.text();
const tempStyle = elem("style")
.html("* { transition: none !important; }")
.appendTo(document.head);
themeStyleElem.html(newThemeStyle);
document.documentElement.setAttribute("data-theme", key);
document.documentElement.setAttribute("data-scheme", response.headers.get("X-Scheme"));
typeof onChanged == "function" && onChanged();
setTimeout(() => { tempStyle.remove(); }, 10);
}
function initThemePicker() {
const themeChoicesInMobileNav = find(".mobile-navigation .theme-choices");
if (!themeChoicesInMobileNav) return;
const themeChoicesInHeader = find(".header-container .theme-choices");
if (themeChoicesInHeader) {
themeChoicesInHeader.replaceWith(
themeChoicesInMobileNav.cloneNode(true)
);
}
const presetElems = findAll(".theme-choices .theme-preset");
let themePreviewElems = document.getElementsByClassName("current-theme-preview");
let isLoading = false;
presetElems.forEach((presetElement) => {
const themeKey = presetElement.dataset.key;
if (themeKey === undefined) {
return;
}
if (themeKey == pageData.theme) {
presetElement.classList.add("current");
}
presetElement.addEventListener("click", () => {
if (themeKey == pageData.theme) return;
if (isLoading) return;
isLoading = true;
changeTheme(themeKey, function() {
isLoading = false;
pageData.theme = themeKey;
presetElems.forEach((e) => { e.classList.remove("current"); });
Array.from(themePreviewElems).forEach((preview) => {
preview.querySelector(".theme-preset").replaceWith(
presetElement.cloneNode(true)
);
})
presetElems.forEach((e) => {
if (e.dataset.key != themeKey) return;
e.classList.add("current");
});
});
});
})
}
async function setupPage() {
initThemePicker();
const pageElement = document.getElementById("page");
const pageContentElement = document.getElementById("page-content");
const pageContent = await fetchPageContent(pageData);
@@ -665,6 +756,7 @@ async function setupPage() {
setupPopovers();
setupClocks()
await setupCalendars();
await setupTodos();
setupCarousels();
setupSearchBoxes();
setupCollapsibleLists();
+16 -2
View File
@@ -38,6 +38,8 @@ function handleMouseEnter(event) {
if (activeTarget !== target) {
hidePopover();
requestAnimationFrame(() => requestAnimationFrame(showPopover));
} else if (activeTarget.dataset.popoverTrigger === "click") {
hidePopover();
}
return;
@@ -100,11 +102,14 @@ function showPopover() {
contentElement.style.maxWidth = contentMaxWidth;
activeTarget.classList.add("popover-active");
document.addEventListener("keydown", handleHidePopoverOnEscape);
window.addEventListener("scroll", queueRepositionContainer);
window.addEventListener("resize", queueRepositionContainer);
observer.observe(containerElement);
}
function repositionContainer() {
if (activeTarget === null) return;
containerElement.style.display = "block";
const targetBounds = activeTarget.dataset.popoverAnchor !== undefined
@@ -125,7 +130,7 @@ function repositionContainer() {
} else if (left + containerBounds.width > window.innerWidth) {
containerElement.style.removeProperty("left");
containerElement.style.right = 0;
containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + -1 + "px");
containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (document.documentElement.clientWidth - targetBounds.left - targetBoundsWidthOffset) + -1 + "px");
} else {
containerElement.style.removeProperty("right");
containerElement.style.left = left + "px";
@@ -157,7 +162,11 @@ function hidePopover() {
activeTarget.classList.remove("popover-active");
containerElement.style.display = "none";
containerElement.style.removeProperty("top");
containerElement.style.removeProperty("left");
containerElement.style.removeProperty("right");
document.removeEventListener("keydown", handleHidePopoverOnEscape);
window.removeEventListener("scroll", queueRepositionContainer);
window.removeEventListener("resize", queueRepositionContainer);
observer.unobserve(containerElement);
@@ -181,7 +190,12 @@ export function setupPopovers() {
for (let i = 0; i < targets.length; i++) {
const target = targets[i];
target.addEventListener("mouseenter", handleMouseEnter);
if (target.dataset.popoverTrigger === "click") {
target.addEventListener("click", handleMouseEnter);
} else {
target.addEventListener("mouseenter", handleMouseEnter);
}
target.addEventListener("mouseleave", handleMouseLeave);
}
}
+26 -1
View File
@@ -29,6 +29,15 @@ export function findAll(selector) {
return document.querySelectorAll(selector);
}
HTMLCollection.prototype.map = function(fn) {
return Array.from(this).map(fn);
}
HTMLCollection.prototype.indexOf = function(element) {
return Array.prototype.indexOf.call(this, element);
}
const ep = HTMLElement.prototype;
const fp = DocumentFragment.prototype;
const tp = Text.prototype;
@@ -110,7 +119,7 @@ ep.appendTo = function(parent) {
return this;
}
ep.swap = function(element) {
ep.swapWith = function(element) {
this.replaceWith(element);
return element;
}
@@ -147,6 +156,22 @@ ep.styles = function(s) {
return this;
}
ep.clearStyles = function(...props) {
for (let i = 0; i < props.length; i++)
this.style.removeProperty(props[i]);
return this;
}
ep.disable = function() {
this.disabled = true;
return this;
}
ep.enable = function() {
this.disabled = false;
return this;
}
const epAnimate = ep.animate;
ep.animate = function(anim, callback) {
const a = epAnimate.call(this, anim.keyframes, anim.options);
+442
View File
@@ -0,0 +1,442 @@
import { elem, fragment } from "./templating.js";
import { animateReposition } from "./animations.js";
import { clamp, Vec2, toggleableEvents, throttledDebounce } from "./utils.js";
const trashIconSvg = `<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z" clip-rule="evenodd" />
</svg>`;
export default function(element) {
element.swapWith(
Todo(element.dataset.todoId)
)
}
function itemAnim(height, entrance = true) {
const visible = { height: height + "px", opacity: 1 };
const hidden = { height: "0", opacity: 0, padding: "0" };
return {
keyframes: [
entrance ? hidden : visible,
entrance ? visible : hidden
],
options: { duration: 200, easing: "ease" }
}
}
function inputMarginAnim(entrance = true) {
const amount = "1.5rem";
return {
keyframes: [
{ marginBottom: entrance ? "0px" : amount },
{ marginBottom: entrance ? amount : "0" }
],
options: { duration: 200, easing: "ease", fill: "forwards" }
}
}
function loadFromLocalStorage(id) {
return JSON.parse(localStorage.getItem(`todo-${id}`) || "[]");
}
function saveToLocalStorage(id, data) {
localStorage.setItem(`todo-${id}`, JSON.stringify(data));
}
function Item(unserialize = {}, onUpdate, onDelete, onEscape, onDragStart) {
let item, input, inputArea;
const serializeable = {
text: unserialize.text || "",
checked: unserialize.checked || false
};
item = elem().classes("todo-item", "flex", "gap-10", "items-center").append(
elem("input")
.classes("todo-item-checkbox", "shrink-0")
.styles({ marginTop: "-0.1rem" })
.attrs({ type: "checkbox" })
.on("change", (e) => {
serializeable.checked = e.target.checked;
onUpdate();
})
.tap(self => self.checked = serializeable.checked),
input = autoScalingTextarea(textarea => inputArea = textarea
.classes("todo-item-text")
.attrs({
placeholder: "empty task",
spellcheck: "false"
})
.on("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
} else if (e.key === "Escape") {
e.preventDefault();
onEscape();
}
})
.on("input", () => {
serializeable.text = inputArea.value;
onUpdate();
})
).classes("min-width-0", "grow").append(
elem()
.classes("todo-item-drag-handle")
.on("mousedown", (e) => onDragStart(e, item))
),
elem("button")
.classes("todo-item-delete", "shrink-0")
.html(trashIconSvg)
.on("click", () => onDelete(item))
);
input.component.setValue(serializeable.text);
return item.component({
focusInput: () => inputArea.focus(),
serialize: () => serializeable
});
}
function Todo(id) {
let items, input, inputArea, inputContainer, lastAddedItem;
let queuedForRemoval = 0;
let reorderable;
let isDragging = false;
const onDragEnd = () => isDragging = false;
const onDragStart = (event, element) => {
isDragging = true;
reorderable.component.onDragStart(event, element);
};
const saveItems = () => {
if (isDragging) return;
saveToLocalStorage(
id, items.children.map(item => item.component.serialize())
);
};
const onItemRepositioned = () => saveItems();
const debouncedOnItemUpdate = throttledDebounce(saveItems, 10, 1000);
const onItemDelete = (item) => {
if (lastAddedItem === item) lastAddedItem = null;
const height = item.clientHeight;
queuedForRemoval++;
item.animate(itemAnim(height, false), () => {
item.remove();
queuedForRemoval--;
saveItems();
});
if (items.children.length - queuedForRemoval === 0)
inputContainer.animate(inputMarginAnim(false));
};
const newItem = (data) => Item(
data,
debouncedOnItemUpdate,
onItemDelete,
() => inputArea.focus(),
onDragStart
);
const addNewItem = (itemText, prepend) => {
const totalItemsBeforeAppending = items.children.length;
const item = lastAddedItem = newItem({ text: itemText });
prepend ? items.prepend(item) : items.append(item);
saveItems();
const height = item.clientHeight;
item.animate(itemAnim(height));
if (totalItemsBeforeAppending === 0)
inputContainer.animate(inputMarginAnim());
};
const handleInputKeyDown = (e) => {
switch (e.key) {
case "Enter":
e.preventDefault();
const value = e.target.value.trim();
if (value === "") return;
addNewItem(value, e.ctrlKey);
input.component.setValue("");
break;
case "Escape":
e.target.blur();
break;
case "ArrowDown":
if (!lastAddedItem) return;
e.preventDefault();
lastAddedItem.component.focusInput();
break;
}
};
items = elem()
.classes("todo-items")
.append(
...loadFromLocalStorage(id).map(data => newItem(data))
);
return fragment().append(
inputContainer = elem()
.classes("todo-input", "flex", "gap-10", "items-center")
.classesIf(items.children.length > 0, "margin-bottom-15")
.styles({ paddingRight: "2.5rem" })
.append(
elem().classes("todo-plus-icon", "shrink-0"),
input = autoScalingTextarea(textarea => inputArea = textarea
.on("keydown", handleInputKeyDown)
.attrs({
placeholder: "Add a task",
spellcheck: "false"
})
).classes("grow", "min-width-0")
),
reorderable = verticallyReorderable(items, onItemRepositioned, onDragEnd),
);
}
// See https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/
export function autoScalingTextarea(yieldTextarea = null) {
let textarea, mimic;
const updateMimic = (newValue) => mimic.text(newValue + ' ');
const container = elem().classes("auto-scaling-textarea-container").append(
textarea = elem("textarea")
.classes("auto-scaling-textarea")
.on("input", () => updateMimic(textarea.value)),
mimic = elem().classes("auto-scaling-textarea-mimic")
)
if (typeof yieldTextarea === "function") yieldTextarea(textarea);
return container.component({ setValue: (newValue) => {
textarea.value = newValue;
updateMimic(newValue);
}});
}
export function verticallyReorderable(itemsContainer, onItemRepositioned, onDragEnd) {
const classToAddToDraggedItem = "is-being-dragged";
const currentlyBeingDragged = {
element: null,
initialIndex: null,
clientOffset: Vec2.new(),
};
const decoy = {
element: null,
currentIndex: null,
};
const draggableContainer = {
element: null,
initialRect: null,
};
const lastClientPos = Vec2.new();
let initialScrollY = null;
let addDocumentEvents, removeDocumentEvents;
const handleReposition = (event) => {
if (currentlyBeingDragged.element == null) return;
if (event.clientY !== undefined && event.clientX !== undefined)
lastClientPos.setFromEvent(event);
const client = lastClientPos;
const container = draggableContainer;
const item = currentlyBeingDragged;
const scrollOffset = window.scrollY - initialScrollY;
const offsetY = client.y - container.initialRect.y - item.clientOffset.y + scrollOffset;
const offsetX = client.x - container.initialRect.x - item.clientOffset.x;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
const viewportWidth = window.innerWidth - scrollbarWidth;
const confinedX = clamp(
offsetX,
-container.initialRect.x,
viewportWidth - container.initialRect.x - container.initialRect.width
);
container.element.styles({
transform: `translate(${confinedX}px, ${offsetY}px)`,
});
const containerTop = client.y - item.clientOffset.y;
const containerBottom = client.y + container.initialRect.height - item.clientOffset.y;
let swapWithLast = true;
let swapWithIndex = null;
for (let i = 0; i < itemsContainer.children.length; i++) {
const childRect = itemsContainer.children[i].getBoundingClientRect();
const topThreshold = childRect.top + childRect.height * .6;
const bottomThreshold = childRect.top + childRect.height * .4;
if (containerBottom > topThreshold) {
if (containerTop < bottomThreshold && i != decoy.currentIndex) {
swapWithIndex = i;
swapWithLast = false;
break;
}
continue;
};
swapWithLast = false;
if (i == decoy.currentIndex || i-1 == decoy.currentIndex) break;
swapWithIndex = (i < decoy.currentIndex) ? i : i-1;
break;
}
const lastItemIndex = itemsContainer.children.length - 1;
if (swapWithLast && decoy.currentIndex != lastItemIndex)
swapWithIndex = lastItemIndex;
if (swapWithIndex === null)
return;
const diff = swapWithIndex - decoy.currentIndex;
if (Math.abs(diff) > 1) {
swapWithIndex = decoy.currentIndex + Math.sign(diff);
}
const siblingToSwapWith = itemsContainer.children[swapWithIndex];
if (siblingToSwapWith.isCurrentlyAnimating) return;
const animateDecoy = animateReposition(decoy.element);
const animateChild = animateReposition(
siblingToSwapWith,
() => {
siblingToSwapWith.isCurrentlyAnimating = false;
handleReposition({
clientX: client.x,
clientY: client.y,
});
}
);
siblingToSwapWith.isCurrentlyAnimating = true;
if (swapWithIndex > decoy.currentIndex)
decoy.element.before(siblingToSwapWith);
else
decoy.element.after(siblingToSwapWith);
decoy.currentIndex = itemsContainer.children.indexOf(decoy.element);
animateDecoy();
animateChild();
}
const handleRelease = (event) => {
if (event.buttons != 0) return;
removeDocumentEvents();
const item = currentlyBeingDragged;
const element = item.element;
element.styles({ pointerEvents: "none" });
const animate = animateReposition(element, () => {
item.element = null;
element
.clearClasses(classToAddToDraggedItem)
.clearStyles("pointer-events");
if (typeof onDragEnd === "function") onDragEnd(element);
if (item.initialIndex != decoy.currentIndex && typeof onItemRepositioned === "function")
onItemRepositioned(element, item.initialIndex, decoy.currentIndex);
});
decoy.element.swapWith(element);
draggableContainer.element.append(decoy.element);
draggableContainer.element.clearStyles("transform", "width");
item.element = null;
decoy.element.remove();
animate();
}
const preventDefault = (event) => {
event.preventDefault();
};
const handleGrab = (event, element) => {
if (currentlyBeingDragged.element != null) return;
event.preventDefault();
const item = currentlyBeingDragged;
if (item.element != null) return;
addDocumentEvents();
initialScrollY = window.scrollY;
const client = lastClientPos.setFromEvent(event);
const elementRect = element.getBoundingClientRect();
item.element = element;
item.initialIndex = decoy.currentIndex = itemsContainer.children.indexOf(element);
item.clientOffset.set(client.x - elementRect.x, client.y - elementRect.y);
// We use getComputedStyle here to get width and height because .clientWidth and .clientHeight
// return integers and not the real float values, which can cause the decoy to be off by a pixel
const elementStyle = getComputedStyle(element);
const initialWidth = elementStyle.width;
decoy.element = elem().classes("drag-and-drop-decoy").styles({
height: elementStyle.height,
width: initialWidth,
});
const container = draggableContainer;
element.swapWith(decoy.element);
container.element.append(element);
element.classes(classToAddToDraggedItem);
decoy.element.animate({
keyframes: [{ transform: "scale(.9)", opacity: 0, offset: 0 }],
options: { duration: 300, easing: "ease" }
})
container.element.styles({ width: initialWidth, transform: "none" });
container.initialRect = container.element.getBoundingClientRect();
const offsetY = elementRect.y - container.initialRect.y;
const offsetX = elementRect.x - container.initialRect.x;
container.element.styles({ transform: `translate(${offsetX}px, ${offsetY}px)` });
}
[addDocumentEvents, removeDocumentEvents] = toggleableEvents(document, {
"mousemove": handleReposition,
"scroll": handleReposition,
"mousedown": preventDefault,
"contextmenu": preventDefault,
"mouseup": handleRelease,
});
return elem().classes("drag-and-drop-container").append(
itemsContainer,
draggableContainer.element = elem().classes("drag-and-drop-draggable")
).component({
onDragStart: handleGrab
});
}
+43
View File
@@ -36,3 +36,46 @@ export function openURLInNewTab(url, focus = true) {
if (focus && newWindow != null) newWindow.focus();
}
export class Vec2 {
constructor(x, y) {
this.x = x;
this.y = y;
}
static new(x = 0, y = 0) {
return new Vec2(x, y);
}
static fromEvent(event) {
return new Vec2(event.clientX, event.clientY);
}
setFromEvent(event) {
this.x = event.clientX;
this.y = event.clientY;
return this;
}
set(x, y) {
this.x = x;
this.y = y;
return this;
}
}
export function toggleableEvents(element, eventToHandlerMap) {
return [
() => {
for (const [event, handler] of Object.entries(eventToHandlerMap)) {
element.addEventListener(event, handler);
}
},
() => {
for (const [event, handler] of Object.entries(eventToHandlerMap)) {
element.removeEventListener(event, handler);
}
}
];
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
-14
View File
@@ -1,14 +0,0 @@
{
"name": "Glance",
"display": "standalone",
"background_color": "#151519",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "app-icon.png",
"type": "image/png",
"sizes": "512x512"
}
]
}
+3
View File
@@ -21,6 +21,9 @@ var globalTemplateFunctions = template.FuncMap{
"safeURL": func(str string) template.URL {
return template.URL(str)
},
"safeHTML": func(str string) template.HTML {
return template.HTML(str)
},
"absInt": func(i int) int {
return int(math.Abs(float64(i)))
},
@@ -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 }}
+84
View File
@@ -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:{}&#91;&#93;|>&amp;*]+" 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 }}
+86
View File
@@ -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 &quot;{{ .Title }}&quot; 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 }}
+18 -11
View File
@@ -2,22 +2,29 @@
{{ define "widget-content" }}
<div class="dynamic-columns list-gap-24 list-with-separator">
{{ range .Groups }}
{{- range .Groups }}
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.String | safeCSS }}"{{ end }}>
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
{{- if ne .Title "" }}
<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>
{{- end }}
<ul class="list list-gap-2">
{{ range .Links }}
<li class="flex items-center gap-10">
{{ if ne "" .Icon.URL }}
<div class="bookmarks-icon-container">
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
{{- range .Links }}
<li>
<div class="flex items-center gap-10">
{{- if ne "" .Icon.URL }}
<div class="bookmarks-icon-container">
<img class="bookmarks-icon{{ if .Icon.AutoInvert }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
</div>
{{- end }}
<a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">{{ .Title }}</a>
</div>
{{ end }}
<a href="{{ .URL | safeURL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if .Target }}target="{{ .Target }}"{{ end }} rel="noreferrer">{{ .Title }}</a>
{{- if .Description }}
<div class="margin-bottom-5">{{ .Description }}</div>
{{- end }}
</li>
{{ end }}
{{- end }}
</ul>
</div>
{{ end }}
{{- end }}
</div>
{{ end }}
@@ -5,7 +5,7 @@
{{- range .Containers }}
<li class="docker-container flex items-center gap-15">
<div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem" data-popover-max-width="400px" aria-hidden="true">
<img class="docker-container-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
<img class="docker-container-icon{{ if .Icon.AutoInvert }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
<div data-popover-html>
<div class="color-highlight text-truncate block">{{ .Image }}</div>
<div>{{ .StateText }}</div>
@@ -14,7 +14,7 @@
{{- range .Children }}
<li class="flex gap-7 items-center">
<div class="margin-bottom-3">{{ template "state-icon" .StateIcon }}</div>
<div class="color-highlight">{{ .Title }} <span class="size-h5 color-base">{{ .StateText }}</span></div>
<div class="color-highlight">{{ .Name }} <span class="size-h5 color-base">{{ .StateText }}</span></div>
</li>
{{- end }}
</ul>
@@ -24,9 +24,9 @@
<div class="min-width-0 grow">
{{- if .URL }}
<a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
<a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Name }}</a>
{{- else }}
<div class="color-highlight text-truncate size-title-dynamic">{{ .Title }}</div>
<div class="color-highlight text-truncate size-title-dynamic">{{ .Name }}</div>
{{- end }}
{{- if .Description }}
<div class="text-truncate">{{ .Description }}</div>
+18 -9
View File
@@ -1,23 +1,32 @@
<!DOCTYPE html>
<html {{ block "document-root-attrs" . }}{{ end }} lang="en" id="top">
<html lang="en" id="top" data-theme="{{ .Request.Theme.Key }}" data-scheme="{{ if .Request.Theme.Light }}light{{ else }}dark{{ end }}">
<head>
{{ block "document-head-before" . }}{{ end }}
<script>
if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');
const pageData = {
/*{{ if .Page }}*/slug: "{{ .Page.Slug }}",/*{{ end }}*/
baseURL: "{{ .App.Config.Server.BaseURL }}",
theme: "{{ .Request.Theme.Key }}",
};
</script>
<title>{{ block "document-title" . }}{{ end }}</title>
<script>if (navigator.platform === 'iPhone') document.documentElement.classList.add('ios');</script>
<meta charset="UTF-8">
<meta name="color-scheme" content="dark">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Glance">
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
<link rel="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}">
<link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
<link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
<script type="module" src="{{ .App.AssetPath "js/main.js" }}"></script>
<meta name="apple-mobile-web-app-title" content="{{ .App.Config.Branding.AppName }}">
<meta name="theme-color" content="{{ .Request.Theme.BackgroundColorAsHex }}">
<link rel="apple-touch-icon" sizes="512x512" href='{{ .App.Config.Branding.AppIconURL }}'>
<link rel="manifest" href='{{ .App.VersionedAssetPath "manifest.json" }}'>
<link rel="icon" type="{{ .App.Config.Branding.FaviconType }}" href="{{ .App.Config.Branding.FaviconURL }}" />
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/bundle.css" }}'>
<style id="theme-style">{{ .Request.Theme.CSS }}</style>
{{ if .App.Config.Theme.CustomCSSFile }}<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.CreatedAt.Unix }}">{{ end }}
{{ block "document-head-after" . }}{{ end }}
{{ if .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
</head>
<body>
{{ template "document-body" . }}
+11
View File
@@ -0,0 +1,11 @@
{{ if not .App.Config.Branding.HideFooter }}
<footer class="footer flex items-center flex-column">
{{ if eq "" .App.Config.Branding.CustomFooter }}
<div>
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
</div>
{{ else }}
{{ .App.Config.Branding.CustomFooter }}
{{ end }}
</footer>
{{ end }}
+2 -2
View File
@@ -23,7 +23,7 @@
{{- end }}
{{- end }}
<div class="grow min-width-0">
<a href="{{ .DiscussionUrl }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
<a href="{{ .DiscussionUrl | safeURL }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
{{- if .Tags }}
<div class="inline-block forum-post-tags-container">
<ul class="attachments">
@@ -36,7 +36,7 @@
<ul class="list-horizontal-text flex-nowrap text-compact">
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li class="shrink-0">{{ .Score | formatApproxNumber }} points</li>
<li class="shrink-0{{ if .TargetUrl }} forum-post-autohide{{ end }}">{{ .CommentCount | formatApproxNumber }} comments</li>
<li class="shrink-0{{ if .TargetUrl | safeURL }} forum-post-autohide{{ end }}">{{ .CommentCount | formatApproxNumber }} comments</li>
{{- if .TargetUrl }}
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
{{- end }}
+53
View File
@@ -0,0 +1,53 @@
{{- template "document.html" . }}
{{- define "document-title" }}Login{{ end }}
{{- define "document-head-before" }}
<link rel="preload" href='{{ .App.StaticAssetPath "js/templating.js" }}' as="script"/>
<link rel="prefetch" href='{{ .App.StaticAssetPath "js/page.js" }}'/>
{{- end }}
{{- define "document-head-after" }}
<link rel="stylesheet" href='{{ .App.StaticAssetPath "css/login.css" }}'>
<script type="module" src='{{ .App.StaticAssetPath "js/login.js" }}'></script>
{{- end }}
{{- define "document-body" }}
<div class="flex flex-column body-content">
<div class="flex grow items-center justify-center" style="padding-bottom: 5rem">
<h1 class="visually-hidden">Login</h1>
<main id="login-container" class="grow login-bounds" style="display: none;">
<div class="animate-entrance">
<label class="form-label widget-header" for="username">Username</label>
<div class="form-input widget-content-frame padding-inline-widget flex gap-10 items-center">
<svg class="form-input-icon" fill="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
<path d="M10 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.465 14.493a1.23 1.23 0 0 0 .41 1.412A9.957 9.957 0 0 0 10 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 0 0-13.074.003Z" />
</svg>
<input type="text" id="username" class="input" placeholder="Enter your username" autocomplete="off">
</div>
</div>
<div class="animate-entrance">
<label class="form-label widget-header margin-top-20" for="password">Password</label>
<div class="form-input widget-content-frame padding-inline-widget flex gap-10 items-center">
<svg class="form-input-icon" fill="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" aria-hidden="true">
<path fill-rule="evenodd" d="M8 7a5 5 0 1 1 3.61 4.804l-1.903 1.903A1 1 0 0 1 9 14H8v1a1 1 0 0 1-1 1H6v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-2a1 1 0 0 1 .293-.707L8.196 8.39A5.002 5.002 0 0 1 8 7Zm5-3a.75.75 0 0 0 0 1.5A1.5 1.5 0 0 1 14.5 7 .75.75 0 0 0 16 7a3 3 0 0 0-3-3Z" clip-rule="evenodd" />
</svg>
<input type="password" id="password" class="input" placeholder="********" autocomplete="off">
<button class="toggle-password-visibility" id="toggle-password-visibility" tabindex="-1"></button>
</div>
</div>
<div class="login-error-message" id="error-message"></div>
<button class="login-button animate-entrance" id="login-button">
<div>LOGIN</div>
<svg stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</button>
</main>
</div>
{{ template "footer.html" . }}
</div>
{{- end }}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "{{ .App.Config.Branding.AppName }}",
"display": "standalone",
"background_color": "{{ .App.Config.Branding.AppBackgroundColor }}",
"theme_color": "{{ .App.Config.Branding.AppBackgroundColor }}",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "{{ .App.Config.Branding.AppIconURL }}",
"type": "image/png",
"sizes": "512x512"
}
]
}
+1 -1
View File
@@ -11,7 +11,7 @@
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
<svg class="market-chart shrink-0" viewBox="0 0 100 50">
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-linejoin="round" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
</svg>
</a>
+2 -2
View File
@@ -22,9 +22,9 @@
{{ define "site" }}
{{ if .Icon.URL }}
<img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
<img class="monitor-site-icon{{ if .Icon.AutoInvert }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
{{ end }}
<div class="min-width-0">
<div class="grow min-width-0">
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL | safeURL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text">
{{ if not .Status.Error }}
+13 -5
View File
@@ -2,12 +2,20 @@
<div class="mobile-reachability-header">{{ .Page.Title }}</div>
{{ end }}
{{ if .Page.HeadWidgets }}
<div class="head-widgets">
{{- range .Page.HeadWidgets }}
{{- .Render }}
{{- end }}
</div>
{{ end }}
<div class="page-columns">
{{ range .Page.Columns }}
{{- range .Page.Columns }}
<div class="page-column page-column-{{ .Size }}">
{{ range .Widgets }}
{{ .Render }}
{{ end }}
{{- range .Widgets }}
{{- .Render }}
{{- end }}
</div>
{{ end }}
{{- end }}
</div>
+105 -39
View File
@@ -2,25 +2,12 @@
{{ define "document-title" }}{{ .Page.Title }}{{ end }}
{{ define "document-head-before" }}
<script>
const pageData = {
slug: "{{ .Page.Slug }}",
baseURL: "{{ .App.Config.Server.BaseURL }}",
};
</script>
{{ end }}
{{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }} {{ end }}{{ if .Page.CenterVertically }}page-center-vertically{{ end }}"{{ end }}
{{ define "document-head-after" }}
{{ .App.ParsedThemeStyle }}
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
<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 }}
{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
{{ end }}
{{ define "navigation-links" }}
@@ -32,13 +19,53 @@
{{ define "document-body" }}
<div class="flex flex-column body-content">
{{ if not .Page.HideDesktopNavigation }}
<div class="header-container content-bounds">
<div class="header-container content-bounds{{ if .Page.DesktopNavigationWidth }} content-bounds-{{ .Page.DesktopNavigationWidth }} {{ end }}">
<div class="header flex padding-inline-widget widget-content-frame">
<!-- TODO: Replace G with actual logo, first need an actual logo -->
<div class="logo" aria-hidden="true">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
<nav class="nav flex grow">
<div class="logo" aria-hidden="true">
{{- if .App.Config.Branding.LogoURL }}
<img src="{{ .App.Config.Branding.LogoURL }}" alt="">
{{- else if .App.Config.Branding.LogoText }}
{{- .App.Config.Branding.LogoText }}
{{- else }}
<svg style="max-height: 2rem;" width="100%" viewBox="0 0 108 108" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect fill="var(--color-text-subdue)" width="50" height="108" rx="6.875" />
<path fill="var(--color-primary)" fill-rule="evenodd" clip-rule="evenodd" d="M64.875 0C61.078 0 58 3.07804 58 6.875V43.125C58 46.922 61.078 50 64.875 50H101.125C104.922 50 108 46.922 108 43.125V6.875C108 3.07804 104.922 0 101.125 0H64.875ZM75.7545 11L71.3078 15.6814H85.2233C85.9209 15.6814 86.5835 15.6633 87.2113 15.627C87.839 15.5544 88.3273 15.4093 88.6761 15.1915L70 34.5706L73.4004 38L91.8149 18.7843C91.6056 19.1835 91.4487 19.7097 91.3441 20.3629C91.2743 20.9798 91.2394 21.5968 91.2394 22.2137V37.1835L96 32.2843V11H75.7545Z"/>
<rect fill="var(--color-text-base)" x="58" y="58" width="50" height="50" rx="6.875" />
</svg>
{{- end }}
</div>
<nav class="nav flex grow hide-scrollbars">
{{ template "navigation-links" . }}
</nav>
{{ if not .App.Config.Theme.DisablePicker }}
<div class="theme-picker self-center" data-popover-type="html" data-popover-position="below" data-popover-show-delay="0">
<div class="current-theme-preview">
{{ .Request.Theme.PreviewHTML }}
</div>
<div data-popover-html>
<div class="theme-choices"></div>
</div>
</div>
{{ end }}
{{- if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }}
<button id="edit-mode-toggle" class="block self-center" title="Toggle edit mode" aria-label="Toggle edit mode">
<svg class="logout-button" stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<a class="block self-center" href="{{ .App.Config.Server.BaseURL }}/edit" title="Open advanced editor">
<svg class="logout-button" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
</svg>
</a>
{{- end }}
{{- if .App.RequiresAuth }}
<a class="block self-center" href="{{ .App.Config.Server.BaseURL }}/logout" title="Logout">
<svg class="logout-button" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
</svg>
</a>
{{- end }}
</div>
</div>
{{ end }}
@@ -49,37 +76,76 @@
{{ range $i, $column := .Page.Columns }}
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq $i $.Page.PrimaryColumnIndex }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
{{ end }}
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"{{ if .Page.ExpandMobilePageNavigation }} checked{{ end }}><div class="hamburger-icon"></div></label>
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
</div>
<div class="mobile-navigation-page-links">
<div class="mobile-navigation-page-links hide-scrollbars">
{{ template "navigation-links" . }}
</div>
<div class="mobile-navigation-actions flex flex-column margin-block-10">
{{ if not .App.Config.Theme.DisablePicker }}
<div class="theme-picker flex justify-between items-center" data-popover-type="html" data-popover-position="above" data-popover-show-delay="0" data-popover-hide-delay="100" data-popover-anchor=".current-theme-preview" data-popover-trigger="click">
<div data-popover-html>
<div class="theme-choices">
{{ .App.Config.Theme.PreviewHTML }}
{{ range $_, $preset := .App.Config.Theme.Presets.Items }}
{{ $preset.PreviewHTML }}
{{ end }}
</div>
</div>
<div class="size-h3 pointer-events-none select-none">Change theme</div>
<div class="flex gap-15 items-center pointer-events-none">
<div class="current-theme-preview">
{{ .Request.Theme.PreviewHTML }}
</div>
<svg class="ui-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z" />
</svg>
</div>
</div>
{{ end }}
{{ if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }}
<button id="edit-mode-toggle" class="flex justify-between items-center" style="background:none;border:none;padding:0;width:100%;cursor:pointer;color:inherit;font:inherit;">
<div class="size-h3">Edit mode</div>
<svg class="ui-icon" stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<a href="{{ .App.Config.Server.BaseURL }}/edit" class="flex justify-between items-center">
<div class="size-h3">Advanced editor</div>
<svg class="ui-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
</svg>
</a>
{{ end }}
{{ if .App.RequiresAuth }}
<a href="{{ .App.Config.Server.BaseURL }}/logout" class="flex justify-between items-center">
<div class="size-h3">Logout</div>
<svg class="ui-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
</svg>
</a>
{{ end }}
</div>
</div>
<div class="content-bounds grow">
<main class="page" id="page" aria-live="polite" aria-busy="true">
<div class="content-bounds grow{{ if .Page.Width }} content-bounds-{{ .Page.Width }}{{ end }}">
<main class="page{{ if .Page.CenterVertically }} center-vertically{{ end }}" id="page" aria-live="polite" aria-busy="true">
<h1 class="visually-hidden">{{ .Page.Title }}</h1>
<div class="page-content" id="page-content"></div>
<div class="page-loading-container">
<!-- TODO: add a bigger/better loading indicator -->
<div class="visually-hidden">Loading</div>
<div class="visually-hidden">Loading</div>
<div class="loading-icon" aria-hidden="true"></div>
</div>
</main>
</div>
{{ if not .App.Config.Branding.HideFooter }}
<footer class="footer flex items-center flex-column">
{{ if eq "" .App.Config.Branding.CustomFooter }}
<div>
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
</div>
{{ else }}
{{ .App.Config.Branding.CustomFooter }}
{{ end }}
</footer>
{{ end }}
{{ template "footer.html" . }}
<div class="mobile-navigation-offset"></div>
</div>
{{ end }}
+3 -3
View File
@@ -10,7 +10,7 @@
{{ if gt (len .Repository.Commits) 0 }}
<hr class="margin-block-8">
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
<div class="flex gap-7 size-h5 margin-top-3">
<div class="flex gap-7 size-h5 size-base-on-mobile margin-top-3">
<ul class="list list-gap-2">
{{ range .Repository.Commits }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
@@ -27,7 +27,7 @@
{{ if gt (len .Repository.PullRequests) 0 }}
<hr class="margin-block-8">
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .Repository.OpenPullRequests | formatNumber }} total)</a>
<div class="flex gap-7 size-h5 margin-top-3">
<div class="flex gap-7 size-h5 size-base-on-mobile margin-top-3">
<ul class="list list-gap-2">
{{ range .Repository.PullRequests }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
@@ -44,7 +44,7 @@
{{ if gt (len .Repository.Issues) 0 }}
<hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .Repository.OpenIssues | formatNumber }} total)</a>
<div class="flex gap-7 size-h5 margin-top-3">
<div class="flex gap-7 size-h5 size-base-on-mobile margin-top-3">
<ul class="list list-gap-2">
{{ range .Repository.Issues }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
@@ -0,0 +1,19 @@
{{- $background := "hsl(240, 8%, 9%)" | safeCSS }}
{{- $primary := "hsl(43, 50%, 70%)" | safeCSS }}
{{- $positive := "hsl(43, 50%, 70%)" | safeCSS }}
{{- $negative := "hsl(0, 70%, 70%)" | safeCSS }}
{{- if .BackgroundColor }}{{ $background = .BackgroundColor.String | safeCSS }}{{ end }}
{{- if .PrimaryColor }}
{{- $primary = .PrimaryColor.String | safeCSS }}
{{- if not .PositiveColor }}
{{- $positive = $primary }}
{{- else }}
{{- $positive = .PositiveColor.String | safeCSS }}
{{- end }}
{{- end }}
{{- if .NegativeColor }}{{ $negative = .NegativeColor.String | safeCSS }}{{ end }}
<button class="theme-preset{{ if .Light }} theme-preset-light{{ end }}" style="--color: {{ $background }}" data-key="{{ .Key }}">
<div class="theme-color" style="--color: {{ $primary }}"></div>
<div class="theme-color" style="--color: {{ $positive }}"></div>
<div class="theme-color" style="--color: {{ $negative }}"></div>
</button>
+3 -5
View File
@@ -1,9 +1,8 @@
<style>
:root {
{{ if .BackgroundColor }}
--bgh: {{ .BackgroundColor.Hue }};
--bgs: {{ .BackgroundColor.Saturation }}%;
--bgl: {{ .BackgroundColor.Lightness }}%;
--bgh: {{ .BackgroundColor.H }};
--bgs: {{ .BackgroundColor.S }}%;
--bgl: {{ .BackgroundColor.L }}%;
{{ end }}
{{ if ne 0.0 .ContrastMultiplier }}--cm: {{ .ContrastMultiplier }};{{ end }}
{{ if ne 0.0 .TextSaturationMultiplier }}--tsm: {{ .TextSaturationMultiplier }};{{ end }}
@@ -11,4 +10,3 @@
{{ if .PositiveColor }}--color-positive: {{ .PositiveColor.String | safeCSS }};{{ end }}
{{ if .NegativeColor }}--color-negative: {{ .NegativeColor.String | safeCSS }};{{ end }}
}
</style>
+5
View File
@@ -0,0 +1,5 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="todo" data-todo-id="{{ .TodoID }}"></div>
{{ end }}
@@ -1,7 +1,7 @@
{{ define "video-card-contents" }}
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
<a class="text-truncate-2-lines margin-bottom-auto color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<a class="text-truncate-2-lines margin-bottom-auto color-primary-if-not-visited" href="{{ .Url | safeURL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-7">
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li class="min-width-0">
@@ -6,7 +6,7 @@
<li class="flex thumbnail-parent gap-10 items-center">
<img class="video-horizontal-list-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
<div class="min-width-0">
<a class="block text-truncate color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<a class="block text-truncate color-primary-if-not-visited" href="{{ .Url | safeURL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap">
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li class="min-width-0">
+2 -2
View File
@@ -1,5 +1,5 @@
<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
{{- if not .HideHeader}}
<div class="widget widget-type-{{ .GetType }}{{ if .CSSClass }} {{ .CSSClass }}{{ end }}">
{{- if not .HideHeader }}
<div class="widget-header">
{{- if ne "" .TitleURL }}
<h2><a href="{{ .TitleURL | safeURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a></h2>

Some files were not shown because too many files have changed in this diff Show More