feat(config-webui): add reset defaults button + /api/config/default
- commit
- c7dc5f29cbbc243c3ac1be1f55b331ad041534bf
- author
- brice · · 2025-12-20 18:47 UTC
- parent
- ee9c157
- signature
- unsigned
7 files · +889 -3
M internal/infrastructure/webkit/scheme_handler.go +130 -1
| @@ -3,6 +3,8 @@ package webkit | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "embed" | |
| 6 | + | "encoding/json" | |
| 7 | + | "fmt" | |
| 6 | 8 | "io/fs" | |
| 7 | 9 | "mime" | |
| 8 | 10 | "net/http" | |
| @@ -11,7 +13,9 @@ import ( | |||
| 11 | 13 | "strings" | |
| 12 | 14 | "sync" | |
| 13 | 15 | ||
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 14 | 17 | "github.com/bnema/dumber/internal/logging" | |
| 18 | + | "github.com/bnema/puregotk-webkit/soup" | |
| 15 | 19 | "github.com/bnema/puregotk-webkit/webkit" | |
| 16 | 20 | "github.com/jwijenbergh/puregotk/v4/gio" | |
| 17 | 21 | "github.com/rs/zerolog" | |
| @@ -20,6 +24,7 @@ import ( | |||
| 20 | 24 | // Scheme path constants | |
| 21 | 25 | const ( | |
| 22 | 26 | HomePath = "home" | |
| 27 | + | ConfigPath = "config" | |
| 23 | 28 | BlockedPath = "blocked" | |
| 24 | 29 | IndexHTML = "index.html" | |
| 25 | 30 | ) | |
| @@ -95,6 +100,98 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||
| 95 | 100 | StatusCode: http.StatusOK, | |
| 96 | 101 | } | |
| 97 | 102 | })) | |
| 103 | + | ||
| 104 | + | // API: Get current config (used by dumb://config) | |
| 105 | + | h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | |
| 106 | + | if req.Method != "GET" { | |
| 107 | + | return nil | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | cfg := config.Get() | |
| 111 | + | resp := struct { | |
| 112 | + | Appearance struct { | |
| 113 | + | SansFont string `json:"sans_font"` | |
| 114 | + | SerifFont string `json:"serif_font"` | |
| 115 | + | MonospaceFont string `json:"monospace_font"` | |
| 116 | + | DefaultFontSize int `json:"default_font_size"` | |
| 117 | + | ColorScheme string `json:"color_scheme"` | |
| 118 | + | LightPalette config.ColorPalette `json:"light_palette"` | |
| 119 | + | DarkPalette config.ColorPalette `json:"dark_palette"` | |
| 120 | + | } `json:"appearance"` | |
| 121 | + | DefaultUIScale float64 `json:"default_ui_scale"` | |
| 122 | + | DefaultSearchEngine string `json:"default_search_engine"` | |
| 123 | + | }{ | |
| 124 | + | DefaultUIScale: cfg.DefaultUIScale, | |
| 125 | + | DefaultSearchEngine: cfg.DefaultSearchEngine, | |
| 126 | + | } | |
| 127 | + | resp.Appearance.SansFont = cfg.Appearance.SansFont | |
| 128 | + | resp.Appearance.SerifFont = cfg.Appearance.SerifFont | |
| 129 | + | resp.Appearance.MonospaceFont = cfg.Appearance.MonospaceFont | |
| 130 | + | resp.Appearance.DefaultFontSize = cfg.Appearance.DefaultFontSize | |
| 131 | + | resp.Appearance.ColorScheme = cfg.Appearance.ColorScheme | |
| 132 | + | resp.Appearance.LightPalette = cfg.Appearance.LightPalette | |
| 133 | + | resp.Appearance.DarkPalette = cfg.Appearance.DarkPalette | |
| 134 | + | ||
| 135 | + | data, err := json.Marshal(resp) | |
| 136 | + | if err != nil { | |
| 137 | + | return &SchemeResponse{ | |
| 138 | + | Data: []byte(fmt.Sprintf(`{"error": "%s"}`, err)), | |
| 139 | + | ContentType: "application/json", | |
| 140 | + | StatusCode: http.StatusInternalServerError, | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | return &SchemeResponse{ | |
| 144 | + | Data: data, | |
| 145 | + | ContentType: "application/json", | |
| 146 | + | StatusCode: http.StatusOK, | |
| 147 | + | } | |
| 148 | + | })) | |
| 149 | + | ||
| 150 | + | // API: Get default config (used by Reset Defaults in dumb://config) | |
| 151 | + | h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | |
| 152 | + | if req.Method != "GET" { | |
| 153 | + | return nil | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | defaults := config.DefaultConfig() | |
| 157 | + | resp := struct { | |
| 158 | + | Appearance struct { | |
| 159 | + | SansFont string `json:"sans_font"` | |
| 160 | + | SerifFont string `json:"serif_font"` | |
| 161 | + | MonospaceFont string `json:"monospace_font"` | |
| 162 | + | DefaultFontSize int `json:"default_font_size"` | |
| 163 | + | ColorScheme string `json:"color_scheme"` | |
| 164 | + | LightPalette config.ColorPalette `json:"light_palette"` | |
| 165 | + | DarkPalette config.ColorPalette `json:"dark_palette"` | |
| 166 | + | } `json:"appearance"` | |
| 167 | + | DefaultUIScale float64 `json:"default_ui_scale"` | |
| 168 | + | DefaultSearchEngine string `json:"default_search_engine"` | |
| 169 | + | }{ | |
| 170 | + | DefaultUIScale: defaults.DefaultUIScale, | |
| 171 | + | DefaultSearchEngine: defaults.DefaultSearchEngine, | |
| 172 | + | } | |
| 173 | + | resp.Appearance.SansFont = defaults.Appearance.SansFont | |
| 174 | + | resp.Appearance.SerifFont = defaults.Appearance.SerifFont | |
| 175 | + | resp.Appearance.MonospaceFont = defaults.Appearance.MonospaceFont | |
| 176 | + | resp.Appearance.DefaultFontSize = defaults.Appearance.DefaultFontSize | |
| 177 | + | resp.Appearance.ColorScheme = defaults.Appearance.ColorScheme | |
| 178 | + | resp.Appearance.LightPalette = defaults.Appearance.LightPalette | |
| 179 | + | resp.Appearance.DarkPalette = defaults.Appearance.DarkPalette | |
| 180 | + | ||
| 181 | + | data, err := json.Marshal(resp) | |
| 182 | + | if err != nil { | |
| 183 | + | return &SchemeResponse{ | |
| 184 | + | Data: []byte(fmt.Sprintf(`{"error": "%s"}`, err)), | |
| 185 | + | ContentType: "application/json", | |
| 186 | + | StatusCode: http.StatusInternalServerError, | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | return &SchemeResponse{ | |
| 190 | + | Data: data, | |
| 191 | + | ContentType: "application/json", | |
| 192 | + | StatusCode: http.StatusOK, | |
| 193 | + | } | |
| 194 | + | })) | |
| 98 | 195 | } | |
| 99 | 196 | ||
| 100 | 197 | // RegisterPage registers a handler for a specific path. | |
| @@ -139,7 +236,22 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||
| 139 | 236 | return | |
| 140 | 237 | } | |
| 141 | 238 | ||
| 142 | - | // Try to serve from embedded assets first | |
| 239 | + | // API endpoints should never be treated as static assets | |
| 240 | + | if strings.HasPrefix(schemeReq.Path, "/api/") { | |
| 241 | + | h.mu.RLock() | |
| 242 | + | handler, ok := h.handlers[schemeReq.Path] | |
| 243 | + | if !ok { | |
| 244 | + | handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")] | |
| 245 | + | } | |
| 246 | + | h.mu.RUnlock() | |
| 247 | + | if ok { | |
| 248 | + | response := handler.Handle(schemeReq) | |
| 249 | + | h.sendResponse(req, response) | |
| 250 | + | return | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // Try to serve from embedded assets | |
| 143 | 255 | if response := h.handleAsset(u); response != nil { | |
| 144 | 256 | h.sendResponse(req, response) | |
| 145 | 257 | return | |
| @@ -191,6 +303,12 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||
| 191 | 303 | // dumb://home/<asset> → serve asset | |
| 192 | 304 | case host == HomePath && path != "": | |
| 193 | 305 | relPath = path | |
| 306 | + | // dumb://config or dumb://config/ → config.html | |
| 307 | + | case host == ConfigPath && (path == "" || path == "/"): | |
| 308 | + | relPath = "config.html" | |
| 309 | + | // dumb://config/<asset> → serve asset | |
| 310 | + | case host == ConfigPath && path != "": | |
| 311 | + | relPath = path | |
| 194 | 312 | // dumb://blocked or dumb://blocked/ → blocked.html | |
| 195 | 313 | case host == BlockedPath && (path == "" || path == "/"): | |
| 196 | 314 | relPath = "blocked.html" | |
| @@ -308,6 +426,17 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response | |||
| 308 | 426 | schemeResp.SetContentType(contentType) | |
| 309 | 427 | schemeResp.SetStatus(uint(response.StatusCode), nil) | |
| 310 | 428 | ||
| 429 | + | // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch(). | |
| 430 | + | // We only add CORS headers for our internal API endpoints. | |
| 431 | + | if strings.HasPrefix(req.GetPath(), "/api/") { | |
| 432 | + | hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue) | |
| 433 | + | hdrs.Append("Access-Control-Allow-Origin", "*") | |
| 434 | + | hdrs.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS") | |
| 435 | + | hdrs.Append("Access-Control-Allow-Headers", "Content-Type") | |
| 436 | + | hdrs.Append("Access-Control-Max-Age", "86400") | |
| 437 | + | schemeResp.SetHttpHeaders(hdrs) | |
| 438 | + | } | |
| 439 | + | ||
| 311 | 440 | req.FinishWithResponse(schemeResp) | |
| 312 | 441 | } | |
| 313 | 442 | ||
| @@ -3,6 +3,8 @@ package webkit | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "embed" | 5 | "embed" | ||
| 6 | + | "encoding/json" | |||
| 7 | + | "fmt" | |||
| 6 | "io/fs" | 8 | "io/fs" | ||
| 7 | "mime" | 9 | "mime" | ||
| 8 | "net/http" | 10 | "net/http" | ||
| @@ -11,7 +13,9 @@ import ( | |||||
| 11 | "strings" | 13 | "strings" | ||
| 12 | "sync" | 14 | "sync" | ||
| 13 | 15 | ||||
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 14 | "github.com/bnema/dumber/internal/logging" | 17 | "github.com/bnema/dumber/internal/logging" | ||
| 18 | + | "github.com/bnema/puregotk-webkit/soup" | |||
| 15 | "github.com/bnema/puregotk-webkit/webkit" | 19 | "github.com/bnema/puregotk-webkit/webkit" | ||
| 16 | "github.com/jwijenbergh/puregotk/v4/gio" | 20 | "github.com/jwijenbergh/puregotk/v4/gio" | ||
| 17 | "github.com/rs/zerolog" | 21 | "github.com/rs/zerolog" | ||
| @@ -20,6 +24,7 @@ import ( | |||||
| 20 | // Scheme path constants | 24 | // Scheme path constants | ||
| 21 | const ( | 25 | const ( | ||
| 22 | HomePath = "home" | 26 | HomePath = "home" | ||
| 27 | + | ConfigPath = "config" | |||
| 23 | BlockedPath = "blocked" | 28 | BlockedPath = "blocked" | ||
| 24 | IndexHTML = "index.html" | 29 | IndexHTML = "index.html" | ||
| 25 | ) | 30 | ) | ||
| @@ -95,6 +100,98 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||||
| 95 | StatusCode: http.StatusOK, | 100 | StatusCode: http.StatusOK, | ||
| 96 | } | 101 | } | ||
| 97 | })) | 102 | })) | ||
| 103 | + | ||||
| 104 | + | // API: Get current config (used by dumb://config) | |||
| 105 | + | h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | |||
| 106 | + | if req.Method != "GET" { | |||
| 107 | + | return nil | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | cfg := config.Get() | |||
| 111 | + | resp := struct { | |||
| 112 | + | Appearance struct { | |||
| 113 | + | SansFont string `json:"sans_font"` | |||
| 114 | + | SerifFont string `json:"serif_font"` | |||
| 115 | + | MonospaceFont string `json:"monospace_font"` | |||
| 116 | + | DefaultFontSize int `json:"default_font_size"` | |||
| 117 | + | ColorScheme string `json:"color_scheme"` | |||
| 118 | + | LightPalette config.ColorPalette `json:"light_palette"` | |||
| 119 | + | DarkPalette config.ColorPalette `json:"dark_palette"` | |||
| 120 | + | } `json:"appearance"` | |||
| 121 | + | DefaultUIScale float64 `json:"default_ui_scale"` | |||
| 122 | + | DefaultSearchEngine string `json:"default_search_engine"` | |||
| 123 | + | }{ | |||
| 124 | + | DefaultUIScale: cfg.DefaultUIScale, | |||
| 125 | + | DefaultSearchEngine: cfg.DefaultSearchEngine, | |||
| 126 | + | } | |||
| 127 | + | resp.Appearance.SansFont = cfg.Appearance.SansFont | |||
| 128 | + | resp.Appearance.SerifFont = cfg.Appearance.SerifFont | |||
| 129 | + | resp.Appearance.MonospaceFont = cfg.Appearance.MonospaceFont | |||
| 130 | + | resp.Appearance.DefaultFontSize = cfg.Appearance.DefaultFontSize | |||
| 131 | + | resp.Appearance.ColorScheme = cfg.Appearance.ColorScheme | |||
| 132 | + | resp.Appearance.LightPalette = cfg.Appearance.LightPalette | |||
| 133 | + | resp.Appearance.DarkPalette = cfg.Appearance.DarkPalette | |||
| 134 | + | ||||
| 135 | + | data, err := json.Marshal(resp) | |||
| 136 | + | if err != nil { | |||
| 137 | + | return &SchemeResponse{ | |||
| 138 | + | Data: []byte(fmt.Sprintf(`{"error": "%s"}`, err)), | |||
| 139 | + | ContentType: "application/json", | |||
| 140 | + | StatusCode: http.StatusInternalServerError, | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | return &SchemeResponse{ | |||
| 144 | + | Data: data, | |||
| 145 | + | ContentType: "application/json", | |||
| 146 | + | StatusCode: http.StatusOK, | |||
| 147 | + | } | |||
| 148 | + | })) | |||
| 149 | + | ||||
| 150 | + | // API: Get default config (used by Reset Defaults in dumb://config) | |||
| 151 | + | h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | |||
| 152 | + | if req.Method != "GET" { | |||
| 153 | + | return nil | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | defaults := config.DefaultConfig() | |||
| 157 | + | resp := struct { | |||
| 158 | + | Appearance struct { | |||
| 159 | + | SansFont string `json:"sans_font"` | |||
| 160 | + | SerifFont string `json:"serif_font"` | |||
| 161 | + | MonospaceFont string `json:"monospace_font"` | |||
| 162 | + | DefaultFontSize int `json:"default_font_size"` | |||
| 163 | + | ColorScheme string `json:"color_scheme"` | |||
| 164 | + | LightPalette config.ColorPalette `json:"light_palette"` | |||
| 165 | + | DarkPalette config.ColorPalette `json:"dark_palette"` | |||
| 166 | + | } `json:"appearance"` | |||
| 167 | + | DefaultUIScale float64 `json:"default_ui_scale"` | |||
| 168 | + | DefaultSearchEngine string `json:"default_search_engine"` | |||
| 169 | + | }{ | |||
| 170 | + | DefaultUIScale: defaults.DefaultUIScale, | |||
| 171 | + | DefaultSearchEngine: defaults.DefaultSearchEngine, | |||
| 172 | + | } | |||
| 173 | + | resp.Appearance.SansFont = defaults.Appearance.SansFont | |||
| 174 | + | resp.Appearance.SerifFont = defaults.Appearance.SerifFont | |||
| 175 | + | resp.Appearance.MonospaceFont = defaults.Appearance.MonospaceFont | |||
| 176 | + | resp.Appearance.DefaultFontSize = defaults.Appearance.DefaultFontSize | |||
| 177 | + | resp.Appearance.ColorScheme = defaults.Appearance.ColorScheme | |||
| 178 | + | resp.Appearance.LightPalette = defaults.Appearance.LightPalette | |||
| 179 | + | resp.Appearance.DarkPalette = defaults.Appearance.DarkPalette | |||
| 180 | + | ||||
| 181 | + | data, err := json.Marshal(resp) | |||
| 182 | + | if err != nil { | |||
| 183 | + | return &SchemeResponse{ | |||
| 184 | + | Data: []byte(fmt.Sprintf(`{"error": "%s"}`, err)), | |||
| 185 | + | ContentType: "application/json", | |||
| 186 | + | StatusCode: http.StatusInternalServerError, | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | return &SchemeResponse{ | |||
| 190 | + | Data: data, | |||
| 191 | + | ContentType: "application/json", | |||
| 192 | + | StatusCode: http.StatusOK, | |||
| 193 | + | } | |||
| 194 | + | })) | |||
| 98 | } | 195 | } | ||
| 99 | 196 | ||||
| 100 | // RegisterPage registers a handler for a specific path. | 197 | // RegisterPage registers a handler for a specific path. | ||
| @@ -139,7 +236,22 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||||
| 139 | return | 236 | return | ||
| 140 | } | 237 | } | ||
| 141 | 238 | ||||
| 142 | - | // Try to serve from embedded assets first | 239 | + | // API endpoints should never be treated as static assets |
| 240 | + | if strings.HasPrefix(schemeReq.Path, "/api/") { | |||
| 241 | + | h.mu.RLock() | |||
| 242 | + | handler, ok := h.handlers[schemeReq.Path] | |||
| 243 | + | if !ok { | |||
| 244 | + | handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")] | |||
| 245 | + | } | |||
| 246 | + | h.mu.RUnlock() | |||
| 247 | + | if ok { | |||
| 248 | + | response := handler.Handle(schemeReq) | |||
| 249 | + | h.sendResponse(req, response) | |||
| 250 | + | return | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | // Try to serve from embedded assets | |||
| 143 | if response := h.handleAsset(u); response != nil { | 255 | if response := h.handleAsset(u); response != nil { | ||
| 144 | h.sendResponse(req, response) | 256 | h.sendResponse(req, response) | ||
| 145 | return | 257 | return | ||
| @@ -191,6 +303,12 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||||
| 191 | // dumb://home/<asset> → serve asset | 303 | // dumb://home/<asset> → serve asset | ||
| 192 | case host == HomePath && path != "": | 304 | case host == HomePath && path != "": | ||
| 193 | relPath = path | 305 | relPath = path | ||
| 306 | + | // dumb://config or dumb://config/ → config.html | |||
| 307 | + | case host == ConfigPath && (path == "" || path == "/"): | |||
| 308 | + | relPath = "config.html" | |||
| 309 | + | // dumb://config/<asset> → serve asset | |||
| 310 | + | case host == ConfigPath && path != "": | |||
| 311 | + | relPath = path | |||
| 194 | // dumb://blocked or dumb://blocked/ → blocked.html | 312 | // dumb://blocked or dumb://blocked/ → blocked.html | ||
| 195 | case host == BlockedPath && (path == "" || path == "/"): | 313 | case host == BlockedPath && (path == "" || path == "/"): | ||
| 196 | relPath = "blocked.html" | 314 | relPath = "blocked.html" | ||
| @@ -308,6 +426,17 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response | |||||
| 308 | schemeResp.SetContentType(contentType) | 426 | schemeResp.SetContentType(contentType) | ||
| 309 | schemeResp.SetStatus(uint(response.StatusCode), nil) | 427 | schemeResp.SetStatus(uint(response.StatusCode), nil) | ||
| 310 | 428 | ||||
| 429 | + | // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch(). | |||
| 430 | + | // We only add CORS headers for our internal API endpoints. | |||
| 431 | + | if strings.HasPrefix(req.GetPath(), "/api/") { | |||
| 432 | + | hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue) | |||
| 433 | + | hdrs.Append("Access-Control-Allow-Origin", "*") | |||
| 434 | + | hdrs.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS") | |||
| 435 | + | hdrs.Append("Access-Control-Allow-Headers", "Content-Type") | |||
| 436 | + | hdrs.Append("Access-Control-Max-Age", "86400") | |||
| 437 | + | schemeResp.SetHttpHeaders(hdrs) | |||
| 438 | + | } | |||
| 439 | + | ||||
| 311 | req.FinishWithResponse(schemeResp) | 440 | req.FinishWithResponse(schemeResp) | ||
| 312 | } | 441 | } | ||
| 313 | 442 | ||||
M webui/package.json +3 -1
| @@ -4,12 +4,14 @@ | |||
| 4 | 4 | "private": true, | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "scripts": { | |
| 7 | - | "build": "VITE_PAGE=homepage vite build && VITE_PAGE=blocked vite build", | |
| 7 | + | "build": "VITE_PAGE=homepage vite build && VITE_PAGE=blocked vite build && VITE_PAGE=config vite build", | |
| 8 | 8 | "build:homepage": "VITE_PAGE=homepage vite build", | |
| 9 | 9 | "build:blocked": "VITE_PAGE=blocked vite build", | |
| 10 | + | "build:config": "VITE_PAGE=config vite build", | |
| 10 | 11 | "dev": "VITE_PAGE=homepage vite build --watch", | |
| 11 | 12 | "dev:homepage": "VITE_PAGE=homepage vite build --watch", | |
| 12 | 13 | "dev:blocked": "VITE_PAGE=blocked vite build --watch", | |
| 14 | + | "dev:config": "VITE_PAGE=config vite build --watch", | |
| 13 | 15 | "check": "svelte-check", | |
| 14 | 16 | "lint": "eslint src --fix", | |
| 15 | 17 | "fmt": "prettier --write ." | |
| @@ -4,12 +4,14 @@ | |||||
| 4 | "private": true, | 4 | "private": true, | ||
| 5 | "type": "module", | 5 | "type": "module", | ||
| 6 | "scripts": { | 6 | "scripts": { | ||
| 7 | - | "build": "VITE_PAGE=homepage vite build && VITE_PAGE=blocked vite build", | 7 | + | "build": "VITE_PAGE=homepage vite build && VITE_PAGE=blocked vite build && VITE_PAGE=config vite build", |
| 8 | "build:homepage": "VITE_PAGE=homepage vite build", | 8 | "build:homepage": "VITE_PAGE=homepage vite build", | ||
| 9 | "build:blocked": "VITE_PAGE=blocked vite build", | 9 | "build:blocked": "VITE_PAGE=blocked vite build", | ||
| 10 | + | "build:config": "VITE_PAGE=config vite build", | |||
| 10 | "dev": "VITE_PAGE=homepage vite build --watch", | 11 | "dev": "VITE_PAGE=homepage vite build --watch", | ||
| 11 | "dev:homepage": "VITE_PAGE=homepage vite build --watch", | 12 | "dev:homepage": "VITE_PAGE=homepage vite build --watch", | ||
| 12 | "dev:blocked": "VITE_PAGE=blocked vite build --watch", | 13 | "dev:blocked": "VITE_PAGE=blocked vite build --watch", | ||
| 14 | + | "dev:config": "VITE_PAGE=config vite build --watch", | |||
| 13 | "check": "svelte-check", | 15 | "check": "svelte-check", | ||
| 14 | "lint": "eslint src --fix", | 16 | "lint": "eslint src --fix", | ||
| 15 | "fmt": "prettier --write ." | 17 | "fmt": "prettier --write ." | ||
A webui/src/pages/ConfigPage.svelte +479 -0
| @@ -0,0 +1,479 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { onMount } from "svelte"; | |
| 3 | + | import ConfigShell from "./config/ConfigShell.svelte"; | |
| 4 | + | ||
| 5 | + | type ColorPalette = { | |
| 6 | + | background: string; | |
| 7 | + | surface: string; | |
| 8 | + | surface_variant: string; | |
| 9 | + | text: string; | |
| 10 | + | muted: string; | |
| 11 | + | accent: string; | |
| 12 | + | border: string; | |
| 13 | + | }; | |
| 14 | + | ||
| 15 | + | type ConfigDTO = { | |
| 16 | + | appearance: { | |
| 17 | + | sans_font: string; | |
| 18 | + | serif_font: string; | |
| 19 | + | monospace_font: string; | |
| 20 | + | default_font_size: number; | |
| 21 | + | color_scheme: string; | |
| 22 | + | light_palette: ColorPalette; | |
| 23 | + | dark_palette: ColorPalette; | |
| 24 | + | }; | |
| 25 | + | default_ui_scale: number; | |
| 26 | + | default_search_engine: string; | |
| 27 | + | }; | |
| 28 | + | ||
| 29 | + | const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [ | |
| 30 | + | { key: "background", label: "Background" }, | |
| 31 | + | { key: "surface", label: "Surface" }, | |
| 32 | + | { key: "surface_variant", label: "Surface Variant" }, | |
| 33 | + | { key: "text", label: "Text" }, | |
| 34 | + | { key: "muted", label: "Muted" }, | |
| 35 | + | { key: "accent", label: "Accent" }, | |
| 36 | + | { key: "border", label: "Border" }, | |
| 37 | + | ]; | |
| 38 | + | ||
| 39 | + | function isHexColor(value: string): boolean { | |
| 40 | + | return /^#[0-9a-fA-F]{6}$/.test(value); | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | ||
| 44 | + | let config = $state<ConfigDTO | null>(null); | |
| 45 | + | let loading = $state(true); | |
| 46 | + | let error = $state<string | null>(null); | |
| 47 | + | let saving = $state(false); | |
| 48 | + | let saveSuccess = $state(false); | |
| 49 | + | let themeEvents = $state(0); | |
| 50 | + | ||
| 51 | + | async function fetchConfig() { | |
| 52 | + | try { | |
| 53 | + | const response = await fetch("/api/config"); | |
| 54 | + | if (!response.ok) throw new Error("Failed to fetch config"); | |
| 55 | + | config = (await response.json()) as ConfigDTO; | |
| 56 | + | } catch (e: any) { | |
| 57 | + | error = e.message; | |
| 58 | + | } finally { | |
| 59 | + | loading = false; | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | async function resetToDefaults() { | |
| 64 | + | error = null; | |
| 65 | + | saveSuccess = false; | |
| 66 | + | ||
| 67 | + | const confirmed = window.confirm( | |
| 68 | + | "Reset this page to default settings? (You still need to click Save to persist.)", | |
| 69 | + | ); | |
| 70 | + | if (!confirmed) return; | |
| 71 | + | ||
| 72 | + | try { | |
| 73 | + | const response = await fetch("/api/config/default"); | |
| 74 | + | if (!response.ok) throw new Error("Failed to fetch default config"); | |
| 75 | + | config = (await response.json()) as ConfigDTO; | |
| 76 | + | } catch (e: any) { | |
| 77 | + | error = e.message; | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { | |
| 82 | + | const bridge = (window as any).webkit?.messageHandlers?.dumber; | |
| 83 | + | if (bridge && typeof bridge.postMessage === "function") { | |
| 84 | + | return bridge; | |
| 85 | + | } | |
| 86 | + | return null; | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | function getWebViewId(): number { | |
| 90 | + | return (window as any).__dumber_webview_id || 0; | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | async function saveConfig() { | |
| 94 | + | saving = true; | |
| 95 | + | saveSuccess = false; | |
| 96 | + | error = null; | |
| 97 | + | ||
| 98 | + | try { | |
| 99 | + | const bridge = getWebKitBridge(); | |
| 100 | + | const webviewId = getWebViewId(); | |
| 101 | + | ||
| 102 | + | console.debug("[config] save click", { | |
| 103 | + | hasBridge: Boolean(bridge), | |
| 104 | + | webviewId, | |
| 105 | + | hasConfig: Boolean(config), | |
| 106 | + | }); | |
| 107 | + | ||
| 108 | + | if (!bridge) { | |
| 109 | + | throw new Error("WebKit bridge not available (not running inside Dumber)"); | |
| 110 | + | } | |
| 111 | + | if (!webviewId) { | |
| 112 | + | throw new Error("Missing window.__dumber_webview_id; cannot dispatch/receive callbacks"); | |
| 113 | + | } | |
| 114 | + | if (!config) { | |
| 115 | + | throw new Error("Config not loaded"); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | let timeout: number | null = window.setTimeout(() => { | |
| 119 | + | if (saving) { | |
| 120 | + | console.warn("[config] save timed out (no callback)"); | |
| 121 | + | error = "Save timed out (no response from native handler)"; | |
| 122 | + | saving = false; | |
| 123 | + | } | |
| 124 | + | }, 8000); | |
| 125 | + | ||
| 126 | + | const clearSaveTimeout = () => { | |
| 127 | + | if (timeout != null) { | |
| 128 | + | window.clearTimeout(timeout); | |
| 129 | + | timeout = null; | |
| 130 | + | } | |
| 131 | + | }; | |
| 132 | + | ||
| 133 | + | (window as any).__dumber_config_saved = (resp?: unknown) => { | |
| 134 | + | clearSaveTimeout(); | |
| 135 | + | console.debug("[config] save success", resp); | |
| 136 | + | saveSuccess = true; | |
| 137 | + | saving = false; | |
| 138 | + | ||
| 139 | + | // Refresh config state from backend (in case watcher normalized values) | |
| 140 | + | fetchConfig(); | |
| 141 | + | ||
| 142 | + | setTimeout(() => { | |
| 143 | + | saveSuccess = false; | |
| 144 | + | }, 3000); | |
| 145 | + | }; | |
| 146 | + | (window as any).__dumber_config_error = (msg: unknown) => { | |
| 147 | + | clearSaveTimeout(); | |
| 148 | + | console.error("[config] save error", msg); | |
| 149 | + | error = typeof msg === "string" ? msg : "Failed to save config"; | |
| 150 | + | saving = false; | |
| 151 | + | }; | |
| 152 | + | ||
| 153 | + | const payload = $state.snapshot(config); | |
| 154 | + | ||
| 155 | + | try { | |
| 156 | + | bridge.postMessage({ | |
| 157 | + | type: "save_config", | |
| 158 | + | webview_id: webviewId, | |
| 159 | + | payload, | |
| 160 | + | }); | |
| 161 | + | console.debug("[config] postMessage sent", { payloadBytes: JSON.stringify(payload).length }); | |
| 162 | + | } catch (postErr) { | |
| 163 | + | console.error("[config] postMessage threw", postErr); | |
| 164 | + | throw postErr; | |
| 165 | + | } | |
| 166 | + | } catch (e: any) { | |
| 167 | + | console.error("[config] save exception", e); | |
| 168 | + | error = e.message; | |
| 169 | + | saving = false; | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | onMount(() => { | |
| 174 | + | console.debug("[config] mount", { | |
| 175 | + | webviewId: (window as any).__dumber_webview_id, | |
| 176 | + | hasBridge: Boolean((window as any).webkit?.messageHandlers?.dumber), | |
| 177 | + | }); | |
| 178 | + | ||
| 179 | + | const onThemeChanged = (e: Event) => { | |
| 180 | + | themeEvents += 1; | |
| 181 | + | console.debug("[config] theme changed", (e as CustomEvent).detail); | |
| 182 | + | }; | |
| 183 | + | window.addEventListener('dumber:theme-changed', onThemeChanged); | |
| 184 | + | ||
| 185 | + | fetchConfig(); | |
| 186 | + | ||
| 187 | + | return () => { | |
| 188 | + | window.removeEventListener('dumber:theme-changed', onThemeChanged); | |
| 189 | + | }; | |
| 190 | + | }); | |
| 191 | + | </script> | |
| 192 | + | ||
| 193 | + | <ConfigShell> | |
| 194 | + | <div class="config-content"> | |
| 195 | + | {#if loading} | |
| 196 | + | <div class="loading">LOADING…</div> | |
| 197 | + | {:else if error} | |
| 198 | + | <div class="error"> | |
| 199 | + | <strong>ERROR</strong> | |
| 200 | + | <span>{error}</span> | |
| 201 | + | </div> | |
| 202 | + | {/if} | |
| 203 | + | ||
| 204 | + | {#if config} | |
| 205 | + | <div class="stack"> | |
| 206 | + | <!-- Appearance Section --> | |
| 207 | + | <section class="border border-border bg-card p-6 text-card-foreground"> | |
| 208 | + | <h2 class="mb-6 text-xl font-semibold">Appearance</h2> | |
| 209 | + | ||
| 210 | + | <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| 211 | + | <div class="space-y-2"> | |
| 212 | + | <label for="sans_font" class="block text-sm font-medium text-muted-foreground">Sans-Serif Font</label> | |
| 213 | + | <input | |
| 214 | + | id="sans_font" | |
| 215 | + | type="text" | |
| 216 | + | bind:value={config.appearance.sans_font} | |
| 217 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 218 | + | /> | |
| 219 | + | </div> | |
| 220 | + | ||
| 221 | + | <div class="space-y-2"> | |
| 222 | + | <label for="serif_font" class="block text-sm font-medium text-muted-foreground">Serif Font</label> | |
| 223 | + | <input | |
| 224 | + | id="serif_font" | |
| 225 | + | type="text" | |
| 226 | + | bind:value={config.appearance.serif_font} | |
| 227 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 228 | + | /> | |
| 229 | + | </div> | |
| 230 | + | ||
| 231 | + | <div class="space-y-2"> | |
| 232 | + | <label for="monospace_font" class="block text-sm font-medium text-muted-foreground">Monospace Font</label> | |
| 233 | + | <input | |
| 234 | + | id="monospace_font" | |
| 235 | + | type="text" | |
| 236 | + | bind:value={config.appearance.monospace_font} | |
| 237 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 238 | + | /> | |
| 239 | + | </div> | |
| 240 | + | ||
| 241 | + | <div class="space-y-2"> | |
| 242 | + | <label for="font_size" class="block text-sm font-medium text-muted-foreground">Default Font Size</label> | |
| 243 | + | <input | |
| 244 | + | id="font_size" | |
| 245 | + | type="number" | |
| 246 | + | bind:value={config.appearance.default_font_size} | |
| 247 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 248 | + | /> | |
| 249 | + | </div> | |
| 250 | + | ||
| 251 | + | <div class="space-y-2"> | |
| 252 | + | <label for="color_scheme" class="block text-sm font-medium text-muted-foreground">Color Scheme</label> | |
| 253 | + | <select | |
| 254 | + | id="color_scheme" | |
| 255 | + | bind:value={config.appearance.color_scheme} | |
| 256 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 257 | + | > | |
| 258 | + | <option value="default">Follow System</option> | |
| 259 | + | <option value="prefer-dark">Always Dark</option> | |
| 260 | + | <option value="prefer-light">Always Light</option> | |
| 261 | + | </select> | |
| 262 | + | </div> | |
| 263 | + | ||
| 264 | + | <div class="space-y-2"> | |
| 265 | + | <label for="ui_scale" class="block text-sm font-medium text-muted-foreground">UI Scale</label> | |
| 266 | + | <input | |
| 267 | + | id="ui_scale" | |
| 268 | + | type="number" | |
| 269 | + | step="0.1" | |
| 270 | + | bind:value={config.default_ui_scale} | |
| 271 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 272 | + | /> | |
| 273 | + | </div> | |
| 274 | + | </div> | |
| 275 | + | ||
| 276 | + | <div class="mt-8 space-y-8"> | |
| 277 | + | <div> | |
| 278 | + | <h3 class="mb-3 text-base font-semibold">Theme: Light</h3> | |
| 279 | + | <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| 280 | + | {#each PALETTE_FIELDS as field (field.key)} | |
| 281 | + | <div class="space-y-2"> | |
| 282 | + | <label class="block text-sm font-medium text-muted-foreground" for={`light_${field.key}`} | |
| 283 | + | >{field.label}</label | |
| 284 | + | > | |
| 285 | + | <div class="flex items-center gap-3"> | |
| 286 | + | <input | |
| 287 | + | id={`light_${field.key}`} | |
| 288 | + | type="color" | |
| 289 | + | value={config.appearance.light_palette[field.key]} | |
| 290 | + | oninput={(e) => { | |
| 291 | + | const v = (e.currentTarget as HTMLInputElement).value; | |
| 292 | + | config!.appearance.light_palette[field.key] = v; | |
| 293 | + | }} | |
| 294 | + | class="h-10 w-12 border border-input bg-background" | |
| 295 | + | /> | |
| 296 | + | <input | |
| 297 | + | type="text" | |
| 298 | + | bind:value={config.appearance.light_palette[field.key]} | |
| 299 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 300 | + | /> | |
| 301 | + | </div> | |
| 302 | + | {#if !isHexColor(config.appearance.light_palette[field.key])} | |
| 303 | + | <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p> | |
| 304 | + | {/if} | |
| 305 | + | </div> | |
| 306 | + | {/each} | |
| 307 | + | </div> | |
| 308 | + | </div> | |
| 309 | + | ||
| 310 | + | <div> | |
| 311 | + | <h3 class="mb-3 text-base font-semibold">Theme: Dark</h3> | |
| 312 | + | <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| 313 | + | {#each PALETTE_FIELDS as field (field.key)} | |
| 314 | + | <div class="space-y-2"> | |
| 315 | + | <label class="block text-sm font-medium text-muted-foreground" for={`dark_${field.key}`} | |
| 316 | + | >{field.label}</label | |
| 317 | + | > | |
| 318 | + | <div class="flex items-center gap-3"> | |
| 319 | + | <input | |
| 320 | + | id={`dark_${field.key}`} | |
| 321 | + | type="color" | |
| 322 | + | value={config.appearance.dark_palette[field.key]} | |
| 323 | + | oninput={(e) => { | |
| 324 | + | const v = (e.currentTarget as HTMLInputElement).value; | |
| 325 | + | config!.appearance.dark_palette[field.key] = v; | |
| 326 | + | }} | |
| 327 | + | class="h-10 w-12 border border-input bg-background" | |
| 328 | + | /> | |
| 329 | + | <input | |
| 330 | + | type="text" | |
| 331 | + | bind:value={config.appearance.dark_palette[field.key]} | |
| 332 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 333 | + | /> | |
| 334 | + | </div> | |
| 335 | + | {#if !isHexColor(config.appearance.dark_palette[field.key])} | |
| 336 | + | <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p> | |
| 337 | + | {/if} | |
| 338 | + | </div> | |
| 339 | + | {/each} | |
| 340 | + | </div> | |
| 341 | + | </div> | |
| 342 | + | </div> | |
| 343 | + | </section> | |
| 344 | + | ||
| 345 | + | <!-- Search Section --> | |
| 346 | + | <section class="border border-border bg-card p-6 text-card-foreground"> | |
| 347 | + | <h2 class="mb-6 text-xl font-semibold">Search</h2> | |
| 348 | + | <div class="space-y-4"> | |
| 349 | + | <div class="space-y-2"> | |
| 350 | + | <label for="search_engine" class="block text-sm font-medium text-muted-foreground">Default Search Engine (use %s for query)</label> | |
| 351 | + | <input | |
| 352 | + | id="search_engine" | |
| 353 | + | type="text" | |
| 354 | + | bind:value={config.default_search_engine} | |
| 355 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |
| 356 | + | /> | |
| 357 | + | </div> | |
| 358 | + | </div> | |
| 359 | + | </section> | |
| 360 | + | ||
| 361 | + | <div class="actions"> | |
| 362 | + | {#if saveSuccess} | |
| 363 | + | <span class="saved">SAVED</span> | |
| 364 | + | {/if} | |
| 365 | + | {#if themeEvents > 0} | |
| 366 | + | <span class="hint">THEME×{themeEvents}</span> | |
| 367 | + | {/if} | |
| 368 | + | <button onclick={resetToDefaults} disabled={saving} class="reset-button" type="button"> | |
| 369 | + | RESET DEFAULTS | |
| 370 | + | </button> | |
| 371 | + | <button onclick={saveConfig} disabled={saving} class="save-button" type="button"> | |
| 372 | + | {saving ? "SAVING…" : "SAVE"} | |
| 373 | + | </button> | |
| 374 | + | </div> | |
| 375 | + | </div> | |
| 376 | + | {/if} | |
| 377 | + | </div> | |
| 378 | + | </ConfigShell> | |
| 379 | + | ||
| 380 | + | <style> | |
| 381 | + | .config-content { | |
| 382 | + | width: 100%; | |
| 383 | + | max-width: 980px; | |
| 384 | + | margin: 0 auto; | |
| 385 | + | padding: 1.5rem 1.25rem 2rem; | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | .stack { | |
| 389 | + | display: flex; | |
| 390 | + | flex-direction: column; | |
| 391 | + | gap: 1.25rem; | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | .loading { | |
| 395 | + | padding: 2rem 0; | |
| 396 | + | color: var(--muted-foreground); | |
| 397 | + | letter-spacing: 0.1em; | |
| 398 | + | font-size: 0.8rem; | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | .error { | |
| 402 | + | padding: 0.75rem 1rem; | |
| 403 | + | border: 1px solid color-mix(in srgb, var(--border) 50%, #ef4444 50%); | |
| 404 | + | background: color-mix(in srgb, var(--card) 70%, #ef4444 30%); | |
| 405 | + | display: flex; | |
| 406 | + | flex-direction: column; | |
| 407 | + | gap: 0.25rem; | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | .actions { | |
| 411 | + | display: flex; | |
| 412 | + | align-items: center; | |
| 413 | + | justify-content: flex-end; | |
| 414 | + | gap: 0.75rem; | |
| 415 | + | padding-top: 0.75rem; | |
| 416 | + | border-top: 1px solid var(--border); | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | .saved { | |
| 420 | + | font-size: 0.7rem; | |
| 421 | + | letter-spacing: 0.12em; | |
| 422 | + | color: var(--primary, #4ade80); | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | .hint { | |
| 426 | + | font-size: 0.7rem; | |
| 427 | + | letter-spacing: 0.12em; | |
| 428 | + | color: var(--muted-foreground); | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | .reset-button { | |
| 432 | + | display: inline-flex; | |
| 433 | + | align-items: center; | |
| 434 | + | justify-content: center; | |
| 435 | + | padding: 0.55rem 1rem; | |
| 436 | + | font-size: 0.7rem; | |
| 437 | + | font-weight: 600; | |
| 438 | + | letter-spacing: 0.12em; | |
| 439 | + | color: var(--foreground); | |
| 440 | + | background: transparent; | |
| 441 | + | border: 1px solid var(--border); | |
| 442 | + | cursor: pointer; | |
| 443 | + | transition: all 120ms ease; | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | .reset-button:hover { | |
| 447 | + | background: color-mix(in srgb, var(--card) 25%, transparent); | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | .reset-button:disabled { | |
| 451 | + | opacity: 0.6; | |
| 452 | + | cursor: not-allowed; | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | .save-button { | |
| 456 | + | display: inline-flex; | |
| 457 | + | align-items: center; | |
| 458 | + | justify-content: center; | |
| 459 | + | padding: 0.55rem 1rem; | |
| 460 | + | font-size: 0.75rem; | |
| 461 | + | font-weight: 600; | |
| 462 | + | letter-spacing: 0.12em; | |
| 463 | + | color: var(--background); | |
| 464 | + | background: var(--primary, #4ade80); | |
| 465 | + | border: 1px solid color-mix(in srgb, var(--primary, #4ade80) 70%, var(--border) 30%); | |
| 466 | + | cursor: pointer; | |
| 467 | + | transition: all 120ms ease; | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | .save-button:hover { | |
| 471 | + | filter: brightness(1.02); | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | .save-button:disabled { | |
| 475 | + | opacity: 0.6; | |
| 476 | + | cursor: not-allowed; | |
| 477 | + | } | |
| 478 | + | </style> | |
| 479 | + | ||
| @@ -0,0 +1,479 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { onMount } from "svelte"; | |||
| 3 | + | import ConfigShell from "./config/ConfigShell.svelte"; | |||
| 4 | + | ||||
| 5 | + | type ColorPalette = { | |||
| 6 | + | background: string; | |||
| 7 | + | surface: string; | |||
| 8 | + | surface_variant: string; | |||
| 9 | + | text: string; | |||
| 10 | + | muted: string; | |||
| 11 | + | accent: string; | |||
| 12 | + | border: string; | |||
| 13 | + | }; | |||
| 14 | + | ||||
| 15 | + | type ConfigDTO = { | |||
| 16 | + | appearance: { | |||
| 17 | + | sans_font: string; | |||
| 18 | + | serif_font: string; | |||
| 19 | + | monospace_font: string; | |||
| 20 | + | default_font_size: number; | |||
| 21 | + | color_scheme: string; | |||
| 22 | + | light_palette: ColorPalette; | |||
| 23 | + | dark_palette: ColorPalette; | |||
| 24 | + | }; | |||
| 25 | + | default_ui_scale: number; | |||
| 26 | + | default_search_engine: string; | |||
| 27 | + | }; | |||
| 28 | + | ||||
| 29 | + | const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [ | |||
| 30 | + | { key: "background", label: "Background" }, | |||
| 31 | + | { key: "surface", label: "Surface" }, | |||
| 32 | + | { key: "surface_variant", label: "Surface Variant" }, | |||
| 33 | + | { key: "text", label: "Text" }, | |||
| 34 | + | { key: "muted", label: "Muted" }, | |||
| 35 | + | { key: "accent", label: "Accent" }, | |||
| 36 | + | { key: "border", label: "Border" }, | |||
| 37 | + | ]; | |||
| 38 | + | ||||
| 39 | + | function isHexColor(value: string): boolean { | |||
| 40 | + | return /^#[0-9a-fA-F]{6}$/.test(value); | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | ||||
| 44 | + | let config = $state<ConfigDTO | null>(null); | |||
| 45 | + | let loading = $state(true); | |||
| 46 | + | let error = $state<string | null>(null); | |||
| 47 | + | let saving = $state(false); | |||
| 48 | + | let saveSuccess = $state(false); | |||
| 49 | + | let themeEvents = $state(0); | |||
| 50 | + | ||||
| 51 | + | async function fetchConfig() { | |||
| 52 | + | try { | |||
| 53 | + | const response = await fetch("/api/config"); | |||
| 54 | + | if (!response.ok) throw new Error("Failed to fetch config"); | |||
| 55 | + | config = (await response.json()) as ConfigDTO; | |||
| 56 | + | } catch (e: any) { | |||
| 57 | + | error = e.message; | |||
| 58 | + | } finally { | |||
| 59 | + | loading = false; | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | async function resetToDefaults() { | |||
| 64 | + | error = null; | |||
| 65 | + | saveSuccess = false; | |||
| 66 | + | ||||
| 67 | + | const confirmed = window.confirm( | |||
| 68 | + | "Reset this page to default settings? (You still need to click Save to persist.)", | |||
| 69 | + | ); | |||
| 70 | + | if (!confirmed) return; | |||
| 71 | + | ||||
| 72 | + | try { | |||
| 73 | + | const response = await fetch("/api/config/default"); | |||
| 74 | + | if (!response.ok) throw new Error("Failed to fetch default config"); | |||
| 75 | + | config = (await response.json()) as ConfigDTO; | |||
| 76 | + | } catch (e: any) { | |||
| 77 | + | error = e.message; | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { | |||
| 82 | + | const bridge = (window as any).webkit?.messageHandlers?.dumber; | |||
| 83 | + | if (bridge && typeof bridge.postMessage === "function") { | |||
| 84 | + | return bridge; | |||
| 85 | + | } | |||
| 86 | + | return null; | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | function getWebViewId(): number { | |||
| 90 | + | return (window as any).__dumber_webview_id || 0; | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | async function saveConfig() { | |||
| 94 | + | saving = true; | |||
| 95 | + | saveSuccess = false; | |||
| 96 | + | error = null; | |||
| 97 | + | ||||
| 98 | + | try { | |||
| 99 | + | const bridge = getWebKitBridge(); | |||
| 100 | + | const webviewId = getWebViewId(); | |||
| 101 | + | ||||
| 102 | + | console.debug("[config] save click", { | |||
| 103 | + | hasBridge: Boolean(bridge), | |||
| 104 | + | webviewId, | |||
| 105 | + | hasConfig: Boolean(config), | |||
| 106 | + | }); | |||
| 107 | + | ||||
| 108 | + | if (!bridge) { | |||
| 109 | + | throw new Error("WebKit bridge not available (not running inside Dumber)"); | |||
| 110 | + | } | |||
| 111 | + | if (!webviewId) { | |||
| 112 | + | throw new Error("Missing window.__dumber_webview_id; cannot dispatch/receive callbacks"); | |||
| 113 | + | } | |||
| 114 | + | if (!config) { | |||
| 115 | + | throw new Error("Config not loaded"); | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | let timeout: number | null = window.setTimeout(() => { | |||
| 119 | + | if (saving) { | |||
| 120 | + | console.warn("[config] save timed out (no callback)"); | |||
| 121 | + | error = "Save timed out (no response from native handler)"; | |||
| 122 | + | saving = false; | |||
| 123 | + | } | |||
| 124 | + | }, 8000); | |||
| 125 | + | ||||
| 126 | + | const clearSaveTimeout = () => { | |||
| 127 | + | if (timeout != null) { | |||
| 128 | + | window.clearTimeout(timeout); | |||
| 129 | + | timeout = null; | |||
| 130 | + | } | |||
| 131 | + | }; | |||
| 132 | + | ||||
| 133 | + | (window as any).__dumber_config_saved = (resp?: unknown) => { | |||
| 134 | + | clearSaveTimeout(); | |||
| 135 | + | console.debug("[config] save success", resp); | |||
| 136 | + | saveSuccess = true; | |||
| 137 | + | saving = false; | |||
| 138 | + | ||||
| 139 | + | // Refresh config state from backend (in case watcher normalized values) | |||
| 140 | + | fetchConfig(); | |||
| 141 | + | ||||
| 142 | + | setTimeout(() => { | |||
| 143 | + | saveSuccess = false; | |||
| 144 | + | }, 3000); | |||
| 145 | + | }; | |||
| 146 | + | (window as any).__dumber_config_error = (msg: unknown) => { | |||
| 147 | + | clearSaveTimeout(); | |||
| 148 | + | console.error("[config] save error", msg); | |||
| 149 | + | error = typeof msg === "string" ? msg : "Failed to save config"; | |||
| 150 | + | saving = false; | |||
| 151 | + | }; | |||
| 152 | + | ||||
| 153 | + | const payload = $state.snapshot(config); | |||
| 154 | + | ||||
| 155 | + | try { | |||
| 156 | + | bridge.postMessage({ | |||
| 157 | + | type: "save_config", | |||
| 158 | + | webview_id: webviewId, | |||
| 159 | + | payload, | |||
| 160 | + | }); | |||
| 161 | + | console.debug("[config] postMessage sent", { payloadBytes: JSON.stringify(payload).length }); | |||
| 162 | + | } catch (postErr) { | |||
| 163 | + | console.error("[config] postMessage threw", postErr); | |||
| 164 | + | throw postErr; | |||
| 165 | + | } | |||
| 166 | + | } catch (e: any) { | |||
| 167 | + | console.error("[config] save exception", e); | |||
| 168 | + | error = e.message; | |||
| 169 | + | saving = false; | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | onMount(() => { | |||
| 174 | + | console.debug("[config] mount", { | |||
| 175 | + | webviewId: (window as any).__dumber_webview_id, | |||
| 176 | + | hasBridge: Boolean((window as any).webkit?.messageHandlers?.dumber), | |||
| 177 | + | }); | |||
| 178 | + | ||||
| 179 | + | const onThemeChanged = (e: Event) => { | |||
| 180 | + | themeEvents += 1; | |||
| 181 | + | console.debug("[config] theme changed", (e as CustomEvent).detail); | |||
| 182 | + | }; | |||
| 183 | + | window.addEventListener('dumber:theme-changed', onThemeChanged); | |||
| 184 | + | ||||
| 185 | + | fetchConfig(); | |||
| 186 | + | ||||
| 187 | + | return () => { | |||
| 188 | + | window.removeEventListener('dumber:theme-changed', onThemeChanged); | |||
| 189 | + | }; | |||
| 190 | + | }); | |||
| 191 | + | </script> | |||
| 192 | + | ||||
| 193 | + | <ConfigShell> | |||
| 194 | + | <div class="config-content"> | |||
| 195 | + | {#if loading} | |||
| 196 | + | <div class="loading">LOADING…</div> | |||
| 197 | + | {:else if error} | |||
| 198 | + | <div class="error"> | |||
| 199 | + | <strong>ERROR</strong> | |||
| 200 | + | <span>{error}</span> | |||
| 201 | + | </div> | |||
| 202 | + | {/if} | |||
| 203 | + | ||||
| 204 | + | {#if config} | |||
| 205 | + | <div class="stack"> | |||
| 206 | + | <!-- Appearance Section --> | |||
| 207 | + | <section class="border border-border bg-card p-6 text-card-foreground"> | |||
| 208 | + | <h2 class="mb-6 text-xl font-semibold">Appearance</h2> | |||
| 209 | + | ||||
| 210 | + | <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | |||
| 211 | + | <div class="space-y-2"> | |||
| 212 | + | <label for="sans_font" class="block text-sm font-medium text-muted-foreground">Sans-Serif Font</label> | |||
| 213 | + | <input | |||
| 214 | + | id="sans_font" | |||
| 215 | + | type="text" | |||
| 216 | + | bind:value={config.appearance.sans_font} | |||
| 217 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |||
| 218 | + | /> | |||
| 219 | + | </div> | |||
| 220 | + | ||||
| 221 | + | <div class="space-y-2"> | |||
| 222 | + | <label for="serif_font" class="block text-sm font-medium text-muted-foreground">Serif Font</label> | |||
| 223 | + | <input | |||
| 224 | + | id="serif_font" | |||
| 225 | + | type="text" | |||
| 226 | + | bind:value={config.appearance.serif_font} | |||
| 227 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |||
| 228 | + | /> | |||
| 229 | + | </div> | |||
| 230 | + | ||||
| 231 | + | <div class="space-y-2"> | |||
| 232 | + | <label for="monospace_font" class="block text-sm font-medium text-muted-foreground">Monospace Font</label> | |||
| 233 | + | <input | |||
| 234 | + | id="monospace_font" | |||
| 235 | + | type="text" | |||
| 236 | + | bind:value={config.appearance.monospace_font} | |||
| 237 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |||
| 238 | + | /> | |||
| 239 | + | </div> | |||
| 240 | + | ||||
| 241 | + | <div class="space-y-2"> | |||
| 242 | + | <label for="font_size" class="block text-sm font-medium text-muted-foreground">Default Font Size</label> | |||
| 243 | + | <input | |||
| 244 | + | id="font_size" | |||
| 245 | + | type="number" | |||
| 246 | + | bind:value={config.appearance.default_font_size} | |||
| 247 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |||
| 248 | + | /> | |||
| 249 | + | </div> | |||
| 250 | + | ||||
| 251 | + | <div class="space-y-2"> | |||
| 252 | + | <label for="color_scheme" class="block text-sm font-medium text-muted-foreground">Color Scheme</label> | |||
| 253 | + | <select | |||
| 254 | + | id="color_scheme" | |||
| 255 | + | bind:value={config.appearance.color_scheme} | |||
| 256 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |||
| 257 | + | > | |||
| 258 | + | <option value="default">Follow System</option> | |||
| 259 | + | <option value="prefer-dark">Always Dark</option> | |||
| 260 | + | <option value="prefer-light">Always Light</option> | |||
| 261 | + | </select> | |||
| 262 | + | </div> | |||
| 263 | + | ||||
| 264 | + | <div class="space-y-2"> | |||
| 265 | + | <label for="ui_scale" class="block text-sm font-medium text-muted-foreground">UI Scale</label> | |||
| 266 | + | <input | |||
| 267 | + | id="ui_scale" | |||
| 268 | + | type="number" | |||
| 269 | + | step="0.1" | |||
| 270 | + | bind:value={config.default_ui_scale} | |||
| 271 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |||
| 272 | + | /> | |||
| 273 | + | </div> | |||
| 274 | + | </div> | |||
| 275 | + | ||||
| 276 | + | <div class="mt-8 space-y-8"> | |||
| 277 | + | <div> | |||
| 278 | + | <h3 class="mb-3 text-base font-semibold">Theme: Light</h3> | |||
| 279 | + | <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |||
| 280 | + | {#each PALETTE_FIELDS as field (field.key)} | |||
| 281 | + | <div class="space-y-2"> | |||
| 282 | + | <label class="block text-sm font-medium text-muted-foreground" for={`light_${field.key}`} | |||
| 283 | + | >{field.label}</label | |||
| 284 | + | > | |||
| 285 | + | <div class="flex items-center gap-3"> | |||
| 286 | + | <input | |||
| 287 | + | id={`light_${field.key}`} | |||
| 288 | + | type="color" | |||
| 289 | + | value={config.appearance.light_palette[field.key]} | |||
| 290 | + | oninput={(e) => { | |||
| 291 | + | const v = (e.currentTarget as HTMLInputElement).value; | |||
| 292 | + | config!.appearance.light_palette[field.key] = v; | |||
| 293 | + | }} | |||
| 294 | + | class="h-10 w-12 border border-input bg-background" | |||
| 295 | + | /> | |||
| 296 | + | <input | |||
| 297 | + | type="text" | |||
| 298 | + | bind:value={config.appearance.light_palette[field.key]} | |||
| 299 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |||
| 300 | + | /> | |||
| 301 | + | </div> | |||
| 302 | + | {#if !isHexColor(config.appearance.light_palette[field.key])} | |||
| 303 | + | <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p> | |||
| 304 | + | {/if} | |||
| 305 | + | </div> | |||
| 306 | + | {/each} | |||
| 307 | + | </div> | |||
| 308 | + | </div> | |||
| 309 | + | ||||
| 310 | + | <div> | |||
| 311 | + | <h3 class="mb-3 text-base font-semibold">Theme: Dark</h3> | |||
| 312 | + | <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |||
| 313 | + | {#each PALETTE_FIELDS as field (field.key)} | |||
| 314 | + | <div class="space-y-2"> | |||
| 315 | + | <label class="block text-sm font-medium text-muted-foreground" for={`dark_${field.key}`} | |||
| 316 | + | >{field.label}</label | |||
| 317 | + | > | |||
| 318 | + | <div class="flex items-center gap-3"> | |||
| 319 | + | <input | |||
| 320 | + | id={`dark_${field.key}`} | |||
| 321 | + | type="color" | |||
| 322 | + | value={config.appearance.dark_palette[field.key]} | |||
| 323 | + | oninput={(e) => { | |||
| 324 | + | const v = (e.currentTarget as HTMLInputElement).value; | |||
| 325 | + | config!.appearance.dark_palette[field.key] = v; | |||
| 326 | + | }} | |||
| 327 | + | class="h-10 w-12 border border-input bg-background" | |||
| 328 | + | /> | |||
| 329 | + | <input | |||
| 330 | + | type="text" | |||
| 331 | + | bind:value={config.appearance.dark_palette[field.key]} | |||
| 332 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |||
| 333 | + | /> | |||
| 334 | + | </div> | |||
| 335 | + | {#if !isHexColor(config.appearance.dark_palette[field.key])} | |||
| 336 | + | <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p> | |||
| 337 | + | {/if} | |||
| 338 | + | </div> | |||
| 339 | + | {/each} | |||
| 340 | + | </div> | |||
| 341 | + | </div> | |||
| 342 | + | </div> | |||
| 343 | + | </section> | |||
| 344 | + | ||||
| 345 | + | <!-- Search Section --> | |||
| 346 | + | <section class="border border-border bg-card p-6 text-card-foreground"> | |||
| 347 | + | <h2 class="mb-6 text-xl font-semibold">Search</h2> | |||
| 348 | + | <div class="space-y-4"> | |||
| 349 | + | <div class="space-y-2"> | |||
| 350 | + | <label for="search_engine" class="block text-sm font-medium text-muted-foreground">Default Search Engine (use %s for query)</label> | |||
| 351 | + | <input | |||
| 352 | + | id="search_engine" | |||
| 353 | + | type="text" | |||
| 354 | + | bind:value={config.default_search_engine} | |||
| 355 | + | class="w-full border border-input bg-background px-3 py-2 text-foreground outline-none focus:border-ring" | |||
| 356 | + | /> | |||
| 357 | + | </div> | |||
| 358 | + | </div> | |||
| 359 | + | </section> | |||
| 360 | + | ||||
| 361 | + | <div class="actions"> | |||
| 362 | + | {#if saveSuccess} | |||
| 363 | + | <span class="saved">SAVED</span> | |||
| 364 | + | {/if} | |||
| 365 | + | {#if themeEvents > 0} | |||
| 366 | + | <span class="hint">THEME×{themeEvents}</span> | |||
| 367 | + | {/if} | |||
| 368 | + | <button onclick={resetToDefaults} disabled={saving} class="reset-button" type="button"> | |||
| 369 | + | RESET DEFAULTS | |||
| 370 | + | </button> | |||
| 371 | + | <button onclick={saveConfig} disabled={saving} class="save-button" type="button"> | |||
| 372 | + | {saving ? "SAVING…" : "SAVE"} | |||
| 373 | + | </button> | |||
| 374 | + | </div> | |||
| 375 | + | </div> | |||
| 376 | + | {/if} | |||
| 377 | + | </div> | |||
| 378 | + | </ConfigShell> | |||
| 379 | + | ||||
| 380 | + | <style> | |||
| 381 | + | .config-content { | |||
| 382 | + | width: 100%; | |||
| 383 | + | max-width: 980px; | |||
| 384 | + | margin: 0 auto; | |||
| 385 | + | padding: 1.5rem 1.25rem 2rem; | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | .stack { | |||
| 389 | + | display: flex; | |||
| 390 | + | flex-direction: column; | |||
| 391 | + | gap: 1.25rem; | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | .loading { | |||
| 395 | + | padding: 2rem 0; | |||
| 396 | + | color: var(--muted-foreground); | |||
| 397 | + | letter-spacing: 0.1em; | |||
| 398 | + | font-size: 0.8rem; | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | .error { | |||
| 402 | + | padding: 0.75rem 1rem; | |||
| 403 | + | border: 1px solid color-mix(in srgb, var(--border) 50%, #ef4444 50%); | |||
| 404 | + | background: color-mix(in srgb, var(--card) 70%, #ef4444 30%); | |||
| 405 | + | display: flex; | |||
| 406 | + | flex-direction: column; | |||
| 407 | + | gap: 0.25rem; | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | .actions { | |||
| 411 | + | display: flex; | |||
| 412 | + | align-items: center; | |||
| 413 | + | justify-content: flex-end; | |||
| 414 | + | gap: 0.75rem; | |||
| 415 | + | padding-top: 0.75rem; | |||
| 416 | + | border-top: 1px solid var(--border); | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | .saved { | |||
| 420 | + | font-size: 0.7rem; | |||
| 421 | + | letter-spacing: 0.12em; | |||
| 422 | + | color: var(--primary, #4ade80); | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | .hint { | |||
| 426 | + | font-size: 0.7rem; | |||
| 427 | + | letter-spacing: 0.12em; | |||
| 428 | + | color: var(--muted-foreground); | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | .reset-button { | |||
| 432 | + | display: inline-flex; | |||
| 433 | + | align-items: center; | |||
| 434 | + | justify-content: center; | |||
| 435 | + | padding: 0.55rem 1rem; | |||
| 436 | + | font-size: 0.7rem; | |||
| 437 | + | font-weight: 600; | |||
| 438 | + | letter-spacing: 0.12em; | |||
| 439 | + | color: var(--foreground); | |||
| 440 | + | background: transparent; | |||
| 441 | + | border: 1px solid var(--border); | |||
| 442 | + | cursor: pointer; | |||
| 443 | + | transition: all 120ms ease; | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | .reset-button:hover { | |||
| 447 | + | background: color-mix(in srgb, var(--card) 25%, transparent); | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | .reset-button:disabled { | |||
| 451 | + | opacity: 0.6; | |||
| 452 | + | cursor: not-allowed; | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | .save-button { | |||
| 456 | + | display: inline-flex; | |||
| 457 | + | align-items: center; | |||
| 458 | + | justify-content: center; | |||
| 459 | + | padding: 0.55rem 1rem; | |||
| 460 | + | font-size: 0.75rem; | |||
| 461 | + | font-weight: 600; | |||
| 462 | + | letter-spacing: 0.12em; | |||
| 463 | + | color: var(--background); | |||
| 464 | + | background: var(--primary, #4ade80); | |||
| 465 | + | border: 1px solid color-mix(in srgb, var(--primary, #4ade80) 70%, var(--border) 30%); | |||
| 466 | + | cursor: pointer; | |||
| 467 | + | transition: all 120ms ease; | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | .save-button:hover { | |||
| 471 | + | filter: brightness(1.02); | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | .save-button:disabled { | |||
| 475 | + | opacity: 0.6; | |||
| 476 | + | cursor: not-allowed; | |||
| 477 | + | } | |||
| 478 | + | </style> | |||
| 479 | + | ||||
A webui/src/pages/config.ts +10 -0
| @@ -0,0 +1,10 @@ | |||
| 1 | + | import { mount } from "svelte"; | |
| 2 | + | import ConfigPage from "./ConfigPage.svelte"; | |
| 3 | + | import "../lib/styles.css"; | |
| 4 | + | ||
| 5 | + | // Mount the config page | |
| 6 | + | const app = mount(ConfigPage, { | |
| 7 | + | target: document.body, | |
| 8 | + | }); | |
| 9 | + | ||
| 10 | + | export default app; | |
| @@ -0,0 +1,10 @@ | |||||
| 1 | + | import { mount } from "svelte"; | |||
| 2 | + | import ConfigPage from "./ConfigPage.svelte"; | |||
| 3 | + | import "../lib/styles.css"; | |||
| 4 | + | ||||
| 5 | + | // Mount the config page | |||
| 6 | + | const app = mount(ConfigPage, { | |||
| 7 | + | target: document.body, | |||
| 8 | + | }); | |||
| 9 | + | ||||
| 10 | + | export default app; | |||
A webui/src/pages/config/ConfigShell.svelte +228 -0
| @@ -0,0 +1,228 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { onMount } from 'svelte'; | |
| 3 | + | import { Home } from '@lucide/svelte'; | |
| 4 | + | ||
| 5 | + | interface Props { | |
| 6 | + | children?: import('svelte').Snippet; | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | let { children }: Props = $props(); | |
| 10 | + | ||
| 11 | + | function navigateWithViewTransition(url: string) { | |
| 12 | + | const doc = document as any; | |
| 13 | + | ||
| 14 | + | if (typeof doc?.startViewTransition !== 'function') { | |
| 15 | + | window.location.href = url; | |
| 16 | + | return; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | const transition = doc.startViewTransition(() => { | |
| 20 | + | document.documentElement.dataset.vt = 'leaving'; | |
| 21 | + | }); | |
| 22 | + | ||
| 23 | + | transition.finished.finally(() => { | |
| 24 | + | window.location.href = url; | |
| 25 | + | }); | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | onMount(() => { | |
| 29 | + | // Avoid default WebKit focus rings. | |
| 30 | + | document.documentElement.dataset.vt = 'ready'; | |
| 31 | + | }); | |
| 32 | + | </script> | |
| 33 | + | ||
| 34 | + | <svelte:head> | |
| 35 | + | <title>dumb://config</title> | |
| 36 | + | <meta name="description" content="Dumber Browser - Settings" /> | |
| 37 | + | {@html `<style> | |
| 38 | + | html, body { margin: 0; padding: 0; } | |
| 39 | + | html { background: var(--background, #0a0a0a); } | |
| 40 | + | body { background: var(--background, #0a0a0a); color: var(--foreground, #e5e5e5); } | |
| 41 | + | *:focus { outline: none !important; } | |
| 42 | + | *:focus-visible { outline: none !important; } | |
| 43 | + | button:focus, input:focus, a:focus { outline: none !important; box-shadow: none !important; } | |
| 44 | + | * { -webkit-overflow-scrolling: touch; } | |
| 45 | + | </style>`} | |
| 46 | + | </svelte:head> | |
| 47 | + | ||
| 48 | + | <div class="config-shell browser-ui"> | |
| 49 | + | <div class="terminal-frame"> | |
| 50 | + | <header class="terminal-header"> | |
| 51 | + | <div class="header-left"> | |
| 52 | + | <span class="terminal-path">dumb://config</span> | |
| 53 | + | </div> | |
| 54 | + | <div class="header-right"> | |
| 55 | + | <button | |
| 56 | + | class="action-button" | |
| 57 | + | type="button" | |
| 58 | + | onclick={() => navigateWithViewTransition('dumb://home')} | |
| 59 | + | title="Back to Home" | |
| 60 | + | > | |
| 61 | + | <Home size={14} strokeWidth={2} /> | |
| 62 | + | <span class="button-text">HOME</span> | |
| 63 | + | </button> | |
| 64 | + | </div> | |
| 65 | + | </header> | |
| 66 | + | ||
| 67 | + | <main class="terminal-body scrollable"> | |
| 68 | + | {#if children} | |
| 69 | + | {@render children()} | |
| 70 | + | {/if} | |
| 71 | + | </main> | |
| 72 | + | ||
| 73 | + | <footer class="terminal-footer"> | |
| 74 | + | <span class="footer-text">Dumber Browser</span> | |
| 75 | + | </footer> | |
| 76 | + | </div> | |
| 77 | + | </div> | |
| 78 | + | ||
| 79 | + | <style> | |
| 80 | + | :global(html), | |
| 81 | + | :global(body) { | |
| 82 | + | height: 100%; | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | :global(body) { | |
| 86 | + | overflow: hidden; | |
| 87 | + | overscroll-behavior: contain; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | .config-shell { | |
| 91 | + | height: 100vh; | |
| 92 | + | min-height: 100vh; | |
| 93 | + | display: flex; | |
| 94 | + | flex-direction: column; | |
| 95 | + | font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace; | |
| 96 | + | line-height: 1.5; | |
| 97 | + | color: var(--foreground); | |
| 98 | + | background: var(--background); | |
| 99 | + | overflow: hidden; | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | .terminal-frame { | |
| 103 | + | flex: 1; | |
| 104 | + | display: grid; | |
| 105 | + | grid-template-rows: auto 1fr auto; | |
| 106 | + | width: 100%; | |
| 107 | + | height: 100%; | |
| 108 | + | background: color-mix(in srgb, var(--card) 60%, var(--background) 40%); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | :global(.dark) .terminal-frame { | |
| 112 | + | box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.5); | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | .terminal-header { | |
| 116 | + | display: flex; | |
| 117 | + | align-items: center; | |
| 118 | + | justify-content: space-between; | |
| 119 | + | gap: 1rem; | |
| 120 | + | padding: 0.75rem 1rem; | |
| 121 | + | min-height: 3.5rem; | |
| 122 | + | border-bottom: 1px solid var(--border); | |
| 123 | + | background: color-mix(in srgb, var(--background) 90%, var(--card) 10%); | |
| 124 | + | position: sticky; | |
| 125 | + | top: 0; | |
| 126 | + | z-index: 5; | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | .header-left { | |
| 130 | + | display: flex; | |
| 131 | + | align-items: center; | |
| 132 | + | gap: 1.5rem; | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | .terminal-path { | |
| 136 | + | font-size: 0.8rem; | |
| 137 | + | font-weight: 600; | |
| 138 | + | color: var(--primary, #4ade80); | |
| 139 | + | letter-spacing: 0.05em; | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | .header-right { | |
| 143 | + | display: flex; | |
| 144 | + | align-items: center; | |
| 145 | + | gap: 0.5rem; | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | .action-button { | |
| 149 | + | display: flex; | |
| 150 | + | align-items: center; | |
| 151 | + | gap: 0.5rem; | |
| 152 | + | padding: 0.4rem 0.75rem; | |
| 153 | + | font-size: 0.7rem; | |
| 154 | + | font-weight: 500; | |
| 155 | + | letter-spacing: 0.08em; | |
| 156 | + | color: var(--muted-foreground); | |
| 157 | + | background: transparent; | |
| 158 | + | border-width: 1px; | |
| 159 | + | border-style: solid; | |
| 160 | + | border-color: var(--border); | |
| 161 | + | cursor: pointer; | |
| 162 | + | transition: all 120ms ease; | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | .action-button:hover, | |
| 166 | + | .action-button:focus-visible { | |
| 167 | + | color: var(--foreground); | |
| 168 | + | border-color: color-mix(in srgb, var(--border) 50%, var(--foreground) 50%); | |
| 169 | + | background: color-mix(in srgb, var(--card) 30%, transparent); | |
| 170 | + | outline: none; | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | .terminal-body { | |
| 174 | + | display: flex; | |
| 175 | + | flex-direction: column; | |
| 176 | + | min-height: 0; | |
| 177 | + | overflow: hidden; | |
| 178 | + | background: var(--background); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | .terminal-body.scrollable { | |
| 182 | + | overflow-y: auto; | |
| 183 | + | -webkit-overflow-scrolling: touch; | |
| 184 | + | touch-action: pan-y; | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | .terminal-footer { | |
| 188 | + | display: flex; | |
| 189 | + | align-items: center; | |
| 190 | + | justify-content: center; | |
| 191 | + | padding: 0.75rem 1rem; | |
| 192 | + | border-top: 1px solid var(--border); | |
| 193 | + | background: color-mix(in srgb, var(--background) 92%, var(--card) 8%); | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | .footer-text { | |
| 197 | + | font-size: 0.7rem; | |
| 198 | + | letter-spacing: 0.08em; | |
| 199 | + | color: var(--muted-foreground); | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /* View Transition helpers */ | |
| 203 | + | :global(html[data-vt='leaving']) { | |
| 204 | + | view-transition-name: config-root; | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | :global(::view-transition-old(config-root)) { | |
| 208 | + | animation: vt-fade-out 180ms ease both; | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | :global(::view-transition-new(config-root)) { | |
| 212 | + | animation: vt-fade-in 220ms ease both; | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | @keyframes vt-fade-out { | |
| 216 | + | to { | |
| 217 | + | opacity: 0; | |
| 218 | + | transform: translateY(6px); | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | @keyframes vt-fade-in { | |
| 223 | + | from { | |
| 224 | + | opacity: 0; | |
| 225 | + | transform: translateY(-6px); | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | </style> | |
| @@ -0,0 +1,228 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { onMount } from 'svelte'; | |||
| 3 | + | import { Home } from '@lucide/svelte'; | |||
| 4 | + | ||||
| 5 | + | interface Props { | |||
| 6 | + | children?: import('svelte').Snippet; | |||
| 7 | + | } | |||
| 8 | + | ||||
| 9 | + | let { children }: Props = $props(); | |||
| 10 | + | ||||
| 11 | + | function navigateWithViewTransition(url: string) { | |||
| 12 | + | const doc = document as any; | |||
| 13 | + | ||||
| 14 | + | if (typeof doc?.startViewTransition !== 'function') { | |||
| 15 | + | window.location.href = url; | |||
| 16 | + | return; | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | const transition = doc.startViewTransition(() => { | |||
| 20 | + | document.documentElement.dataset.vt = 'leaving'; | |||
| 21 | + | }); | |||
| 22 | + | ||||
| 23 | + | transition.finished.finally(() => { | |||
| 24 | + | window.location.href = url; | |||
| 25 | + | }); | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | onMount(() => { | |||
| 29 | + | // Avoid default WebKit focus rings. | |||
| 30 | + | document.documentElement.dataset.vt = 'ready'; | |||
| 31 | + | }); | |||
| 32 | + | </script> | |||
| 33 | + | ||||
| 34 | + | <svelte:head> | |||
| 35 | + | <title>dumb://config</title> | |||
| 36 | + | <meta name="description" content="Dumber Browser - Settings" /> | |||
| 37 | + | {@html `<style> | |||
| 38 | + | html, body { margin: 0; padding: 0; } | |||
| 39 | + | html { background: var(--background, #0a0a0a); } | |||
| 40 | + | body { background: var(--background, #0a0a0a); color: var(--foreground, #e5e5e5); } | |||
| 41 | + | *:focus { outline: none !important; } | |||
| 42 | + | *:focus-visible { outline: none !important; } | |||
| 43 | + | button:focus, input:focus, a:focus { outline: none !important; box-shadow: none !important; } | |||
| 44 | + | * { -webkit-overflow-scrolling: touch; } | |||
| 45 | + | </style>`} | |||
| 46 | + | </svelte:head> | |||
| 47 | + | ||||
| 48 | + | <div class="config-shell browser-ui"> | |||
| 49 | + | <div class="terminal-frame"> | |||
| 50 | + | <header class="terminal-header"> | |||
| 51 | + | <div class="header-left"> | |||
| 52 | + | <span class="terminal-path">dumb://config</span> | |||
| 53 | + | </div> | |||
| 54 | + | <div class="header-right"> | |||
| 55 | + | <button | |||
| 56 | + | class="action-button" | |||
| 57 | + | type="button" | |||
| 58 | + | onclick={() => navigateWithViewTransition('dumb://home')} | |||
| 59 | + | title="Back to Home" | |||
| 60 | + | > | |||
| 61 | + | <Home size={14} strokeWidth={2} /> | |||
| 62 | + | <span class="button-text">HOME</span> | |||
| 63 | + | </button> | |||
| 64 | + | </div> | |||
| 65 | + | </header> | |||
| 66 | + | ||||
| 67 | + | <main class="terminal-body scrollable"> | |||
| 68 | + | {#if children} | |||
| 69 | + | {@render children()} | |||
| 70 | + | {/if} | |||
| 71 | + | </main> | |||
| 72 | + | ||||
| 73 | + | <footer class="terminal-footer"> | |||
| 74 | + | <span class="footer-text">Dumber Browser</span> | |||
| 75 | + | </footer> | |||
| 76 | + | </div> | |||
| 77 | + | </div> | |||
| 78 | + | ||||
| 79 | + | <style> | |||
| 80 | + | :global(html), | |||
| 81 | + | :global(body) { | |||
| 82 | + | height: 100%; | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | :global(body) { | |||
| 86 | + | overflow: hidden; | |||
| 87 | + | overscroll-behavior: contain; | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | .config-shell { | |||
| 91 | + | height: 100vh; | |||
| 92 | + | min-height: 100vh; | |||
| 93 | + | display: flex; | |||
| 94 | + | flex-direction: column; | |||
| 95 | + | font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace; | |||
| 96 | + | line-height: 1.5; | |||
| 97 | + | color: var(--foreground); | |||
| 98 | + | background: var(--background); | |||
| 99 | + | overflow: hidden; | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | .terminal-frame { | |||
| 103 | + | flex: 1; | |||
| 104 | + | display: grid; | |||
| 105 | + | grid-template-rows: auto 1fr auto; | |||
| 106 | + | width: 100%; | |||
| 107 | + | height: 100%; | |||
| 108 | + | background: color-mix(in srgb, var(--card) 60%, var(--background) 40%); | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | :global(.dark) .terminal-frame { | |||
| 112 | + | box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.5); | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | .terminal-header { | |||
| 116 | + | display: flex; | |||
| 117 | + | align-items: center; | |||
| 118 | + | justify-content: space-between; | |||
| 119 | + | gap: 1rem; | |||
| 120 | + | padding: 0.75rem 1rem; | |||
| 121 | + | min-height: 3.5rem; | |||
| 122 | + | border-bottom: 1px solid var(--border); | |||
| 123 | + | background: color-mix(in srgb, var(--background) 90%, var(--card) 10%); | |||
| 124 | + | position: sticky; | |||
| 125 | + | top: 0; | |||
| 126 | + | z-index: 5; | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | .header-left { | |||
| 130 | + | display: flex; | |||
| 131 | + | align-items: center; | |||
| 132 | + | gap: 1.5rem; | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | .terminal-path { | |||
| 136 | + | font-size: 0.8rem; | |||
| 137 | + | font-weight: 600; | |||
| 138 | + | color: var(--primary, #4ade80); | |||
| 139 | + | letter-spacing: 0.05em; | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | .header-right { | |||
| 143 | + | display: flex; | |||
| 144 | + | align-items: center; | |||
| 145 | + | gap: 0.5rem; | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | .action-button { | |||
| 149 | + | display: flex; | |||
| 150 | + | align-items: center; | |||
| 151 | + | gap: 0.5rem; | |||
| 152 | + | padding: 0.4rem 0.75rem; | |||
| 153 | + | font-size: 0.7rem; | |||
| 154 | + | font-weight: 500; | |||
| 155 | + | letter-spacing: 0.08em; | |||
| 156 | + | color: var(--muted-foreground); | |||
| 157 | + | background: transparent; | |||
| 158 | + | border-width: 1px; | |||
| 159 | + | border-style: solid; | |||
| 160 | + | border-color: var(--border); | |||
| 161 | + | cursor: pointer; | |||
| 162 | + | transition: all 120ms ease; | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | .action-button:hover, | |||
| 166 | + | .action-button:focus-visible { | |||
| 167 | + | color: var(--foreground); | |||
| 168 | + | border-color: color-mix(in srgb, var(--border) 50%, var(--foreground) 50%); | |||
| 169 | + | background: color-mix(in srgb, var(--card) 30%, transparent); | |||
| 170 | + | outline: none; | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | .terminal-body { | |||
| 174 | + | display: flex; | |||
| 175 | + | flex-direction: column; | |||
| 176 | + | min-height: 0; | |||
| 177 | + | overflow: hidden; | |||
| 178 | + | background: var(--background); | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | .terminal-body.scrollable { | |||
| 182 | + | overflow-y: auto; | |||
| 183 | + | -webkit-overflow-scrolling: touch; | |||
| 184 | + | touch-action: pan-y; | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | .terminal-footer { | |||
| 188 | + | display: flex; | |||
| 189 | + | align-items: center; | |||
| 190 | + | justify-content: center; | |||
| 191 | + | padding: 0.75rem 1rem; | |||
| 192 | + | border-top: 1px solid var(--border); | |||
| 193 | + | background: color-mix(in srgb, var(--background) 92%, var(--card) 8%); | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | .footer-text { | |||
| 197 | + | font-size: 0.7rem; | |||
| 198 | + | letter-spacing: 0.08em; | |||
| 199 | + | color: var(--muted-foreground); | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | /* View Transition helpers */ | |||
| 203 | + | :global(html[data-vt='leaving']) { | |||
| 204 | + | view-transition-name: config-root; | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | :global(::view-transition-old(config-root)) { | |||
| 208 | + | animation: vt-fade-out 180ms ease both; | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | :global(::view-transition-new(config-root)) { | |||
| 212 | + | animation: vt-fade-in 220ms ease both; | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | @keyframes vt-fade-out { | |||
| 216 | + | to { | |||
| 217 | + | opacity: 0; | |||
| 218 | + | transform: translateY(6px); | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | @keyframes vt-fade-in { | |||
| 223 | + | from { | |||
| 224 | + | opacity: 0; | |||
| 225 | + | transform: translateY(-6px); | |||
| 226 | + | } | |||
| 227 | + | } | |||
| 228 | + | </style> | |||
M webui/src/pages/homepage/shell/HomepageShell.svelte +27 -1
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | import CommandPalette from './CommandPalette.svelte'; | |
| 7 | 7 | import StatusBar from './StatusBar.svelte'; | |
| 8 | 8 | import KeyboardHints from './KeyboardHints.svelte'; | |
| 9 | - | import { History, Star, BarChart3, Search, Moon, Sun } from '@lucide/svelte'; | |
| 9 | + | import { History, Star, BarChart3, Search, Moon, Sun, Settings } from '@lucide/svelte'; | |
| 10 | 10 | ||
| 11 | 11 | // Props for slot content | |
| 12 | 12 | interface Props { | |
| @@ -34,6 +34,23 @@ | |||
| 34 | 34 | themeMode = document.documentElement.classList.contains('dark') ? 'dark' : 'light'; | |
| 35 | 35 | }; | |
| 36 | 36 | ||
| 37 | + | function navigateWithViewTransition(url: string) { | |
| 38 | + | const doc = document as any; | |
| 39 | + | ||
| 40 | + | if (typeof doc?.startViewTransition !== 'function') { | |
| 41 | + | window.location.href = url; | |
| 42 | + | return; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | const transition = doc.startViewTransition(() => { | |
| 46 | + | document.documentElement.dataset.vt = 'leaving'; | |
| 47 | + | }); | |
| 48 | + | ||
| 49 | + | transition.finished.finally(() => { | |
| 50 | + | window.location.href = url; | |
| 51 | + | }); | |
| 52 | + | } | |
| 53 | + | ||
| 37 | 54 | const toggleTheme = () => { | |
| 38 | 55 | const nextMode = themeMode === 'dark' ? 'light' : 'dark'; | |
| 39 | 56 | const manager = (window as any).__dumber_color_scheme_manager as | |
| @@ -173,6 +190,15 @@ | |||
| 173 | 190 | <span class="button-text">CMD</span> | |
| 174 | 191 | <kbd class="kbd-hint">/</kbd> | |
| 175 | 192 | </button> | |
| 193 | + | <button | |
| 194 | + | class="action-button" | |
| 195 | + | type="button" | |
| 196 | + | onclick={() => navigateWithViewTransition('dumb://config')} | |
| 197 | + | title="Settings" | |
| 198 | + | > | |
| 199 | + | <Settings size={14} strokeWidth={2} /> | |
| 200 | + | <span class="button-text">CFG</span> | |
| 201 | + | </button> | |
| 176 | 202 | <button | |
| 177 | 203 | class="theme-toggle" | |
| 178 | 204 | type="button" | |
| @@ -6,7 +6,7 @@ | |||||
| 6 | import CommandPalette from './CommandPalette.svelte'; | 6 | import CommandPalette from './CommandPalette.svelte'; | ||
| 7 | import StatusBar from './StatusBar.svelte'; | 7 | import StatusBar from './StatusBar.svelte'; | ||
| 8 | import KeyboardHints from './KeyboardHints.svelte'; | 8 | import KeyboardHints from './KeyboardHints.svelte'; | ||
| 9 | - | import { History, Star, BarChart3, Search, Moon, Sun } from '@lucide/svelte'; | 9 | + | import { History, Star, BarChart3, Search, Moon, Sun, Settings } from '@lucide/svelte'; |
| 10 | 10 | ||||
| 11 | // Props for slot content | 11 | // Props for slot content | ||
| 12 | interface Props { | 12 | interface Props { | ||
| @@ -34,6 +34,23 @@ | |||||
| 34 | themeMode = document.documentElement.classList.contains('dark') ? 'dark' : 'light'; | 34 | themeMode = document.documentElement.classList.contains('dark') ? 'dark' : 'light'; | ||
| 35 | }; | 35 | }; | ||
| 36 | 36 | ||||
| 37 | + | function navigateWithViewTransition(url: string) { | |||
| 38 | + | const doc = document as any; | |||
| 39 | + | ||||
| 40 | + | if (typeof doc?.startViewTransition !== 'function') { | |||
| 41 | + | window.location.href = url; | |||
| 42 | + | return; | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | const transition = doc.startViewTransition(() => { | |||
| 46 | + | document.documentElement.dataset.vt = 'leaving'; | |||
| 47 | + | }); | |||
| 48 | + | ||||
| 49 | + | transition.finished.finally(() => { | |||
| 50 | + | window.location.href = url; | |||
| 51 | + | }); | |||
| 52 | + | } | |||
| 53 | + | ||||
| 37 | const toggleTheme = () => { | 54 | const toggleTheme = () => { | ||
| 38 | const nextMode = themeMode === 'dark' ? 'light' : 'dark'; | 55 | const nextMode = themeMode === 'dark' ? 'light' : 'dark'; | ||
| 39 | const manager = (window as any).__dumber_color_scheme_manager as | 56 | const manager = (window as any).__dumber_color_scheme_manager as | ||
| @@ -173,6 +190,15 @@ | |||||
| 173 | <span class="button-text">CMD</span> | 190 | <span class="button-text">CMD</span> | ||
| 174 | <kbd class="kbd-hint">/</kbd> | 191 | <kbd class="kbd-hint">/</kbd> | ||
| 175 | </button> | 192 | </button> | ||
| 193 | + | <button | |||
| 194 | + | class="action-button" | |||
| 195 | + | type="button" | |||
| 196 | + | onclick={() => navigateWithViewTransition('dumb://config')} | |||
| 197 | + | title="Settings" | |||
| 198 | + | > | |||
| 199 | + | <Settings size={14} strokeWidth={2} /> | |||
| 200 | + | <span class="button-text">CFG</span> | |||
| 201 | + | </button> | |||
| 176 | <button | 202 | <button | ||
| 177 | class="theme-toggle" | 203 | class="theme-toggle" | ||
| 178 | type="button" | 204 | type="button" | ||
M webui/vite.config.ts +12 -0
| @@ -29,6 +29,18 @@ const pages = { | |||
| 29 | 29 | filename: "blocked.html", | |
| 30 | 30 | }, | |
| 31 | 31 | }, | |
| 32 | + | config: { | |
| 33 | + | entry: "src/pages/config.ts", | |
| 34 | + | output: "config.min.js", | |
| 35 | + | global: "DumberConfig", | |
| 36 | + | html: { | |
| 37 | + | name: "config", | |
| 38 | + | title: "Settings", | |
| 39 | + | script: "config.min.js", | |
| 40 | + | css: "style.css", | |
| 41 | + | filename: "config.html", | |
| 42 | + | }, | |
| 43 | + | }, | |
| 32 | 44 | } as const; | |
| 33 | 45 | ||
| 34 | 46 | // Get page from VITE_PAGE env var, default to building all | |
| @@ -29,6 +29,18 @@ const pages = { | |||||
| 29 | filename: "blocked.html", | 29 | filename: "blocked.html", | ||
| 30 | }, | 30 | }, | ||
| 31 | }, | 31 | }, | ||
| 32 | + | config: { | |||
| 33 | + | entry: "src/pages/config.ts", | |||
| 34 | + | output: "config.min.js", | |||
| 35 | + | global: "DumberConfig", | |||
| 36 | + | html: { | |||
| 37 | + | name: "config", | |||
| 38 | + | title: "Settings", | |||
| 39 | + | script: "config.min.js", | |||
| 40 | + | css: "style.css", | |||
| 41 | + | filename: "config.html", | |||
| 42 | + | }, | |||
| 43 | + | }, | |||
| 32 | } as const; | 44 | } as const; | ||
| 33 | 45 | ||||
| 34 | // Get page from VITE_PAGE env var, default to building all | 46 | // Get page from VITE_PAGE env var, default to building all | ||