diff --git a/README.md b/README.md
index 5913013..61e7b24 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,9 @@ processes together.
The read-only network explorer is served on `http://localhost:8080` by default:
+The header includes English/Persian localization and a persistent light/dark
+theme toggle that initially follows the operating-system preference.
+
- `/` shows network totals and recent committed journals;
- `/assets` ranks the top positive user holders for recently active assets;
- `/holders` combines a user's available and frozen balance and activity for one asset;
diff --git a/interface/web/assets.go b/interface/web/assets.go
index 4dcc58b..ee9a90b 100644
--- a/interface/web/assets.go
+++ b/interface/web/assets.go
@@ -10,7 +10,16 @@ import (
//go:embed assets/favicon.ico
var favicon []byte
+//go:embed assets/theme.js
+var themeScript []byte
+
func (h *Handler) favicon(response http.ResponseWriter, request *http.Request) {
response.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeContent(response, request, "favicon.ico", time.Time{}, bytes.NewReader(favicon))
}
+
+func (h *Handler) theme(response http.ResponseWriter, request *http.Request) {
+ response.Header().Set("Cache-Control", "public, max-age=3600")
+ response.Header().Set("Content-Type", "application/javascript; charset=utf-8")
+ http.ServeContent(response, request, "theme.js", time.Time{}, bytes.NewReader(themeScript))
+}
diff --git a/interface/web/assets/theme.js b/interface/web/assets/theme.js
new file mode 100644
index 0000000..70e7d34
--- /dev/null
+++ b/interface/web/assets/theme.js
@@ -0,0 +1,54 @@
+(() => {
+ const storageKey = "gl-theme";
+ const root = document.documentElement;
+
+ const storedTheme = () => {
+ try {
+ const value = localStorage.getItem(storageKey);
+ return value === "dark" || value === "light" ? value : "";
+ } catch (_) {
+ return "";
+ }
+ };
+
+ const systemTheme = () =>
+ window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+
+ const updateControls = (theme) => {
+ document.querySelectorAll("[data-theme-toggle]").forEach((control) => {
+ const dark = theme === "dark";
+ control.setAttribute("aria-pressed", String(dark));
+ control.setAttribute("aria-label", dark ? control.dataset.lightLabel : control.dataset.darkLabel);
+ const icon = control.querySelector("[data-theme-icon]");
+ if (icon) icon.textContent = dark ? "☀" : "☾";
+ });
+ };
+
+ const applyTheme = (theme) => {
+ root.dataset.theme = theme;
+ root.style.colorScheme = theme;
+ updateControls(theme);
+ };
+
+ applyTheme(storedTheme() || systemTheme());
+
+ document.addEventListener("DOMContentLoaded", () => updateControls(root.dataset.theme));
+ document.addEventListener("htmx:afterSwap", () => updateControls(root.dataset.theme));
+ document.addEventListener("click", (event) => {
+ if (!(event.target instanceof Element)) return;
+ const control = event.target.closest("[data-theme-toggle]");
+ if (!control) return;
+ const theme = root.dataset.theme === "dark" ? "light" : "dark";
+ try {
+ localStorage.setItem(storageKey, theme);
+ } catch (_) {
+ // The selected theme still applies for this page when storage is unavailable.
+ }
+ applyTheme(theme);
+ });
+
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
+ media.addEventListener?.("change", () => {
+ if (!storedTheme()) applyTheme(systemTheme());
+ });
+})();
diff --git a/interface/web/handler.go b/interface/web/handler.go
index 00e4d26..14aed1d 100644
--- a/interface/web/handler.go
+++ b/interface/web/handler.go
@@ -61,6 +61,7 @@ type HolderPageData struct {
func NewHandler(service Explorer) *Handler {
handler := &Handler{explorer: service, routes: http.NewServeMux()}
handler.routes.HandleFunc("GET /favicon.ico", handler.favicon)
+ handler.routes.HandleFunc("GET /theme.js", handler.theme)
handler.routes.HandleFunc("GET /{$}", handler.dashboard)
handler.routes.HandleFunc("GET /assets", handler.assets)
handler.routes.HandleFunc("GET /transactions", handler.transaction)
diff --git a/interface/web/handler_test.go b/interface/web/handler_test.go
index e6daf0b..f199e8a 100644
--- a/interface/web/handler_test.go
+++ b/interface/web/handler_test.go
@@ -72,7 +72,7 @@ func TestDashboardRendersFullTemplPage(t *testing.T) {
NewHandler(service).ServeHTTP(response, request)
body := response.Body.String()
- for _, expected := range []string{"", "DARANO", "1,234", "abc123", "htmx.org@2.0.10", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} {
+ for _, expected := range []string{"", "DARANO", "1,234", "abc123", "htmx.org@2.0.10", "src=\"/theme.js\"", "data-theme-toggle", "html[data-theme=\"dark\"]", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} {
if !strings.Contains(body, expected) {
t.Fatalf("response did not contain %q", expected)
}
@@ -183,6 +183,21 @@ func TestFaviconIsServedFromEmbeddedBrandAsset(t *testing.T) {
}
}
+func TestThemeScriptIsServedFromEmbeddedAsset(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "/theme.js", nil)
+ response := httptest.NewRecorder()
+
+ NewHandler(&explorerStub{}).ServeHTTP(response, request)
+
+ body := response.Body.String()
+ if response.Code != http.StatusOK || !strings.Contains(body, "localStorage") || !strings.Contains(body, "prefers-color-scheme") {
+ t.Fatalf("unexpected theme script response: status=%d body=%s", response.Code, body)
+ }
+ if !strings.Contains(response.Header().Get("Content-Type"), "javascript") {
+ t.Fatalf("unexpected theme script content type: %s", response.Header().Get("Content-Type"))
+ }
+}
+
func TestTransactionHTMXRequestRendersOnlyExplorerContent(t *testing.T) {
service := &explorerStub{transaction: []ledger.Journal{{
ID: "journal-1", EffectKind: "withdrawal",
diff --git a/interface/web/i18n.go b/interface/web/i18n.go
index fda10c0..34df9a6 100644
--- a/interface/web/i18n.go
+++ b/interface/web/i18n.go
@@ -95,6 +95,8 @@ var english = map[string]string{
"assets": "Assets",
"accounts": "Accounts",
"read_only": "READ ONLY",
+ "dark_mode": "Use dark mode",
+ "light_mode": "Use light mode",
"general_ledger_explorer": "General ledger explorer",
"hero_line_one": "Every movement.",
"hero_line_two": "One durable record.",
@@ -194,6 +196,8 @@ var persian = map[string]string{
"assets": "داراییها",
"accounts": "حسابها",
"read_only": "فقط خواندنی",
+ "dark_mode": "استفاده از حالت تیره",
+ "light_mode": "استفاده از حالت روشن",
"general_ledger_explorer": "کاوشگر دفتر کل",
"hero_line_one": "هر جابهجایی.",
"hero_line_two": "یک سابقه ماندگار.",
diff --git a/interface/web/templates.templ b/interface/web/templates.templ
index 75f4ebe..ce90fb9 100644
--- a/interface/web/templates.templ
+++ b/interface/web/templates.templ
@@ -17,12 +17,14 @@ templ Page(title string, active string, locale Locale, englishURL string, persia
{ title } · { tr(locale, "site_name") }
+
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "site_name"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 130, Col: 36}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 134, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -121,7 +121,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "site_subtitle"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 130, Col: 75}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 134, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -139,7 +139,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "overview"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 134, Col: 57}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 138, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -157,7 +157,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "overview"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 136, Col: 42}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 140, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -176,7 +176,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transactions"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 139, Col: 73}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 143, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -194,7 +194,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transactions"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 141, Col: 58}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 145, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -213,7 +213,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 144, Col: 61}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 148, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -231,7 +231,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 146, Col: 46}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 150, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -250,7 +250,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "accounts"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 149, Col: 65}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 153, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -268,7 +268,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "accounts"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 151, Col: 50}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 155, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -286,91 +286,130 @@ func Page(title string, active string, locale Locale, englishURL string, persian
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "read_only"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 153, Col: 75}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 157, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if locale == LocaleEnglish {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "EN ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- } else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "EN ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- }
- if locale == LocalePersian {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "فا")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- } else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "فا")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" hx-boost=\"false\" lang=\"en\">EN ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "EN ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "")
+ if locale == LocalePersian {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "فا")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "فا")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -378,33 +417,33 @@ func Page(title string, active string, locale Locale, englishURL string, persian
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "