detached a021447

build(ci): remove webui asset pipeline

commit
a021447f96063e20b2cdc1785814e219a0c0c996
author
brice · · 2026-04-11 19:18 UTC
committer
brice · · 2026-04-12 04:52 UTC
parent
5d04e80
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
129 files · +93 -16767
M .github/dependabot.yml +0 -9
@@ -9,15 +9,6 @@ updates:
99 patterns:
1010 - "*"
1111
12- - package-ecosystem: npm
13- directory: /webui
14- schedule:
15- interval: weekly
16- groups:
17- npm-dependencies:
18- patterns:
19- - "*"
20-
2112 - package-ecosystem: github-actions
2213 directory: /
2314 schedule:
M .github/workflows/aur.yml +1 -1
@@ -51,7 +51,7 @@ jobs:
5151 "arch=('x86_64')" \
5252 'url="https://github.com/bnema/dumber"' \
5353 "license=('MIT')" \
54- "makedepends=('go>=1.25' 'npm' 'git')" \
54+ "makedepends=('go>=1.25' 'git')" \
5555 "depends=('gtk4' 'webkitgtk-6.0')" \
5656 'optdepends=(' \
5757 " 'gst-plugins-base: Base media codecs'" \
M .github/workflows/ci.yml +6 -22
@@ -7,7 +7,6 @@ on:
77 - '**.go'
88 - 'go.mod'
99 - 'go.sum'
10- - 'webui/**'
1110 - '.golangci.yml'
1211 - 'Makefile'
1312 - 'sqlc.yaml'
@@ -21,7 +20,6 @@ on:
2120 - '**.go'
2221 - 'go.mod'
2322 - 'go.sum'
24- - 'webui/**'
2523 - '.golangci.yml'
2624 - 'Makefile'
2725 - 'sqlc.yaml'
@@ -40,7 +38,7 @@ jobs:
4038 - name: Install dependencies
4139 run: |
4240 pacman -Syu --noconfirm
43- pacman -S --noconfirm git go nodejs npm webkitgtk-6.0 gtk4 gobject-introspection base-devel
41+ pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel
4442 - uses: actions/checkout@v6
4543 - name: Cache Go modules
4644 uses: actions/cache@v5
@@ -56,15 +54,8 @@ jobs:
5654 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
5755 restore-keys: |
5856 ${{ runner.os }}-go-build-
59- - name: Cache npm
60- uses: actions/cache@v5
61- with:
62- path: ~/.npm
63- key: ${{ runner.os }}-npm-${{ hashFiles('webui/package-lock.json') }}
64- restore-keys: |
65- ${{ runner.os }}-npm-
66- - name: Build frontend assets
67- run: make build-frontend
57+ - name: Build systemviews assets
58+ run: make build-systemviews
6859 - name: Install golangci-lint
6960 run: |
7061 curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin v2.9.0
@@ -81,7 +72,7 @@ jobs:
8172 - name: Install dependencies
8273 run: |
8374 pacman -Syu --noconfirm
84- pacman -S --noconfirm git go nodejs npm webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel
75+ pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel
8576 - uses: actions/checkout@v6
8677 - name: Cache Go modules
8778 uses: actions/cache@v5
@@ -97,15 +88,8 @@ jobs:
9788 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
9889 restore-keys: |
9990 ${{ runner.os }}-go-build-
100- - name: Cache npm
101- uses: actions/cache@v5
102- with:
103- path: ~/.npm
104- key: ${{ runner.os }}-npm-${{ hashFiles('webui/package-lock.json') }}
105- restore-keys: |
106- ${{ runner.os }}-npm-
107- - name: Build frontend assets
108- run: make build-frontend
91+ - name: Build systemviews assets
92+ run: make build-systemviews
10993 - name: Run tests
11094 env:
11195 GDK_BACKEND: wayland
D .github/workflows/webui.yml +0 -30
@@ -1,30 +0,0 @@
1-name: WebUI CI
2-
3-on:
4- push:
5- branches: [dev, main]
6- paths:
7- - 'webui/**'
8- pull_request:
9- paths:
10- - 'webui/**'
11-
12-jobs:
13- check:
14- runs-on: ubuntu-latest
15- steps:
16- - uses: actions/checkout@v6
17-
18- - uses: actions/setup-node@v6
19- with:
20- node-version: '24'
21- cache: 'npm'
22- cache-dependency-path: 'webui/package-lock.json'
23-
24- - name: Install dependencies
25- run: npm ci
26- working-directory: webui
27-
28- - name: Run checks
29- run: npm run check
30- working-directory: webui
M .goreleaser.yaml +2 -2
@@ -4,8 +4,8 @@ version: 2
44
55 before:
66 hooks:
7- # Build frontend assets before building binaries
8- - make build-frontend
7+ # Build systemviews assets before building binaries
8+ - make build-systemviews
99
1010 builds:
1111 # Pure Go build (no CGO required)
M CONTRIBUTING.md +2 -4
@@ -34,10 +34,8 @@ git push origin my-fix
3434 2. Run `make test` and ensure tests pass
3535 3. Run `go fmt` on any Go code changes
3636
37-**WebUI code (in `webui/`):**
38-1. Run `npm run lint` to fix linting issues
39-2. Run `npm run fmt` to format with Prettier
40-3. Run `npm run check` to run svelte-check
37+**Systemviews assets:**
38+1. Run `make build-systemviews` to regenerate embedded WASM assets
4139
4240 Keep commits focused and use single-line commit messages.
4341
M Makefile +5 -12
@@ -1,6 +1,6 @@
11 # Makefile for dumber (Clean Architecture - puregotk)
22
3-.PHONY: build build-frontend build-systemviews test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check
3+.PHONY: build build-systemviews test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check
44
55 # Load local overrides from .env.local if present (Makefile syntax)
66 ifneq (,$(wildcard .env.local))
@@ -38,18 +38,13 @@ help: ## Show this help message
3838 @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
3939
4040 # Build targets
41-build: build-frontend build-systemviews ## Build the application (pure Go, no CGO)
41+build: build-systemviews ## Build the application (pure Go, no CGO)
4242 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
4343 @mkdir -p $(DIST_DIR)
4444 CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
4545 CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper
4646 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
4747
48-build-frontend: ## Build homepage and error pages
49- @echo "Building webui pages (homepage + error)..."
50- @cd webui && npm install --silent && npm run build
51- @echo "Frontend build complete"
52-
5348 build-systemviews: ## Build the WASM systemviews runtime
5449 @echo "Building systemviews wasm assets..."
5550 @mkdir -p assets/systemviews
@@ -57,8 +52,8 @@ build-systemviews: ## Build the WASM systemviews runtime
5752 GOOS=js GOARCH=wasm go build -o assets/systemviews/systemviews.wasm ./cmd/systemviews
5853 @echo "Systemviews build complete"
5954
60-build-quick: ## Build without frontend (faster for backend development)
61- @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."
55+build-quick: ## Build quickly for backend development
56+ @echo "Building $(BINARY_NAME) $(VERSION) (quick)..."
6257 @mkdir -p $(DIST_DIR)
6358 CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
6459 CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper
@@ -130,8 +125,6 @@ clean: ## Clean build artifacts
130125 @echo "Cleaning build artifacts..."
131126 rm -rf $(DIST_DIR)
132127 rm -f $(BINARY_NAME)
133- rm -rf webui/dist webui/node_modules
134- rm -f assets/webui/homepage.min.js assets/webui/error.min.js assets/webui/config.min.js assets/webui/index.html assets/webui/error.html assets/webui/config.html assets/webui/style.css
135128 rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm
136129 rm -f coverage.out coverage.html
137130 go clean -cache
@@ -168,7 +161,7 @@ man: build-quick ## Install man pages to ~/.local/share/man/man1/
168161 # Native release targets
169162 .PHONY: release-snapshot release
170163
171-release-snapshot: build-frontend ## Build snapshot using goreleaser
164+release-snapshot: build-systemviews ## Build snapshot using goreleaser
172165 @echo "Building snapshot with goreleaser..."
173166 goreleaser release --snapshot --clean
174167
M assets/webui_embed.go +4 -4
@@ -4,11 +4,11 @@ import (
44 "embed"
55 )
66
7-// WebUIAssets contains the embedded webui and systemviews build output.
8-// Used by the scheme handlers during the transition to serve dumb://home,
9-// dumb://error, dumb://config, dumb://webrtc, dumb://history, and dumb://favorites.
7+// WebUIAssets contains the embedded systemviews build output.
8+// Used by the scheme handlers to serve dumb://error, dumb://config,
9+// dumb://history, and dumb://favorites.
1010 //
11-//go:embed webui/* systemviews/*
11+//go:embed systemviews/*
1212 var WebUIAssets embed.FS
1313
1414 // LogoSVG contains the dumber logo as SVG for desktop icon installation.
M aur/dumber-browser-git/.SRCINFO +0 -1
@@ -6,7 +6,6 @@ pkgbase = dumber-browser-git
66 arch = x86_64
77 license = MIT
88 makedepends = go>=1.25
9- makedepends = npm
109 makedepends = git
1110 depends = gtk4
1211 depends = webkitgtk-6.0
M aur/dumber-browser-git/PKGBUILD +1 -1
@@ -6,7 +6,7 @@ pkgdesc="A minimal keyboard-driven browser for tiling WMs (git version)"
66 arch=('x86_64')
77 url="https://github.com/bnema/dumber"
88 license=('MIT')
9-makedepends=('go>=1.25' 'npm' 'git')
9+makedepends=('go>=1.25' 'git')
1010 depends=('gtk4' 'webkitgtk-6.0')
1111 optdepends=(
1212 'gst-plugins-base: Base media codecs'
M docs/config/index.md +1 -1
@@ -201,7 +201,7 @@ border = "#363636"
201201 **Example:**
202202 ```toml
203203 [workspace]
204-new_pane_url = "dumb://home"
204+new_pane_url = "dumb://history"
205205 ```
206206
207207 ### Pane Mode
M internal/domain/url/normalize_test.go +4 -4
@@ -34,8 +34,8 @@ func TestNormalize(t *testing.T) {
3434 },
3535 {
3636 name: "dumb scheme unchanged",
37- input: "dumb://home",
38- want: "dumb://home",
37+ input: "dumb://history",
38+ want: "dumb://history",
3939 },
4040 {
4141 name: "about scheme unchanged",
@@ -347,7 +347,7 @@ func TestLooksLikeURL(t *testing.T) {
347347 },
348348 {
349349 name: "dumb URL",
350- input: "dumb://home",
350+ input: "dumb://history",
351351 want: true,
352352 },
353353 {
@@ -506,7 +506,7 @@ func TestIsExternalScheme(t *testing.T) {
506506 },
507507 {
508508 name: "dumb scheme",
509- input: "dumb://home",
509+ input: "dumb://history",
510510 want: false,
511511 },
512512 {
M internal/infrastructure/cef/internal_urls.go +4 -4
@@ -39,7 +39,7 @@ func toActualInternalURL(raw string) string {
3939 if page == "" {
4040 page = parsed.Opaque
4141 }
42- if page == "" {
42+ if page == "" || !isInternalPageHost(page) {
4343 return raw
4444 }
4545
@@ -47,8 +47,8 @@ func toActualInternalURL(raw string) string {
4747 trimmedPath := strings.Trim(parsed.Path, "/")
4848 switch {
4949 case trimmedPath == "":
50- // Keep page roots at /home instead of /home/ so relative assets resolve
51- // from the origin root.
50+ // Keep page roots at /history instead of /history/ so relative assets
51+ // resolve from the origin root.
5252 case strings.HasPrefix(trimmedPath, "api/"):
5353 actualPath = "/" + trimmedPath
5454 case path.Ext(trimmedPath) != "":
@@ -102,7 +102,7 @@ func toConceptualInternalURL(raw string) string {
102102
103103 func isInternalPageHost(host string) bool {
104104 switch host {
105- case homePath, historyPath, favoritesPath, configPath, webrtcPath, errorPath:
105+ case historyPath, favoritesPath, configPath, errorPath:
106106 return true
107107 default:
108108 return false
M internal/infrastructure/cef/internal_urls_test.go +20 -10
@@ -12,14 +12,19 @@ func TestToActualInternalURL(t *testing.T) {
1212 }{
1313 {
1414 name: "page root",
15- in: "dumb://home",
16- want: "https://dumber.invalid/home",
15+ in: "dumb://history",
16+ want: "https://dumber.invalid/history",
1717 },
1818 {
1919 name: "history page root",
2020 in: "dumb://history",
2121 want: "https://dumber.invalid/history",
2222 },
23+ {
24+ name: "error page root",
25+ in: "dumb://error",
26+ want: "https://dumber.invalid/error",
27+ },
2328 {
2429 name: "favorites page root",
2530 in: "dumb://favorites",
@@ -32,18 +37,18 @@ func TestToActualInternalURL(t *testing.T) {
3237 },
3338 {
3439 name: "api path stays at origin root",
35- in: "dumb://home/api/message",
40+ in: "dumb://history/api/message",
3641 want: "https://dumber.invalid/api/message",
3742 },
3843 {
3944 name: "root asset stays at origin root",
40- in: "dumb://home/favicon.ico",
45+ in: "dumb://history/favicon.ico",
4146 want: "https://dumber.invalid/favicon.ico",
4247 },
4348 {
4449 name: "page subroute stays under page namespace",
45- in: "dumb://home/crash?url=https%3A%2F%2Fexample.com",
46- want: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com",
50+ in: "dumb://history/crash?url=https%3A%2F%2Fexample.com",
51+ want: "https://dumber.invalid/history/crash?url=https%3A%2F%2Fexample.com",
4752 },
4853 {
4954 name: "actual internal URL unchanged",
@@ -72,14 +77,19 @@ func TestToConceptualInternalURL(t *testing.T) {
7277 }{
7378 {
7479 name: "page root",
75- in: "https://dumber.invalid/home",
76- want: "dumb://home",
80+ in: "https://dumber.invalid/history",
81+ want: "dumb://history",
7782 },
7883 {
7984 name: "history page root",
8085 in: "https://dumber.invalid/history",
8186 want: "dumb://history",
8287 },
88+ {
89+ name: "error page root",
90+ in: "https://dumber.invalid/error",
91+ want: "dumb://error",
92+ },
8393 {
8494 name: "favorites page root",
8595 in: "https://dumber.invalid/favorites",
@@ -92,8 +102,8 @@ func TestToConceptualInternalURL(t *testing.T) {
92102 },
93103 {
94104 name: "page subroute",
95- in: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com",
96- want: "dumb://home/crash?url=https%3A%2F%2Fexample.com",
105+ in: "https://dumber.invalid/history/crash?url=https%3A%2F%2Fexample.com",
106+ want: "dumb://history/crash?url=https%3A%2F%2Fexample.com",
97107 },
98108 {
99109 name: "root asset is left unchanged",
M internal/infrastructure/cef/scheme_handler.go +10 -15
@@ -26,11 +26,9 @@ import (
2626
2727 // Scheme path constants matching WebKit's naming.
2828 const (
29- homePath = "home"
3029 historyPath = "history"
3130 favoritesPath = "favorites"
3231 configPath = "config"
33- webrtcPath = "webrtc"
3432 errorPath = "error"
3533 indexHTML = "index.html"
3634 maxSchemeTruncatedURLLength = 240
@@ -38,12 +36,10 @@ const (
3836
3937 // pageRootFiles maps internal page hosts/paths to their HTML entry points.
4038 var pageRootFiles = map[string]string{
41- homePath: indexHTML,
4239 historyPath: indexHTML,
4340 favoritesPath: indexHTML,
4441 configPath: indexHTML,
45- webrtcPath: "webrtc.html",
46- errorPath: "error.html",
42+ errorPath: indexHTML,
4743 }
4844
4945 // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual
@@ -85,14 +81,14 @@ func newDumbSchemeHandler(
8581 ctx: ctx,
8682 messageRouter: router,
8783 transcoder: transcoder,
88- assetDir: "webui",
84+ assetDir: "systemviews",
8985 logger: log.With().Str("component", "scheme-handler").Logger(),
9086 currentConfigPayload: currentConfigPayload,
9187 defaultConfigPayload: defaultConfigPayload,
9288 }, nil
9389 }
9490
95-// setAssets sets the embedded filesystem containing webui assets.
91+// setAssets sets the embedded filesystem containing systemviews assets.
9692 func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
9793 h.mu.Lock()
9894 defer h.mu.Unlock()
@@ -325,7 +321,7 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
325321 }
326322
327323 // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL
328-// to a relative asset path inside assets/webui.
324+// to a relative asset path inside assets/systemviews.
329325 func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
330326 if u == nil {
331327 return "", "", false
@@ -378,7 +374,7 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
378374 if len(parts) == 1 {
379375 return assetDirForPageHost(page), root, true
380376 }
381- if page == homePath && parts[1] == "crash" {
377+ if page == historyPath && parts[1] == "crash" {
382378 return "", "", false
383379 }
384380 return assetDirForPageHost(page), parts[1], true
@@ -388,16 +384,15 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
388384 return "", "", false
389385 }
390386
391- // Serve root assets like homepage.min.js, style.css and favicon.png.
392- return "webui", path, true
387+ return "", "", false
393388 }
394389
395390 func assetDirForPageHost(host string) string {
396391 switch host {
397- case historyPath, favoritesPath, configPath:
392+ case historyPath, favoritesPath, configPath, errorPath:
398393 return "systemviews"
399394 default:
400- return "webui"
395+ return ""
401396 }
402397 }
403398
@@ -408,8 +403,8 @@ func isCEFCrashPageURL(u *url.URL) bool {
408403
409404 switch {
410405 case strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost):
411- return strings.Trim(u.Path, "/") == homePath+"/crash"
412- case strings.EqualFold(u.Scheme, "dumb") && u.Host == homePath:
406+ return strings.Trim(u.Path, "/") == historyPath+"/crash"
407+ case strings.EqualFold(u.Scheme, "dumb") && u.Host == historyPath:
413408 return strings.Trim(u.Path, "/") == "crash"
414409 default:
415410 return false
M internal/infrastructure/handlers/config.go +1 -1
@@ -39,7 +39,7 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload js
3939 return nil, fmt.Errorf("invalid config format: %w", err)
4040 }
4141
42- log.Info().Msg("saving appearance configuration from webui")
42+ log.Info().Msg("saving appearance configuration")
4343
4444 if err := h.saveConfig(ctx, payloadCfg); err != nil {
4545 log.Error().Err(err).Msg("failed to save config")
M internal/infrastructure/webkit/scheme_handler.go +15 -26
@@ -21,16 +21,13 @@ import (
2121
2222 // Scheme path constants
2323 const (
24- HomePath = "home"
25- HistoryPath = "history"
26- FavoritesPath = "favorites"
27- ConfigPath = "config"
28- WebRTCPath = "webrtc"
29- ErrorPath = "error"
30- CrashPath = "crash"
31- IndexHTML = "index.html"
32- SystemViewsIndexHTML = "systemviews/index.html"
33- httpGET = "GET"
24+ HistoryPath = "history"
25+ FavoritesPath = "favorites"
26+ ConfigPath = "config"
27+ ErrorPath = "error"
28+ CrashPath = "crash"
29+ IndexHTML = "index.html"
30+ httpGET = "GET"
3431 )
3532
3633 // SchemeRequest represents a request to a custom URI scheme.
@@ -65,7 +62,7 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
6562 type DumbSchemeHandler struct {
6663 handlers map[string]PageHandler
6764 assets embed.FS
68- assetDir string // default subdirectory within embed.FS (e.g., "webui")
65+ assetDir string // default subdirectory within embed.FS (e.g., "systemviews")
6966 logger zerolog.Logger
7067 mu sync.RWMutex
7168 currentConfigPayload func() ([]byte, error)
@@ -78,7 +75,7 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
7875
7976 h := &DumbSchemeHandler{
8077 handlers: make(map[string]PageHandler),
81- assetDir: "webui",
78+ assetDir: "systemviews",
8279 logger: log.With().Str("component", "scheme-handler").Logger(),
8380 }
8481
@@ -88,7 +85,7 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
8885 return h
8986 }
9087
91-// SetAssets sets the embedded filesystem containing webui assets.
88+// SetAssets sets the embedded filesystem containing systemviews assets.
9289 func (h *DumbSchemeHandler) SetAssets(assets embed.FS) {
9390 h.mu.Lock()
9491 defer h.mu.Unlock()
@@ -138,7 +135,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
138135 h.mu.RLock()
139136 build := h.currentConfigPayload
140137 h.mu.RUnlock()
141- return h.buildConfigResponse(build)
138+ return buildConfigResponse(build)
142139 }))
143140
144141 // API: Get default config (used by Reset Defaults in dumb://config)
@@ -150,7 +147,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
150147 h.mu.RLock()
151148 build := h.defaultConfigPayload
152149 h.mu.RUnlock()
153- return h.buildConfigResponse(build)
150+ return buildConfigResponse(build)
154151 }))
155152 }
156153
@@ -173,7 +170,7 @@ func buildCrashPageHTML(originalURI string) string {
173170 return webutil.BuildCrashPageHTML(originalURI)
174171 }
175172
176-func (h *DumbSchemeHandler) buildConfigResponse(build func() ([]byte, error)) *SchemeResponse {
173+func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse {
177174 if build == nil {
178175 return &SchemeResponse{
179176 Data: []byte(`{"error": "config payload builder not configured"}`),
@@ -333,12 +330,10 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
333330 assetDir string
334331 file string
335332 }{
336- HomePath: {assetDir: "webui", file: IndexHTML},
337333 HistoryPath: {assetDir: "systemviews", file: IndexHTML},
338334 FavoritesPath: {assetDir: "systemviews", file: IndexHTML},
339335 ConfigPath: {assetDir: "systemviews", file: IndexHTML},
340- WebRTCPath: {assetDir: "webui", file: "webrtc.html"},
341- ErrorPath: {assetDir: "webui", file: "error.html"},
336+ ErrorPath: {assetDir: "systemviews", file: IndexHTML},
342337 }
343338
344339 if root, ok := rootByHost[u.Host]; ok {
@@ -350,14 +345,8 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
350345 }
351346
352347 switch u.Opaque {
353- case HomePath:
354- return "webui", IndexHTML, true
355- case HistoryPath, FavoritesPath, ConfigPath:
348+ case HistoryPath, FavoritesPath, ConfigPath, ErrorPath:
356349 return "systemviews", IndexHTML, true
357- case ErrorPath:
358- return "webui", "error.html", true
359- case WebRTCPath:
360- return "webui", "webrtc.html", true
361350 default:
362351 return "", "", false
363352 }
M internal/infrastructure/webkit/scheme_handler_test.go +8 -32
@@ -12,8 +12,8 @@ import (
1212 )
1313
1414 func TestCrashOriginalURI(t *testing.T) {
15- assert.Equal(t, "https://example.com/path?q=1", crashOriginalURI("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
16- assert.Empty(t, crashOriginalURI("dumb://home/crash"))
15+ assert.Equal(t, "https://example.com/path?q=1", crashOriginalURI("dumb://history/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
16+ assert.Empty(t, crashOriginalURI("dumb://history/crash"))
1717 assert.Empty(t, crashOriginalURI("%%%"))
1818 }
1919
@@ -38,8 +38,8 @@ func TestBuildCrashPageHTMLRejectsUnsafeReloadScheme(t *testing.T) {
3838 }
3939
4040 func TestBuildCrashPageHTMLAllowsDumbSchemeTarget(t *testing.T) {
41- body := buildCrashPageHTML("dumb://home")
42- assert.Contains(t, body, `data-target="dumb://home"`)
41+ body := buildCrashPageHTML("dumb://history")
42+ assert.Contains(t, body, `data-target="dumb://history"`)
4343 }
4444
4545 func TestBuildCrashPageHTMLEscapesScriptBreakoutPayload(t *testing.T) {
@@ -60,7 +60,7 @@ func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) {
6060 require.NotNil(t, crashHandler)
6161
6262 resp := crashHandler.Handle(&SchemeRequest{
63- URI: "dumb://home/crash?url=https%3A%2F%2Fexample.com",
63+ URI: "dumb://history/crash?url=https%3A%2F%2Fexample.com",
6464 Path: "/crash",
6565 Method: "GET",
6666 Scheme: "dumb",
@@ -82,7 +82,7 @@ func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) {
8282 require.NotNil(t, crashHandler)
8383
8484 resp := crashHandler.Handle(&SchemeRequest{
85- URI: "dumb://home/crash?url=javascript%3Aalert(1)",
85+ URI: "dumb://history/crash?url=javascript%3Aalert(1)",
8686 Path: "/crash",
8787 Method: "GET",
8888 Scheme: "dumb",
@@ -91,32 +91,6 @@ func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) {
9191 assert.Contains(t, string(resp.Data), `data-target=""`)
9292 }
9393
94-func TestHandleAsset_WebRTCRootServesInternalWebRTCTester(t *testing.T) {
95- h := NewDumbSchemeHandler(context.Background())
96- h.SetAssets(assets.WebUIAssets)
97-
98- u, err := url.Parse("dumb://webrtc/")
99- require.NoError(t, err)
100-
101- resp := h.handleAsset(u)
102- require.NotNil(t, resp)
103- assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
104- assert.Contains(t, string(resp.Data), "webrtc.min.js")
105-}
106-
107-func TestHandleAsset_WebRTCOpaqueFormServesInternalWebRTCTester(t *testing.T) {
108- h := NewDumbSchemeHandler(context.Background())
109- h.SetAssets(assets.WebUIAssets)
110-
111- u, err := url.Parse("dumb:webrtc")
112- require.NoError(t, err)
113-
114- resp := h.handleAsset(u)
115- require.NotNil(t, resp)
116- assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
117- assert.Contains(t, string(resp.Data), "webrtc.min.js")
118-}
119-
12094 func TestHandleAsset_ConfigOpaqueFormServesSystemviewsShell(t *testing.T) {
12195 h := NewDumbSchemeHandler(context.Background())
12296 h.SetAssets(assets.WebUIAssets)
@@ -141,6 +115,8 @@ func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) {
141115 "dumb:favorites",
142116 "dumb://config",
143117 "dumb:config",
118+ "dumb://error",
119+ "dumb:error",
144120 }
145121
146122 for _, raw := range tests {
M internal/ui/app.go +1 -1
@@ -1408,7 +1408,7 @@ func (a *App) createInitialTab(ctx context.Context) {
14081408 }
14091409
14101410 // Create an initial tab using coordinator.
1411- initialURL := "dumb://home"
1411+ initialURL := "dumb://history"
14121412 if a.deps != nil && a.deps.InitialURL != "" {
14131413 initialURL = a.deps.InitialURL
14141414 }
M internal/ui/coordinator/content/appearance.go +1 -1
@@ -14,7 +14,7 @@ import (
1414
1515 const (
1616 aboutBlankURI = "about:blank"
17- crashPageURI = "dumb://home/crash"
17+ crashPageURI = "dumb://history/crash"
1818 logURLMaxLen = 80
1919 oauthParentRefreshDebounce = 200 * time.Millisecond
2020
M internal/ui/coordinator/content/callbacks.go +2 -2
@@ -12,8 +12,8 @@ import (
1212 "github.com/bnema/puregotk/v4/gdk"
1313 )
1414
15-// internalSchemePath is the path used in dumb:// URIs (replaces webkit.HomePath).
16-const internalSchemePath = "home"
15+// internalSchemePath is the host used in dumb:// crash-page URIs.
16+const internalSchemePath = "history"
1717
1818 func shouldRenderCrashPage(reason port.WebProcessTerminationReason) bool {
1919 switch reason {
M internal/ui/coordinator/content/content_termination_test.go +4 -4
@@ -15,13 +15,13 @@ func TestShouldRenderCrashPage(t *testing.T) {
1515 }
1616
1717 func TestExtractOriginalURIFromCrashPage(t *testing.T) {
18- assert.Equal(t, "https://example.com/path?q=1", extractOriginalURIFromCrashPage("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
19- assert.Empty(t, extractOriginalURIFromCrashPage("dumb://home/crash"))
18+ assert.Equal(t, "https://example.com/path?q=1", extractOriginalURIFromCrashPage("dumb://history/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
19+ assert.Empty(t, extractOriginalURIFromCrashPage("dumb://history/crash"))
2020 assert.Equal(t, "https://example.com", extractOriginalURIFromCrashPage("https://example.com"))
2121 assert.Equal(t, "%%%bad", extractOriginalURIFromCrashPage("%%%bad"))
2222 }
2323
2424 func TestBuildCrashPageURI(t *testing.T) {
25- assert.Equal(t, "dumb://home/crash", buildCrashPageURI(""))
26- assert.Equal(t, "dumb://home/crash?url=https%3A%2F%2Fexample.com%2Ffoo%3Fa%3D1%26b%3D2", buildCrashPageURI("https://example.com/foo?a=1&b=2"))
25+ assert.Equal(t, "dumb://history/crash", buildCrashPageURI(""))
26+ assert.Equal(t, "dumb://history/crash?url=https%3A%2F%2Fexample.com%2Ffoo%3Fa%3D1%26b%3D2", buildCrashPageURI("https://example.com/foo?a=1&b=2"))
2727 }
M internal/ui/standalone_runtime_test.go +1 -1
@@ -74,7 +74,7 @@ func TestStandaloneExternalSchemeRules_CustomSchemeIsExternal(t *testing.T) {
7474 }
7575
7676 func TestStandaloneExternalSchemeRules_InternalSchemesStayInternal(t *testing.T) {
77- for _, rawURL := range []string{"dumb://home", "file:///tmp/demo", "about:blank", "data:text/plain,hi"} {
77+ for _, rawURL := range []string{"dumb://history", "dumb://error", "file:///tmp/demo", "about:blank", "data:text/plain,hi"} {
7878 if urlutil.IsExternalScheme(rawURL) {
7979 t.Fatalf("expected %q to stay internal", rawURL)
8080 }
D webui/components.json +0 -16
@@ -1,16 +0,0 @@
1-{
2- "$schema": "https://shadcn-svelte.com/schema.json",
3- "tailwind": {
4- "css": "src/styles/app.css",
5- "baseColor": "neutral"
6- },
7- "aliases": {
8- "components": "$lib/components",
9- "utils": "$lib/utils",
10- "ui": "$lib/components/ui",
11- "hooks": "$lib/hooks",
12- "lib": "$lib"
13- },
14- "typescript": true,
15- "registry": "https://shadcn-svelte.com/registry"
16-}
D webui/eslint.config.js +0 -59
@@ -1,59 +0,0 @@
1-import js from "@eslint/js";
2-import svelte from "eslint-plugin-svelte";
3-import globals from "globals";
4-import tseslint from "typescript-eslint";
5-import svelteConfig from "./svelte.config.js";
6-
7-export default tseslint.config(
8- js.configs.recommended,
9- ...tseslint.configs.recommended,
10- ...svelte.configs.recommended,
11- {
12- languageOptions: {
13- globals: {
14- ...globals.browser,
15- },
16- },
17- },
18- {
19- files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
20- languageOptions: {
21- parserOptions: {
22- extraFileExtensions: [".svelte"],
23- parser: tseslint.parser,
24- svelteConfig,
25- },
26- },
27- },
28- {
29- files: ["**/*.{js,mjs,cjs,ts}"],
30- languageOptions: {
31- ecmaVersion: 2024,
32- sourceType: "module",
33- },
34- rules: {
35- // Modern TypeScript best practices
36- "@typescript-eslint/no-unused-vars": [
37- "error",
38- {
39- argsIgnorePattern: "^_",
40- varsIgnorePattern: "^_",
41- },
42- ],
43- "@typescript-eslint/no-explicit-any": "warn",
44- "@typescript-eslint/no-empty-function": "off",
45- "@typescript-eslint/consistent-type-imports": "error",
46- "@typescript-eslint/no-import-type-side-effects": "error",
47-
48- // Modern JavaScript/TypeScript patterns
49- "prefer-const": "error",
50- "no-var": "error",
51- "object-shorthand": "error",
52- "prefer-template": "error",
53- },
54- },
55- {
56- files: ["**/*.js", "**/*.mjs"],
57- ...tseslint.configs.disableTypeChecked,
58- },
59-);
D webui/package-lock.json +0 -3903
@@ -1,3903 +0,0 @@
1-{
2- "name": "@dumber/gui",
3- "version": "0.1.0",
4- "lockfileVersion": 3,
5- "requires": true,
6- "packages": {
7- "": {
8- "name": "@dumber/gui",
9- "version": "0.1.0",
10- "dependencies": {
11- "bits-ui": "^2.16.3",
12- "clsx": "^2.1.1",
13- "frappe-charts": "^1.6.2",
14- "mode-watcher": "^1.1.0",
15- "tailwind-merge": "^3.5.0"
16- },
17- "devDependencies": {
18- "@internationalized/date": "^3.12.0",
19- "@lucide/svelte": "^0.577.0",
20- "@sveltejs/vite-plugin-svelte": "^7.0.0",
21- "@tailwindcss/cli": "^4.2.2",
22- "@tailwindcss/postcss": "^4.2.2",
23- "@types/node": "^25.5.0",
24- "@typescript-eslint/eslint-plugin": "^8.57.1",
25- "@typescript-eslint/parser": "^8.57.1",
26- "autoprefixer": "^10.4.27",
27- "eslint": "^10.0.3",
28- "eslint-plugin-svelte": "^3.15.2",
29- "globals": "^17.4.0",
30- "postcss": "^8.5.8",
31- "svelte": "^5.54.0",
32- "svelte-awesome-color-picker": "^4.1.0",
33- "svelte-check": "^4.4.5",
34- "tailwind-variants": "^3.2.2",
35- "tailwindcss": "^4.0.0",
36- "tw-animate-css": "^1.4.0",
37- "typescript": "^5.3.0",
38- "typescript-eslint": "^8.57.1",
39- "vite": "^8.0.0"
40- }
41- },
42- "node_modules/@alloc/quick-lru": {
43- "version": "5.2.0",
44- "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
45- "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
46- "dev": true,
47- "license": "MIT",
48- "engines": {
49- "node": ">=10"
50- },
51- "funding": {
52- "url": "https://github.com/sponsors/sindresorhus"
53- }
54- },
55- "node_modules/@emnapi/core": {
56- "version": "1.9.0",
57- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
58- "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
59- "dev": true,
60- "license": "MIT",
61- "optional": true,
62- "dependencies": {
63- "@emnapi/wasi-threads": "1.2.0",
64- "tslib": "^2.4.0"
65- }
66- },
67- "node_modules/@emnapi/runtime": {
68- "version": "1.9.0",
69- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
70- "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
71- "dev": true,
72- "license": "MIT",
73- "optional": true,
74- "dependencies": {
75- "tslib": "^2.4.0"
76- }
77- },
78- "node_modules/@emnapi/wasi-threads": {
79- "version": "1.2.0",
80- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
81- "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
82- "dev": true,
83- "license": "MIT",
84- "optional": true,
85- "dependencies": {
86- "tslib": "^2.4.0"
87- }
88- },
89- "node_modules/@eslint-community/eslint-utils": {
90- "version": "4.9.1",
91- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
92- "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
93- "dev": true,
94- "license": "MIT",
95- "dependencies": {
96- "eslint-visitor-keys": "^3.4.3"
97- },
98- "engines": {
99- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
100- },
101- "funding": {
102- "url": "https://opencollective.com/eslint"
103- },
104- "peerDependencies": {
105- "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
106- }
107- },
108- "node_modules/@eslint-community/regexpp": {
109- "version": "4.12.2",
110- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
111- "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
112- "dev": true,
113- "license": "MIT",
114- "engines": {
115- "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
116- }
117- },
118- "node_modules/@eslint/config-array": {
119- "version": "0.23.3",
120- "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
121- "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
122- "dev": true,
123- "license": "Apache-2.0",
124- "dependencies": {
125- "@eslint/object-schema": "^3.0.3",
126- "debug": "^4.3.1",
127- "minimatch": "^10.2.4"
128- },
129- "engines": {
130- "node": "^20.19.0 || ^22.13.0 || >=24"
131- }
132- },
133- "node_modules/@eslint/config-helpers": {
134- "version": "0.5.2",
135- "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz",
136- "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==",
137- "dev": true,
138- "license": "Apache-2.0",
139- "dependencies": {
140- "@eslint/core": "^1.1.0"
141- },
142- "engines": {
143- "node": "^20.19.0 || ^22.13.0 || >=24"
144- }
145- },
146- "node_modules/@eslint/core": {
147- "version": "1.1.1",
148- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
149- "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
150- "dev": true,
151- "license": "Apache-2.0",
152- "dependencies": {
153- "@types/json-schema": "^7.0.15"
154- },
155- "engines": {
156- "node": "^20.19.0 || ^22.13.0 || >=24"
157- }
158- },
159- "node_modules/@eslint/object-schema": {
160- "version": "3.0.3",
161- "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
162- "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
163- "dev": true,
164- "license": "Apache-2.0",
165- "engines": {
166- "node": "^20.19.0 || ^22.13.0 || >=24"
167- }
168- },
169- "node_modules/@eslint/plugin-kit": {
170- "version": "0.6.1",
171- "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
172- "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
173- "dev": true,
174- "license": "Apache-2.0",
175- "dependencies": {
176- "@eslint/core": "^1.1.1",
177- "levn": "^0.4.1"
178- },
179- "engines": {
180- "node": "^20.19.0 || ^22.13.0 || >=24"
181- }
182- },
183- "node_modules/@floating-ui/core": {
184- "version": "1.7.3",
185- "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
186- "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
187- "license": "MIT",
188- "dependencies": {
189- "@floating-ui/utils": "^0.2.10"
190- }
191- },
192- "node_modules/@floating-ui/dom": {
193- "version": "1.7.4",
194- "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
195- "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
196- "license": "MIT",
197- "dependencies": {
198- "@floating-ui/core": "^1.7.3",
199- "@floating-ui/utils": "^0.2.10"
200- }
201- },
202- "node_modules/@floating-ui/utils": {
203- "version": "0.2.10",
204- "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
205- "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
206- "license": "MIT"
207- },
208- "node_modules/@humanfs/core": {
209- "version": "0.19.1",
210- "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
211- "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
212- "dev": true,
213- "license": "Apache-2.0",
214- "engines": {
215- "node": ">=18.18.0"
216- }
217- },
218- "node_modules/@humanfs/node": {
219- "version": "0.16.7",
220- "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
221- "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
222- "dev": true,
223- "license": "Apache-2.0",
224- "dependencies": {
225- "@humanfs/core": "^0.19.1",
226- "@humanwhocodes/retry": "^0.4.0"
227- },
228- "engines": {
229- "node": ">=18.18.0"
230- }
231- },
232- "node_modules/@humanwhocodes/module-importer": {
233- "version": "1.0.1",
234- "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
235- "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
236- "dev": true,
237- "license": "Apache-2.0",
238- "engines": {
239- "node": ">=12.22"
240- },
241- "funding": {
242- "type": "github",
243- "url": "https://github.com/sponsors/nzakas"
244- }
245- },
246- "node_modules/@humanwhocodes/retry": {
247- "version": "0.4.3",
248- "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
249- "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
250- "dev": true,
251- "license": "Apache-2.0",
252- "engines": {
253- "node": ">=18.18"
254- },
255- "funding": {
256- "type": "github",
257- "url": "https://github.com/sponsors/nzakas"
258- }
259- },
260- "node_modules/@internationalized/date": {
261- "version": "3.12.0",
262- "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz",
263- "integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==",
264- "license": "Apache-2.0",
265- "peer": true,
266- "dependencies": {
267- "@swc/helpers": "^0.5.0"
268- }
269- },
270- "node_modules/@jridgewell/gen-mapping": {
271- "version": "0.3.13",
272- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
273- "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
274- "license": "MIT",
275- "dependencies": {
276- "@jridgewell/sourcemap-codec": "^1.5.0",
277- "@jridgewell/trace-mapping": "^0.3.24"
278- }
279- },
280- "node_modules/@jridgewell/remapping": {
281- "version": "2.3.5",
282- "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
283- "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
284- "license": "MIT",
285- "dependencies": {
286- "@jridgewell/gen-mapping": "^0.3.5",
287- "@jridgewell/trace-mapping": "^0.3.24"
288- }
289- },
290- "node_modules/@jridgewell/resolve-uri": {
291- "version": "3.1.2",
292- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
293- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
294- "license": "MIT",
295- "engines": {
296- "node": ">=6.0.0"
297- }
298- },
299- "node_modules/@jridgewell/sourcemap-codec": {
300- "version": "1.5.5",
301- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
302- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
303- "license": "MIT"
304- },
305- "node_modules/@jridgewell/trace-mapping": {
306- "version": "0.3.31",
307- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
308- "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
309- "license": "MIT",
310- "dependencies": {
311- "@jridgewell/resolve-uri": "^3.1.0",
312- "@jridgewell/sourcemap-codec": "^1.4.14"
313- }
314- },
315- "node_modules/@lucide/svelte": {
316- "version": "0.577.0",
317- "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.577.0.tgz",
318- "integrity": "sha512-0P6mkySd2MapIEgq08tADPmcN4DHndC/02PWwaLkOerXlx5Sv9aT4BxyXLIY+eccr0g/nEyCYiJesqS61YdBZQ==",
319- "dev": true,
320- "license": "ISC",
321- "peerDependencies": {
322- "svelte": "^5"
323- }
324- },
325- "node_modules/@napi-rs/wasm-runtime": {
326- "version": "1.1.1",
327- "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
328- "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
329- "dev": true,
330- "license": "MIT",
331- "optional": true,
332- "dependencies": {
333- "@emnapi/core": "^1.7.1",
334- "@emnapi/runtime": "^1.7.1",
335- "@tybys/wasm-util": "^0.10.1"
336- },
337- "funding": {
338- "type": "github",
339- "url": "https://github.com/sponsors/Brooooooklyn"
340- }
341- },
342- "node_modules/@oxc-project/runtime": {
343- "version": "0.115.0",
344- "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
345- "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
346- "dev": true,
347- "license": "MIT",
348- "engines": {
349- "node": "^20.19.0 || >=22.12.0"
350- }
351- },
352- "node_modules/@oxc-project/types": {
353- "version": "0.115.0",
354- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
355- "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
356- "dev": true,
357- "license": "MIT",
358- "funding": {
359- "url": "https://github.com/sponsors/Boshen"
360- }
361- },
362- "node_modules/@parcel/watcher": {
363- "version": "2.5.4",
364- "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.4.tgz",
365- "integrity": "sha512-WYa2tUVV5HiArWPB3ydlOc4R2ivq0IDrlqhMi3l7mVsFEXNcTfxYFPIHXHXIh/ca/y/V5N4E1zecyxdIBjYnkQ==",
366- "dev": true,
367- "hasInstallScript": true,
368- "license": "MIT",
369- "dependencies": {
370- "detect-libc": "^2.0.3",
371- "is-glob": "^4.0.3",
372- "node-addon-api": "^7.0.0",
373- "picomatch": "^4.0.3"
374- },
375- "engines": {
376- "node": ">= 10.0.0"
377- },
378- "funding": {
379- "type": "opencollective",
380- "url": "https://opencollective.com/parcel"
381- },
382- "optionalDependencies": {
383- "@parcel/watcher-android-arm64": "2.5.4",
384- "@parcel/watcher-darwin-arm64": "2.5.4",
385- "@parcel/watcher-darwin-x64": "2.5.4",
386- "@parcel/watcher-freebsd-x64": "2.5.4",
387- "@parcel/watcher-linux-arm-glibc": "2.5.4",
388- "@parcel/watcher-linux-arm-musl": "2.5.4",
389- "@parcel/watcher-linux-arm64-glibc": "2.5.4",
390- "@parcel/watcher-linux-arm64-musl": "2.5.4",
391- "@parcel/watcher-linux-x64-glibc": "2.5.4",
392- "@parcel/watcher-linux-x64-musl": "2.5.4",
393- "@parcel/watcher-win32-arm64": "2.5.4",
394- "@parcel/watcher-win32-ia32": "2.5.4",
395- "@parcel/watcher-win32-x64": "2.5.4"
396- }
397- },
398- "node_modules/@parcel/watcher-android-arm64": {
399- "version": "2.5.4",
400- "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.4.tgz",
401- "integrity": "sha512-hoh0vx4v+b3BNI7Cjoy2/B0ARqcwVNrzN/n7DLq9ZB4I3lrsvhrkCViJyfTj/Qi5xM9YFiH4AmHGK6pgH1ss7g==",
402- "cpu": [
403- "arm64"
404- ],
405- "dev": true,
406- "license": "MIT",
407- "optional": true,
408- "os": [
409- "android"
410- ],
411- "engines": {
412- "node": ">= 10.0.0"
413- },
414- "funding": {
415- "type": "opencollective",
416- "url": "https://opencollective.com/parcel"
417- }
418- },
419- "node_modules/@parcel/watcher-darwin-arm64": {
420- "version": "2.5.4",
421- "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.4.tgz",
422- "integrity": "sha512-kphKy377pZiWpAOyTgQYPE5/XEKVMaj6VUjKT5VkNyUJlr2qZAn8gIc7CPzx+kbhvqHDT9d7EqdOqRXT6vk0zw==",
423- "cpu": [
424- "arm64"
425- ],
426- "dev": true,
427- "license": "MIT",
428- "optional": true,
429- "os": [
430- "darwin"
431- ],
432- "engines": {
433- "node": ">= 10.0.0"
434- },
435- "funding": {
436- "type": "opencollective",
437- "url": "https://opencollective.com/parcel"
438- }
439- },
440- "node_modules/@parcel/watcher-darwin-x64": {
441- "version": "2.5.4",
442- "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.4.tgz",
443- "integrity": "sha512-UKaQFhCtNJW1A9YyVz3Ju7ydf6QgrpNQfRZ35wNKUhTQ3dxJ/3MULXN5JN/0Z80V/KUBDGa3RZaKq1EQT2a2gg==",
444- "cpu": [
445- "x64"
446- ],
447- "dev": true,
448- "license": "MIT",
449- "optional": true,
450- "os": [
451- "darwin"
452- ],
453- "engines": {
454- "node": ">= 10.0.0"
455- },
456- "funding": {
457- "type": "opencollective",
458- "url": "https://opencollective.com/parcel"
459- }
460- },
461- "node_modules/@parcel/watcher-freebsd-x64": {
462- "version": "2.5.4",
463- "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.4.tgz",
464- "integrity": "sha512-Dib0Wv3Ow/m2/ttvLdeI2DBXloO7t3Z0oCp4bAb2aqyqOjKPPGrg10pMJJAQ7tt8P4V2rwYwywkDhUia/FgS+Q==",
465- "cpu": [
466- "x64"
467- ],
468- "dev": true,
469- "license": "MIT",
470- "optional": true,
471- "os": [
472- "freebsd"
473- ],
474- "engines": {
475- "node": ">= 10.0.0"
476- },
477- "funding": {
478- "type": "opencollective",
479- "url": "https://opencollective.com/parcel"
480- }
481- },
482- "node_modules/@parcel/watcher-linux-arm-glibc": {
483- "version": "2.5.4",
484- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.4.tgz",
485- "integrity": "sha512-I5Vb769pdf7Q7Sf4KNy8Pogl/URRCKu9ImMmnVKYayhynuyGYMzuI4UOWnegQNa2sGpsPSbzDsqbHNMyeyPCgw==",
486- "cpu": [
487- "arm"
488- ],
489- "dev": true,
490- "license": "MIT",
491- "optional": true,
492- "os": [
493- "linux"
494- ],
495- "engines": {
496- "node": ">= 10.0.0"
497- },
498- "funding": {
499- "type": "opencollective",
500- "url": "https://opencollective.com/parcel"
501- }
502- },
503- "node_modules/@parcel/watcher-linux-arm-musl": {
504- "version": "2.5.4",
505- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.4.tgz",
506- "integrity": "sha512-kGO8RPvVrcAotV4QcWh8kZuHr9bXi9a3bSZw7kFarYR0+fGliU7hd/zevhjw8fnvIKG3J9EO5G6sXNGCSNMYPQ==",
507- "cpu": [
508- "arm"
509- ],
510- "dev": true,
511- "license": "MIT",
512- "optional": true,
513- "os": [
514- "linux"
515- ],
516- "engines": {
517- "node": ">= 10.0.0"
518- },
519- "funding": {
520- "type": "opencollective",
521- "url": "https://opencollective.com/parcel"
522- }
523- },
524- "node_modules/@parcel/watcher-linux-arm64-glibc": {
525- "version": "2.5.4",
526- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.4.tgz",
527- "integrity": "sha512-KU75aooXhqGFY2W5/p8DYYHt4hrjHZod8AhcGAmhzPn/etTa+lYCDB2b1sJy3sWJ8ahFVTdy+EbqSBvMx3iFlw==",
528- "cpu": [
529- "arm64"
530- ],
531- "dev": true,
532- "license": "MIT",
533- "optional": true,
534- "os": [
535- "linux"
536- ],
537- "engines": {
538- "node": ">= 10.0.0"
539- },
540- "funding": {
541- "type": "opencollective",
542- "url": "https://opencollective.com/parcel"
543- }
544- },
545- "node_modules/@parcel/watcher-linux-arm64-musl": {
546- "version": "2.5.4",
547- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.4.tgz",
548- "integrity": "sha512-Qx8uNiIekVutnzbVdrgSanM+cbpDD3boB1f8vMtnuG5Zau4/bdDbXyKwIn0ToqFhIuob73bcxV9NwRm04/hzHQ==",
549- "cpu": [
550- "arm64"
551- ],
552- "dev": true,
553- "license": "MIT",
554- "optional": true,
555- "os": [
556- "linux"
557- ],
558- "engines": {
559- "node": ">= 10.0.0"
560- },
561- "funding": {
562- "type": "opencollective",
563- "url": "https://opencollective.com/parcel"
564- }
565- },
566- "node_modules/@parcel/watcher-linux-x64-glibc": {
567- "version": "2.5.4",
568- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.4.tgz",
569- "integrity": "sha512-UYBQvhYmgAv61LNUn24qGQdjtycFBKSK3EXr72DbJqX9aaLbtCOO8+1SkKhD/GNiJ97ExgcHBrukcYhVjrnogA==",
570- "cpu": [
571- "x64"
572- ],
573- "dev": true,
574- "license": "MIT",
575- "optional": true,
576- "os": [
577- "linux"
578- ],
579- "engines": {
580- "node": ">= 10.0.0"
581- },
582- "funding": {
583- "type": "opencollective",
584- "url": "https://opencollective.com/parcel"
585- }
586- },
587- "node_modules/@parcel/watcher-linux-x64-musl": {
588- "version": "2.5.4",
589- "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.4.tgz",
590- "integrity": "sha512-YoRWCVgxv8akZrMhdyVi6/TyoeeMkQ0PGGOf2E4omODrvd1wxniXP+DBynKoHryStks7l+fDAMUBRzqNHrVOpg==",
591- "cpu": [
592- "x64"
593- ],
594- "dev": true,
595- "license": "MIT",
596- "optional": true,
597- "os": [
598- "linux"
599- ],
600- "engines": {
601- "node": ">= 10.0.0"
602- },
603- "funding": {
604- "type": "opencollective",
605- "url": "https://opencollective.com/parcel"
606- }
607- },
608- "node_modules/@parcel/watcher-win32-arm64": {
609- "version": "2.5.4",
610- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.4.tgz",
611- "integrity": "sha512-iby+D/YNXWkiQNYcIhg8P5hSjzXEHaQrk2SLrWOUD7VeC4Ohu0WQvmV+HDJokZVJ2UjJ4AGXW3bx7Lls9Ln4TQ==",
612- "cpu": [
613- "arm64"
614- ],
615- "dev": true,
616- "license": "MIT",
617- "optional": true,
618- "os": [
619- "win32"
620- ],
621- "engines": {
622- "node": ">= 10.0.0"
623- },
624- "funding": {
625- "type": "opencollective",
626- "url": "https://opencollective.com/parcel"
627- }
628- },
629- "node_modules/@parcel/watcher-win32-ia32": {
630- "version": "2.5.4",
631- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.4.tgz",
632- "integrity": "sha512-vQN+KIReG0a2ZDpVv8cgddlf67J8hk1WfZMMP7sMeZmJRSmEax5xNDNWKdgqSe2brOKTQQAs3aCCUal2qBHAyg==",
633- "cpu": [
634- "ia32"
635- ],
636- "dev": true,
637- "license": "MIT",
638- "optional": true,
639- "os": [
640- "win32"
641- ],
642- "engines": {
643- "node": ">= 10.0.0"
644- },
645- "funding": {
646- "type": "opencollective",
647- "url": "https://opencollective.com/parcel"
648- }
649- },
650- "node_modules/@parcel/watcher-win32-x64": {
651- "version": "2.5.4",
652- "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.4.tgz",
653- "integrity": "sha512-3A6efb6BOKwyw7yk9ro2vus2YTt2nvcd56AuzxdMiVOxL9umDyN5PKkKfZ/gZ9row41SjVmTVQNWQhaRRGpOKw==",
654- "cpu": [
655- "x64"
656- ],
657- "dev": true,
658- "license": "MIT",
659- "optional": true,
660- "os": [
661- "win32"
662- ],
663- "engines": {
664- "node": ">= 10.0.0"
665- },
666- "funding": {
667- "type": "opencollective",
668- "url": "https://opencollective.com/parcel"
669- }
670- },
671- "node_modules/@rolldown/binding-android-arm64": {
672- "version": "1.0.0-rc.9",
673- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
674- "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
675- "cpu": [
676- "arm64"
677- ],
678- "dev": true,
679- "license": "MIT",
680- "optional": true,
681- "os": [
682- "android"
683- ],
684- "engines": {
685- "node": "^20.19.0 || >=22.12.0"
686- }
687- },
688- "node_modules/@rolldown/binding-darwin-arm64": {
689- "version": "1.0.0-rc.9",
690- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
691- "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
692- "cpu": [
693- "arm64"
694- ],
695- "dev": true,
696- "license": "MIT",
697- "optional": true,
698- "os": [
699- "darwin"
700- ],
701- "engines": {
702- "node": "^20.19.0 || >=22.12.0"
703- }
704- },
705- "node_modules/@rolldown/binding-darwin-x64": {
706- "version": "1.0.0-rc.9",
707- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz",
708- "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
709- "cpu": [
710- "x64"
711- ],
712- "dev": true,
713- "license": "MIT",
714- "optional": true,
715- "os": [
716- "darwin"
717- ],
718- "engines": {
719- "node": "^20.19.0 || >=22.12.0"
720- }
721- },
722- "node_modules/@rolldown/binding-freebsd-x64": {
723- "version": "1.0.0-rc.9",
724- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz",
725- "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
726- "cpu": [
727- "x64"
728- ],
729- "dev": true,
730- "license": "MIT",
731- "optional": true,
732- "os": [
733- "freebsd"
734- ],
735- "engines": {
736- "node": "^20.19.0 || >=22.12.0"
737- }
738- },
739- "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
740- "version": "1.0.0-rc.9",
741- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz",
742- "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
743- "cpu": [
744- "arm"
745- ],
746- "dev": true,
747- "license": "MIT",
748- "optional": true,
749- "os": [
750- "linux"
751- ],
752- "engines": {
753- "node": "^20.19.0 || >=22.12.0"
754- }
755- },
756- "node_modules/@rolldown/binding-linux-arm64-gnu": {
757- "version": "1.0.0-rc.9",
758- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz",
759- "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
760- "cpu": [
761- "arm64"
762- ],
763- "dev": true,
764- "license": "MIT",
765- "optional": true,
766- "os": [
767- "linux"
768- ],
769- "engines": {
770- "node": "^20.19.0 || >=22.12.0"
771- }
772- },
773- "node_modules/@rolldown/binding-linux-arm64-musl": {
774- "version": "1.0.0-rc.9",
775- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz",
776- "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
777- "cpu": [
778- "arm64"
779- ],
780- "dev": true,
781- "license": "MIT",
782- "optional": true,
783- "os": [
784- "linux"
785- ],
786- "engines": {
787- "node": "^20.19.0 || >=22.12.0"
788- }
789- },
790- "node_modules/@rolldown/binding-linux-ppc64-gnu": {
791- "version": "1.0.0-rc.9",
792- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz",
793- "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
794- "cpu": [
795- "ppc64"
796- ],
797- "dev": true,
798- "license": "MIT",
799- "optional": true,
800- "os": [
801- "linux"
802- ],
803- "engines": {
804- "node": "^20.19.0 || >=22.12.0"
805- }
806- },
807- "node_modules/@rolldown/binding-linux-s390x-gnu": {
808- "version": "1.0.0-rc.9",
809- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz",
810- "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
811- "cpu": [
812- "s390x"
813- ],
814- "dev": true,
815- "license": "MIT",
816- "optional": true,
817- "os": [
818- "linux"
819- ],
820- "engines": {
821- "node": "^20.19.0 || >=22.12.0"
822- }
823- },
824- "node_modules/@rolldown/binding-linux-x64-gnu": {
825- "version": "1.0.0-rc.9",
826- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz",
827- "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
828- "cpu": [
829- "x64"
830- ],
831- "dev": true,
832- "license": "MIT",
833- "optional": true,
834- "os": [
835- "linux"
836- ],
837- "engines": {
838- "node": "^20.19.0 || >=22.12.0"
839- }
840- },
841- "node_modules/@rolldown/binding-linux-x64-musl": {
842- "version": "1.0.0-rc.9",
843- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz",
844- "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
845- "cpu": [
846- "x64"
847- ],
848- "dev": true,
849- "license": "MIT",
850- "optional": true,
851- "os": [
852- "linux"
853- ],
854- "engines": {
855- "node": "^20.19.0 || >=22.12.0"
856- }
857- },
858- "node_modules/@rolldown/binding-openharmony-arm64": {
859- "version": "1.0.0-rc.9",
860- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz",
861- "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
862- "cpu": [
863- "arm64"
864- ],
865- "dev": true,
866- "license": "MIT",
867- "optional": true,
868- "os": [
869- "openharmony"
870- ],
871- "engines": {
872- "node": "^20.19.0 || >=22.12.0"
873- }
874- },
875- "node_modules/@rolldown/binding-wasm32-wasi": {
876- "version": "1.0.0-rc.9",
877- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz",
878- "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
879- "cpu": [
880- "wasm32"
881- ],
882- "dev": true,
883- "license": "MIT",
884- "optional": true,
885- "dependencies": {
886- "@napi-rs/wasm-runtime": "^1.1.1"
887- },
888- "engines": {
889- "node": ">=14.0.0"
890- }
891- },
892- "node_modules/@rolldown/binding-win32-arm64-msvc": {
893- "version": "1.0.0-rc.9",
894- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
895- "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
896- "cpu": [
897- "arm64"
898- ],
899- "dev": true,
900- "license": "MIT",
901- "optional": true,
902- "os": [
903- "win32"
904- ],
905- "engines": {
906- "node": "^20.19.0 || >=22.12.0"
907- }
908- },
909- "node_modules/@rolldown/binding-win32-x64-msvc": {
910- "version": "1.0.0-rc.9",
911- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz",
912- "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
913- "cpu": [
914- "x64"
915- ],
916- "dev": true,
917- "license": "MIT",
918- "optional": true,
919- "os": [
920- "win32"
921- ],
922- "engines": {
923- "node": "^20.19.0 || >=22.12.0"
924- }
925- },
926- "node_modules/@rolldown/pluginutils": {
927- "version": "1.0.0-rc.9",
928- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
929- "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
930- "dev": true,
931- "license": "MIT"
932- },
933- "node_modules/@sveltejs/acorn-typescript": {
934- "version": "1.0.8",
935- "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
936- "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
937- "license": "MIT",
938- "peerDependencies": {
939- "acorn": "^8.9.0"
940- }
941- },
942- "node_modules/@sveltejs/vite-plugin-svelte": {
943- "version": "7.0.0",
944- "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz",
945- "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==",
946- "dev": true,
947- "license": "MIT",
948- "dependencies": {
949- "deepmerge": "^4.3.1",
950- "magic-string": "^0.30.21",
951- "obug": "^2.1.0",
952- "vitefu": "^1.1.2"
953- },
954- "engines": {
955- "node": "^20.19 || ^22.12 || >=24"
956- },
957- "peerDependencies": {
958- "svelte": "^5.46.4",
959- "vite": "^8.0.0-beta.7 || ^8.0.0"
960- }
961- },
962- "node_modules/@swc/helpers": {
963- "version": "0.5.18",
964- "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
965- "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
966- "license": "Apache-2.0",
967- "dependencies": {
968- "tslib": "^2.8.0"
969- }
970- },
971- "node_modules/@tailwindcss/cli": {
972- "version": "4.2.2",
973- "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.2.tgz",
974- "integrity": "sha512-iJS+8kAFZ8HPqnh0O5DHCLjo4L6dD97DBQEkrhfSO4V96xeefUus2jqsBs1dUMt3OU9Ks4qIkiY0mpL5UW+4LQ==",
975- "dev": true,
976- "license": "MIT",
977- "dependencies": {
978- "@parcel/watcher": "^2.5.1",
979- "@tailwindcss/node": "4.2.2",
980- "@tailwindcss/oxide": "4.2.2",
981- "enhanced-resolve": "^5.19.0",
982- "mri": "^1.2.0",
983- "picocolors": "^1.1.1",
984- "tailwindcss": "4.2.2"
985- },
986- "bin": {
987- "tailwindcss": "dist/index.mjs"
988- }
989- },
990- "node_modules/@tailwindcss/node": {
991- "version": "4.2.2",
992- "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
993- "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
994- "dev": true,
995- "license": "MIT",
996- "dependencies": {
997- "@jridgewell/remapping": "^2.3.5",
998- "enhanced-resolve": "^5.19.0",
999- "jiti": "^2.6.1",
1000- "lightningcss": "1.32.0",
1001- "magic-string": "^0.30.21",
1002- "source-map-js": "^1.2.1",
1003- "tailwindcss": "4.2.2"
1004- }
1005- },
1006- "node_modules/@tailwindcss/oxide": {
1007- "version": "4.2.2",
1008- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
1009- "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
1010- "dev": true,
1011- "license": "MIT",
1012- "engines": {
1013- "node": ">= 20"
1014- },
1015- "optionalDependencies": {
1016- "@tailwindcss/oxide-android-arm64": "4.2.2",
1017- "@tailwindcss/oxide-darwin-arm64": "4.2.2",
1018- "@tailwindcss/oxide-darwin-x64": "4.2.2",
1019- "@tailwindcss/oxide-freebsd-x64": "4.2.2",
1020- "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
1021- "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
1022- "@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
1023- "@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
1024- "@tailwindcss/oxide-linux-x64-musl": "4.2.2",
1025- "@tailwindcss/oxide-wasm32-wasi": "4.2.2",
1026- "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
1027- "@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
1028- }
1029- },
1030- "node_modules/@tailwindcss/oxide-android-arm64": {
1031- "version": "4.2.2",
1032- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
1033- "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
1034- "cpu": [
1035- "arm64"
1036- ],
1037- "dev": true,
1038- "license": "MIT",
1039- "optional": true,
1040- "os": [
1041- "android"
1042- ],
1043- "engines": {
1044- "node": ">= 20"
1045- }
1046- },
1047- "node_modules/@tailwindcss/oxide-darwin-arm64": {
1048- "version": "4.2.2",
1049- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
1050- "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
1051- "cpu": [
1052- "arm64"
1053- ],
1054- "dev": true,
1055- "license": "MIT",
1056- "optional": true,
1057- "os": [
1058- "darwin"
1059- ],
1060- "engines": {
1061- "node": ">= 20"
1062- }
1063- },
1064- "node_modules/@tailwindcss/oxide-darwin-x64": {
1065- "version": "4.2.2",
1066- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
1067- "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
1068- "cpu": [
1069- "x64"
1070- ],
1071- "dev": true,
1072- "license": "MIT",
1073- "optional": true,
1074- "os": [
1075- "darwin"
1076- ],
1077- "engines": {
1078- "node": ">= 20"
1079- }
1080- },
1081- "node_modules/@tailwindcss/oxide-freebsd-x64": {
1082- "version": "4.2.2",
1083- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
1084- "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
1085- "cpu": [
1086- "x64"
1087- ],
1088- "dev": true,
1089- "license": "MIT",
1090- "optional": true,
1091- "os": [
1092- "freebsd"
1093- ],
1094- "engines": {
1095- "node": ">= 20"
1096- }
1097- },
1098- "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
1099- "version": "4.2.2",
1100- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
1101- "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
1102- "cpu": [
1103- "arm"
1104- ],
1105- "dev": true,
1106- "license": "MIT",
1107- "optional": true,
1108- "os": [
1109- "linux"
1110- ],
1111- "engines": {
1112- "node": ">= 20"
1113- }
1114- },
1115- "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
1116- "version": "4.2.2",
1117- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
1118- "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
1119- "cpu": [
1120- "arm64"
1121- ],
1122- "dev": true,
1123- "license": "MIT",
1124- "optional": true,
1125- "os": [
1126- "linux"
1127- ],
1128- "engines": {
1129- "node": ">= 20"
1130- }
1131- },
1132- "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
1133- "version": "4.2.2",
1134- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
1135- "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
1136- "cpu": [
1137- "arm64"
1138- ],
1139- "dev": true,
1140- "license": "MIT",
1141- "optional": true,
1142- "os": [
1143- "linux"
1144- ],
1145- "engines": {
1146- "node": ">= 20"
1147- }
1148- },
1149- "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
1150- "version": "4.2.2",
1151- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
1152- "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
1153- "cpu": [
1154- "x64"
1155- ],
1156- "dev": true,
1157- "license": "MIT",
1158- "optional": true,
1159- "os": [
1160- "linux"
1161- ],
1162- "engines": {
1163- "node": ">= 20"
1164- }
1165- },
1166- "node_modules/@tailwindcss/oxide-linux-x64-musl": {
1167- "version": "4.2.2",
1168- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
1169- "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
1170- "cpu": [
1171- "x64"
1172- ],
1173- "dev": true,
1174- "license": "MIT",
1175- "optional": true,
1176- "os": [
1177- "linux"
1178- ],
1179- "engines": {
1180- "node": ">= 20"
1181- }
1182- },
1183- "node_modules/@tailwindcss/oxide-wasm32-wasi": {
1184- "version": "4.2.2",
1185- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
1186- "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
1187- "bundleDependencies": [
1188- "@napi-rs/wasm-runtime",
1189- "@emnapi/core",
1190- "@emnapi/runtime",
1191- "@tybys/wasm-util",
1192- "@emnapi/wasi-threads",
1193- "tslib"
1194- ],
1195- "cpu": [
1196- "wasm32"
1197- ],
1198- "dev": true,
1199- "license": "MIT",
1200- "optional": true,
1201- "dependencies": {
1202- "@emnapi/core": "^1.8.1",
1203- "@emnapi/runtime": "^1.8.1",
1204- "@emnapi/wasi-threads": "^1.1.0",
1205- "@napi-rs/wasm-runtime": "^1.1.1",
1206- "@tybys/wasm-util": "^0.10.1",
1207- "tslib": "^2.8.1"
1208- },
1209- "engines": {
1210- "node": ">=14.0.0"
1211- }
1212- },
1213- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
1214- "version": "1.8.1",
1215- "dev": true,
1216- "inBundle": true,
1217- "license": "MIT",
1218- "optional": true,
1219- "dependencies": {
1220- "@emnapi/wasi-threads": "1.1.0",
1221- "tslib": "^2.4.0"
1222- }
1223- },
1224- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
1225- "version": "1.8.1",
1226- "dev": true,
1227- "inBundle": true,
1228- "license": "MIT",
1229- "optional": true,
1230- "dependencies": {
1231- "tslib": "^2.4.0"
1232- }
1233- },
1234- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
1235- "version": "1.1.0",
1236- "dev": true,
1237- "inBundle": true,
1238- "license": "MIT",
1239- "optional": true,
1240- "dependencies": {
1241- "tslib": "^2.4.0"
1242- }
1243- },
1244- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
1245- "version": "1.1.1",
1246- "dev": true,
1247- "inBundle": true,
1248- "license": "MIT",
1249- "optional": true,
1250- "dependencies": {
1251- "@emnapi/core": "^1.7.1",
1252- "@emnapi/runtime": "^1.7.1",
1253- "@tybys/wasm-util": "^0.10.1"
1254- },
1255- "funding": {
1256- "type": "github",
1257- "url": "https://github.com/sponsors/Brooooooklyn"
1258- }
1259- },
1260- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
1261- "version": "0.10.1",
1262- "dev": true,
1263- "inBundle": true,
1264- "license": "MIT",
1265- "optional": true,
1266- "dependencies": {
1267- "tslib": "^2.4.0"
1268- }
1269- },
1270- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
1271- "version": "2.8.1",
1272- "dev": true,
1273- "inBundle": true,
1274- "license": "0BSD",
1275- "optional": true
1276- },
1277- "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
1278- "version": "4.2.2",
1279- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
1280- "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
1281- "cpu": [
1282- "arm64"
1283- ],
1284- "dev": true,
1285- "license": "MIT",
1286- "optional": true,
1287- "os": [
1288- "win32"
1289- ],
1290- "engines": {
1291- "node": ">= 20"
1292- }
1293- },
1294- "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
1295- "version": "4.2.2",
1296- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
1297- "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
1298- "cpu": [
1299- "x64"
1300- ],
1301- "dev": true,
1302- "license": "MIT",
1303- "optional": true,
1304- "os": [
1305- "win32"
1306- ],
1307- "engines": {
1308- "node": ">= 20"
1309- }
1310- },
1311- "node_modules/@tailwindcss/postcss": {
1312- "version": "4.2.2",
1313- "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz",
1314- "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==",
1315- "dev": true,
1316- "license": "MIT",
1317- "dependencies": {
1318- "@alloc/quick-lru": "^5.2.0",
1319- "@tailwindcss/node": "4.2.2",
1320- "@tailwindcss/oxide": "4.2.2",
1321- "postcss": "^8.5.6",
1322- "tailwindcss": "4.2.2"
1323- }
1324- },
1325- "node_modules/@tybys/wasm-util": {
1326- "version": "0.10.1",
1327- "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
1328- "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
1329- "dev": true,
1330- "license": "MIT",
1331- "optional": true,
1332- "dependencies": {
1333- "tslib": "^2.4.0"
1334- }
1335- },
1336- "node_modules/@types/esrecurse": {
1337- "version": "4.3.1",
1338- "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
1339- "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
1340- "dev": true,
1341- "license": "MIT"
1342- },
1343- "node_modules/@types/estree": {
1344- "version": "1.0.8",
1345- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
1346- "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
1347- "license": "MIT"
1348- },
1349- "node_modules/@types/json-schema": {
1350- "version": "7.0.15",
1351- "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
1352- "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
1353- "dev": true,
1354- "license": "MIT"
1355- },
1356- "node_modules/@types/node": {
1357- "version": "25.5.0",
1358- "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
1359- "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
1360- "dev": true,
1361- "license": "MIT",
1362- "peer": true,
1363- "dependencies": {
1364- "undici-types": "~7.18.0"
1365- }
1366- },
1367- "node_modules/@types/trusted-types": {
1368- "version": "2.0.7",
1369- "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
1370- "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
1371- "license": "MIT"
1372- },
1373- "node_modules/@typescript-eslint/eslint-plugin": {
1374- "version": "8.57.1",
1375- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
1376- "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==",
1377- "dev": true,
1378- "license": "MIT",
1379- "dependencies": {
1380- "@eslint-community/regexpp": "^4.12.2",
1381- "@typescript-eslint/scope-manager": "8.57.1",
1382- "@typescript-eslint/type-utils": "8.57.1",
1383- "@typescript-eslint/utils": "8.57.1",
1384- "@typescript-eslint/visitor-keys": "8.57.1",
1385- "ignore": "^7.0.5",
1386- "natural-compare": "^1.4.0",
1387- "ts-api-utils": "^2.4.0"
1388- },
1389- "engines": {
1390- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1391- },
1392- "funding": {
1393- "type": "opencollective",
1394- "url": "https://opencollective.com/typescript-eslint"
1395- },
1396- "peerDependencies": {
1397- "@typescript-eslint/parser": "^8.57.1",
1398- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
1399- "typescript": ">=4.8.4 <6.0.0"
1400- }
1401- },
1402- "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
1403- "version": "7.0.5",
1404- "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
1405- "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
1406- "dev": true,
1407- "license": "MIT",
1408- "engines": {
1409- "node": ">= 4"
1410- }
1411- },
1412- "node_modules/@typescript-eslint/parser": {
1413- "version": "8.57.1",
1414- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz",
1415- "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
1416- "dev": true,
1417- "license": "MIT",
1418- "peer": true,
1419- "dependencies": {
1420- "@typescript-eslint/scope-manager": "8.57.1",
1421- "@typescript-eslint/types": "8.57.1",
1422- "@typescript-eslint/typescript-estree": "8.57.1",
1423- "@typescript-eslint/visitor-keys": "8.57.1",
1424- "debug": "^4.4.3"
1425- },
1426- "engines": {
1427- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1428- },
1429- "funding": {
1430- "type": "opencollective",
1431- "url": "https://opencollective.com/typescript-eslint"
1432- },
1433- "peerDependencies": {
1434- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
1435- "typescript": ">=4.8.4 <6.0.0"
1436- }
1437- },
1438- "node_modules/@typescript-eslint/project-service": {
1439- "version": "8.57.1",
1440- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz",
1441- "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==",
1442- "dev": true,
1443- "license": "MIT",
1444- "dependencies": {
1445- "@typescript-eslint/tsconfig-utils": "^8.57.1",
1446- "@typescript-eslint/types": "^8.57.1",
1447- "debug": "^4.4.3"
1448- },
1449- "engines": {
1450- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1451- },
1452- "funding": {
1453- "type": "opencollective",
1454- "url": "https://opencollective.com/typescript-eslint"
1455- },
1456- "peerDependencies": {
1457- "typescript": ">=4.8.4 <6.0.0"
1458- }
1459- },
1460- "node_modules/@typescript-eslint/scope-manager": {
1461- "version": "8.57.1",
1462- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz",
1463- "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==",
1464- "dev": true,
1465- "license": "MIT",
1466- "dependencies": {
1467- "@typescript-eslint/types": "8.57.1",
1468- "@typescript-eslint/visitor-keys": "8.57.1"
1469- },
1470- "engines": {
1471- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1472- },
1473- "funding": {
1474- "type": "opencollective",
1475- "url": "https://opencollective.com/typescript-eslint"
1476- }
1477- },
1478- "node_modules/@typescript-eslint/tsconfig-utils": {
1479- "version": "8.57.1",
1480- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz",
1481- "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==",
1482- "dev": true,
1483- "license": "MIT",
1484- "engines": {
1485- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1486- },
1487- "funding": {
1488- "type": "opencollective",
1489- "url": "https://opencollective.com/typescript-eslint"
1490- },
1491- "peerDependencies": {
1492- "typescript": ">=4.8.4 <6.0.0"
1493- }
1494- },
1495- "node_modules/@typescript-eslint/type-utils": {
1496- "version": "8.57.1",
1497- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz",
1498- "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==",
1499- "dev": true,
1500- "license": "MIT",
1501- "dependencies": {
1502- "@typescript-eslint/types": "8.57.1",
1503- "@typescript-eslint/typescript-estree": "8.57.1",
1504- "@typescript-eslint/utils": "8.57.1",
1505- "debug": "^4.4.3",
1506- "ts-api-utils": "^2.4.0"
1507- },
1508- "engines": {
1509- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1510- },
1511- "funding": {
1512- "type": "opencollective",
1513- "url": "https://opencollective.com/typescript-eslint"
1514- },
1515- "peerDependencies": {
1516- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
1517- "typescript": ">=4.8.4 <6.0.0"
1518- }
1519- },
1520- "node_modules/@typescript-eslint/types": {
1521- "version": "8.57.1",
1522- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz",
1523- "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==",
1524- "dev": true,
1525- "license": "MIT",
1526- "engines": {
1527- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1528- },
1529- "funding": {
1530- "type": "opencollective",
1531- "url": "https://opencollective.com/typescript-eslint"
1532- }
1533- },
1534- "node_modules/@typescript-eslint/typescript-estree": {
1535- "version": "8.57.1",
1536- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz",
1537- "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==",
1538- "dev": true,
1539- "license": "MIT",
1540- "dependencies": {
1541- "@typescript-eslint/project-service": "8.57.1",
1542- "@typescript-eslint/tsconfig-utils": "8.57.1",
1543- "@typescript-eslint/types": "8.57.1",
1544- "@typescript-eslint/visitor-keys": "8.57.1",
1545- "debug": "^4.4.3",
1546- "minimatch": "^10.2.2",
1547- "semver": "^7.7.3",
1548- "tinyglobby": "^0.2.15",
1549- "ts-api-utils": "^2.4.0"
1550- },
1551- "engines": {
1552- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1553- },
1554- "funding": {
1555- "type": "opencollective",
1556- "url": "https://opencollective.com/typescript-eslint"
1557- },
1558- "peerDependencies": {
1559- "typescript": ">=4.8.4 <6.0.0"
1560- }
1561- },
1562- "node_modules/@typescript-eslint/utils": {
1563- "version": "8.57.1",
1564- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz",
1565- "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==",
1566- "dev": true,
1567- "license": "MIT",
1568- "dependencies": {
1569- "@eslint-community/eslint-utils": "^4.9.1",
1570- "@typescript-eslint/scope-manager": "8.57.1",
1571- "@typescript-eslint/types": "8.57.1",
1572- "@typescript-eslint/typescript-estree": "8.57.1"
1573- },
1574- "engines": {
1575- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1576- },
1577- "funding": {
1578- "type": "opencollective",
1579- "url": "https://opencollective.com/typescript-eslint"
1580- },
1581- "peerDependencies": {
1582- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
1583- "typescript": ">=4.8.4 <6.0.0"
1584- }
1585- },
1586- "node_modules/@typescript-eslint/visitor-keys": {
1587- "version": "8.57.1",
1588- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz",
1589- "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==",
1590- "dev": true,
1591- "license": "MIT",
1592- "dependencies": {
1593- "@typescript-eslint/types": "8.57.1",
1594- "eslint-visitor-keys": "^5.0.0"
1595- },
1596- "engines": {
1597- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1598- },
1599- "funding": {
1600- "type": "opencollective",
1601- "url": "https://opencollective.com/typescript-eslint"
1602- }
1603- },
1604- "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
1605- "version": "5.0.1",
1606- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
1607- "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
1608- "dev": true,
1609- "license": "Apache-2.0",
1610- "engines": {
1611- "node": "^20.19.0 || ^22.13.0 || >=24"
1612- },
1613- "funding": {
1614- "url": "https://opencollective.com/eslint"
1615- }
1616- },
1617- "node_modules/acorn": {
1618- "version": "8.16.0",
1619- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
1620- "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
1621- "license": "MIT",
1622- "peer": true,
1623- "bin": {
1624- "acorn": "bin/acorn"
1625- },
1626- "engines": {
1627- "node": ">=0.4.0"
1628- }
1629- },
1630- "node_modules/acorn-jsx": {
1631- "version": "5.3.2",
1632- "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
1633- "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
1634- "dev": true,
1635- "license": "MIT",
1636- "peerDependencies": {
1637- "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
1638- }
1639- },
1640- "node_modules/ajv": {
1641- "version": "6.14.0",
1642- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
1643- "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
1644- "dev": true,
1645- "license": "MIT",
1646- "dependencies": {
1647- "fast-deep-equal": "^3.1.1",
1648- "fast-json-stable-stringify": "^2.0.0",
1649- "json-schema-traverse": "^0.4.1",
1650- "uri-js": "^4.2.2"
1651- },
1652- "funding": {
1653- "type": "github",
1654- "url": "https://github.com/sponsors/epoberezkin"
1655- }
1656- },
1657- "node_modules/aria-query": {
1658- "version": "5.3.1",
1659- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
1660- "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
1661- "license": "Apache-2.0",
1662- "engines": {
1663- "node": ">= 0.4"
1664- }
1665- },
1666- "node_modules/autoprefixer": {
1667- "version": "10.4.27",
1668- "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
1669- "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
1670- "dev": true,
1671- "funding": [
1672- {
1673- "type": "opencollective",
1674- "url": "https://opencollective.com/postcss/"
1675- },
1676- {
1677- "type": "tidelift",
1678- "url": "https://tidelift.com/funding/github/npm/autoprefixer"
1679- },
1680- {
1681- "type": "github",
1682- "url": "https://github.com/sponsors/ai"
1683- }
1684- ],
1685- "license": "MIT",
1686- "dependencies": {
1687- "browserslist": "^4.28.1",
1688- "caniuse-lite": "^1.0.30001774",
1689- "fraction.js": "^5.3.4",
1690- "picocolors": "^1.1.1",
1691- "postcss-value-parser": "^4.2.0"
1692- },
1693- "bin": {
1694- "autoprefixer": "bin/autoprefixer"
1695- },
1696- "engines": {
1697- "node": "^10 || ^12 || >=14"
1698- },
1699- "peerDependencies": {
1700- "postcss": "^8.1.0"
1701- }
1702- },
1703- "node_modules/axobject-query": {
1704- "version": "4.1.0",
1705- "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
1706- "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
1707- "license": "Apache-2.0",
1708- "engines": {
1709- "node": ">= 0.4"
1710- }
1711- },
1712- "node_modules/balanced-match": {
1713- "version": "4.0.4",
1714- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
1715- "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
1716- "dev": true,
1717- "license": "MIT",
1718- "engines": {
1719- "node": "18 || 20 || >=22"
1720- }
1721- },
1722- "node_modules/baseline-browser-mapping": {
1723- "version": "2.9.17",
1724- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz",
1725- "integrity": "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==",
1726- "dev": true,
1727- "license": "Apache-2.0",
1728- "bin": {
1729- "baseline-browser-mapping": "dist/cli.js"
1730- }
1731- },
1732- "node_modules/bits-ui": {
1733- "version": "2.16.3",
1734- "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.16.3.tgz",
1735- "integrity": "sha512-5hJ5dEhf5yPzkRFcxzgQHScGodeo0gK0MUUXrdLlRHWaBOBGZiacWLG96j/wwFatKwZvouw7q+sn14i0fx3RIg==",
1736- "license": "MIT",
1737- "dependencies": {
1738- "@floating-ui/core": "^1.7.1",
1739- "@floating-ui/dom": "^1.7.1",
1740- "esm-env": "^1.1.2",
1741- "runed": "^0.35.1",
1742- "svelte-toolbelt": "^0.10.6",
1743- "tabbable": "^6.2.0"
1744- },
1745- "engines": {
1746- "node": ">=20"
1747- },
1748- "funding": {
1749- "url": "https://github.com/sponsors/huntabyte"
1750- },
1751- "peerDependencies": {
1752- "@internationalized/date": "^3.8.1",
1753- "svelte": "^5.33.0"
1754- }
1755- },
1756- "node_modules/brace-expansion": {
1757- "version": "5.0.3",
1758- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
1759- "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
1760- "dev": true,
1761- "license": "MIT",
1762- "dependencies": {
1763- "balanced-match": "^4.0.2"
1764- },
1765- "engines": {
1766- "node": "18 || 20 || >=22"
1767- }
1768- },
1769- "node_modules/browserslist": {
1770- "version": "4.28.1",
1771- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
1772- "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
1773- "dev": true,
1774- "funding": [
1775- {
1776- "type": "opencollective",
1777- "url": "https://opencollective.com/browserslist"
1778- },
1779- {
1780- "type": "tidelift",
1781- "url": "https://tidelift.com/funding/github/npm/browserslist"
1782- },
1783- {
1784- "type": "github",
1785- "url": "https://github.com/sponsors/ai"
1786- }
1787- ],
1788- "license": "MIT",
1789- "peer": true,
1790- "dependencies": {
1791- "baseline-browser-mapping": "^2.9.0",
1792- "caniuse-lite": "^1.0.30001759",
1793- "electron-to-chromium": "^1.5.263",
1794- "node-releases": "^2.0.27",
1795- "update-browserslist-db": "^1.2.0"
1796- },
1797- "bin": {
1798- "browserslist": "cli.js"
1799- },
1800- "engines": {
1801- "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1802- }
1803- },
1804- "node_modules/caniuse-lite": {
1805- "version": "1.0.30001776",
1806- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz",
1807- "integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==",
1808- "dev": true,
1809- "funding": [
1810- {
1811- "type": "opencollective",
1812- "url": "https://opencollective.com/browserslist"
1813- },
1814- {
1815- "type": "tidelift",
1816- "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1817- },
1818- {
1819- "type": "github",
1820- "url": "https://github.com/sponsors/ai"
1821- }
1822- ],
1823- "license": "CC-BY-4.0"
1824- },
1825- "node_modules/chokidar": {
1826- "version": "4.0.3",
1827- "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
1828- "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
1829- "dev": true,
1830- "license": "MIT",
1831- "dependencies": {
1832- "readdirp": "^4.0.1"
1833- },
1834- "engines": {
1835- "node": ">= 14.16.0"
1836- },
1837- "funding": {
1838- "url": "https://paulmillr.com/funding/"
1839- }
1840- },
1841- "node_modules/clsx": {
1842- "version": "2.1.1",
1843- "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
1844- "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
1845- "license": "MIT",
1846- "engines": {
1847- "node": ">=6"
1848- }
1849- },
1850- "node_modules/colord": {
1851- "version": "2.9.3",
1852- "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
1853- "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
1854- "dev": true,
1855- "license": "MIT"
1856- },
1857- "node_modules/cross-spawn": {
1858- "version": "7.0.6",
1859- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
1860- "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
1861- "dev": true,
1862- "license": "MIT",
1863- "dependencies": {
1864- "path-key": "^3.1.0",
1865- "shebang-command": "^2.0.0",
1866- "which": "^2.0.1"
1867- },
1868- "engines": {
1869- "node": ">= 8"
1870- }
1871- },
1872- "node_modules/cssesc": {
1873- "version": "3.0.0",
1874- "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
1875- "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
1876- "dev": true,
1877- "license": "MIT",
1878- "bin": {
1879- "cssesc": "bin/cssesc"
1880- },
1881- "engines": {
1882- "node": ">=4"
1883- }
1884- },
1885- "node_modules/debug": {
1886- "version": "4.4.3",
1887- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1888- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1889- "dev": true,
1890- "license": "MIT",
1891- "dependencies": {
1892- "ms": "^2.1.3"
1893- },
1894- "engines": {
1895- "node": ">=6.0"
1896- },
1897- "peerDependenciesMeta": {
1898- "supports-color": {
1899- "optional": true
1900- }
1901- }
1902- },
1903- "node_modules/deep-is": {
1904- "version": "0.1.4",
1905- "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
1906- "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
1907- "dev": true,
1908- "license": "MIT"
1909- },
1910- "node_modules/deepmerge": {
1911- "version": "4.3.1",
1912- "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
1913- "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
1914- "dev": true,
1915- "license": "MIT",
1916- "engines": {
1917- "node": ">=0.10.0"
1918- }
1919- },
1920- "node_modules/dequal": {
1921- "version": "2.0.3",
1922- "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
1923- "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
1924- "license": "MIT",
1925- "engines": {
1926- "node": ">=6"
1927- }
1928- },
1929- "node_modules/detect-libc": {
1930- "version": "2.1.2",
1931- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
1932- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
1933- "dev": true,
1934- "license": "Apache-2.0",
1935- "engines": {
1936- "node": ">=8"
1937- }
1938- },
1939- "node_modules/devalue": {
1940- "version": "5.6.4",
1941- "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
1942- "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
1943- "license": "MIT"
1944- },
1945- "node_modules/electron-to-chromium": {
1946- "version": "1.5.277",
1947- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.277.tgz",
1948- "integrity": "sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw==",
1949- "dev": true,
1950- "license": "ISC"
1951- },
1952- "node_modules/enhanced-resolve": {
1953- "version": "5.19.0",
1954- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
1955- "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
1956- "dev": true,
1957- "license": "MIT",
1958- "dependencies": {
1959- "graceful-fs": "^4.2.4",
1960- "tapable": "^2.3.0"
1961- },
1962- "engines": {
1963- "node": ">=10.13.0"
1964- }
1965- },
1966- "node_modules/escalade": {
1967- "version": "3.2.0",
1968- "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1969- "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1970- "dev": true,
1971- "license": "MIT",
1972- "engines": {
1973- "node": ">=6"
1974- }
1975- },
1976- "node_modules/escape-string-regexp": {
1977- "version": "4.0.0",
1978- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
1979- "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
1980- "dev": true,
1981- "license": "MIT",
1982- "engines": {
1983- "node": ">=10"
1984- },
1985- "funding": {
1986- "url": "https://github.com/sponsors/sindresorhus"
1987- }
1988- },
1989- "node_modules/eslint": {
1990- "version": "10.0.3",
1991- "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz",
1992- "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
1993- "dev": true,
1994- "license": "MIT",
1995- "peer": true,
1996- "dependencies": {
1997- "@eslint-community/eslint-utils": "^4.8.0",
1998- "@eslint-community/regexpp": "^4.12.2",
1999- "@eslint/config-array": "^0.23.3",
2000- "@eslint/config-helpers": "^0.5.2",
2001- "@eslint/core": "^1.1.1",
2002- "@eslint/plugin-kit": "^0.6.1",
2003- "@humanfs/node": "^0.16.6",
2004- "@humanwhocodes/module-importer": "^1.0.1",
2005- "@humanwhocodes/retry": "^0.4.2",
2006- "@types/estree": "^1.0.6",
2007- "ajv": "^6.14.0",
2008- "cross-spawn": "^7.0.6",
2009- "debug": "^4.3.2",
2010- "escape-string-regexp": "^4.0.0",
2011- "eslint-scope": "^9.1.2",
2012- "eslint-visitor-keys": "^5.0.1",
2013- "espree": "^11.1.1",
2014- "esquery": "^1.7.0",
2015- "esutils": "^2.0.2",
2016- "fast-deep-equal": "^3.1.3",
2017- "file-entry-cache": "^8.0.0",
2018- "find-up": "^5.0.0",
2019- "glob-parent": "^6.0.2",
2020- "ignore": "^5.2.0",
2021- "imurmurhash": "^0.1.4",
2022- "is-glob": "^4.0.0",
2023- "json-stable-stringify-without-jsonify": "^1.0.1",
2024- "minimatch": "^10.2.4",
2025- "natural-compare": "^1.4.0",
2026- "optionator": "^0.9.3"
2027- },
2028- "bin": {
2029- "eslint": "bin/eslint.js"
2030- },
2031- "engines": {
2032- "node": "^20.19.0 || ^22.13.0 || >=24"
2033- },
2034- "funding": {
2035- "url": "https://eslint.org/donate"
2036- },
2037- "peerDependencies": {
2038- "jiti": "*"
2039- },
2040- "peerDependenciesMeta": {
2041- "jiti": {
2042- "optional": true
2043- }
2044- }
2045- },
2046- "node_modules/eslint-plugin-svelte": {
2047- "version": "3.15.2",
2048- "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.15.2.tgz",
2049- "integrity": "sha512-k4Nsjs3bHujeEnnckoTM4mFYR1e8Mb9l2rTwNdmYiamA+Tjzn8X+2F+fuSP2w4VbXYhn2bmySyACQYdmUDW2Cg==",
2050- "dev": true,
2051- "license": "MIT",
2052- "dependencies": {
2053- "@eslint-community/eslint-utils": "^4.6.1",
2054- "@jridgewell/sourcemap-codec": "^1.5.0",
2055- "esutils": "^2.0.3",
2056- "globals": "^16.0.0",
2057- "known-css-properties": "^0.37.0",
2058- "postcss": "^8.4.49",
2059- "postcss-load-config": "^3.1.4",
2060- "postcss-safe-parser": "^7.0.0",
2061- "semver": "^7.6.3",
2062- "svelte-eslint-parser": "^1.4.0"
2063- },
2064- "engines": {
2065- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2066- },
2067- "funding": {
2068- "url": "https://github.com/sponsors/ota-meshi"
2069- },
2070- "peerDependencies": {
2071- "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0",
2072- "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0"
2073- },
2074- "peerDependenciesMeta": {
2075- "svelte": {
2076- "optional": true
2077- }
2078- }
2079- },
2080- "node_modules/eslint-plugin-svelte/node_modules/globals": {
2081- "version": "16.5.0",
2082- "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
2083- "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
2084- "dev": true,
2085- "license": "MIT",
2086- "engines": {
2087- "node": ">=18"
2088- },
2089- "funding": {
2090- "url": "https://github.com/sponsors/sindresorhus"
2091- }
2092- },
2093- "node_modules/eslint-scope": {
2094- "version": "8.4.0",
2095- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
2096- "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
2097- "dev": true,
2098- "license": "BSD-2-Clause",
2099- "dependencies": {
2100- "esrecurse": "^4.3.0",
2101- "estraverse": "^5.2.0"
2102- },
2103- "engines": {
2104- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2105- },
2106- "funding": {
2107- "url": "https://opencollective.com/eslint"
2108- }
2109- },
2110- "node_modules/eslint-visitor-keys": {
2111- "version": "3.4.3",
2112- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
2113- "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
2114- "dev": true,
2115- "license": "Apache-2.0",
2116- "engines": {
2117- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
2118- },
2119- "funding": {
2120- "url": "https://opencollective.com/eslint"
2121- }
2122- },
2123- "node_modules/eslint/node_modules/eslint-scope": {
2124- "version": "9.1.2",
2125- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
2126- "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
2127- "dev": true,
2128- "license": "BSD-2-Clause",
2129- "dependencies": {
2130- "@types/esrecurse": "^4.3.1",
2131- "@types/estree": "^1.0.8",
2132- "esrecurse": "^4.3.0",
2133- "estraverse": "^5.2.0"
2134- },
2135- "engines": {
2136- "node": "^20.19.0 || ^22.13.0 || >=24"
2137- },
2138- "funding": {
2139- "url": "https://opencollective.com/eslint"
2140- }
2141- },
2142- "node_modules/eslint/node_modules/eslint-visitor-keys": {
2143- "version": "5.0.1",
2144- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
2145- "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
2146- "dev": true,
2147- "license": "Apache-2.0",
2148- "engines": {
2149- "node": "^20.19.0 || ^22.13.0 || >=24"
2150- },
2151- "funding": {
2152- "url": "https://opencollective.com/eslint"
2153- }
2154- },
2155- "node_modules/eslint/node_modules/espree": {
2156- "version": "11.1.1",
2157- "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz",
2158- "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==",
2159- "dev": true,
2160- "license": "BSD-2-Clause",
2161- "dependencies": {
2162- "acorn": "^8.16.0",
2163- "acorn-jsx": "^5.3.2",
2164- "eslint-visitor-keys": "^5.0.1"
2165- },
2166- "engines": {
2167- "node": "^20.19.0 || ^22.13.0 || >=24"
2168- },
2169- "funding": {
2170- "url": "https://opencollective.com/eslint"
2171- }
2172- },
2173- "node_modules/esm-env": {
2174- "version": "1.2.2",
2175- "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
2176- "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
2177- "license": "MIT"
2178- },
2179- "node_modules/espree": {
2180- "version": "10.4.0",
2181- "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
2182- "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
2183- "dev": true,
2184- "license": "BSD-2-Clause",
2185- "dependencies": {
2186- "acorn": "^8.15.0",
2187- "acorn-jsx": "^5.3.2",
2188- "eslint-visitor-keys": "^4.2.1"
2189- },
2190- "engines": {
2191- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2192- },
2193- "funding": {
2194- "url": "https://opencollective.com/eslint"
2195- }
2196- },
2197- "node_modules/espree/node_modules/eslint-visitor-keys": {
2198- "version": "4.2.1",
2199- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
2200- "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
2201- "dev": true,
2202- "license": "Apache-2.0",
2203- "engines": {
2204- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
2205- },
2206- "funding": {
2207- "url": "https://opencollective.com/eslint"
2208- }
2209- },
2210- "node_modules/esquery": {
2211- "version": "1.7.0",
2212- "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
2213- "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
2214- "dev": true,
2215- "license": "BSD-3-Clause",
2216- "dependencies": {
2217- "estraverse": "^5.1.0"
2218- },
2219- "engines": {
2220- "node": ">=0.10"
2221- }
2222- },
2223- "node_modules/esrap": {
2224- "version": "2.2.2",
2225- "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
2226- "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
2227- "license": "MIT",
2228- "dependencies": {
2229- "@jridgewell/sourcemap-codec": "^1.4.15"
2230- }
2231- },
2232- "node_modules/esrecurse": {
2233- "version": "4.3.0",
2234- "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
2235- "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
2236- "dev": true,
2237- "license": "BSD-2-Clause",
2238- "dependencies": {
2239- "estraverse": "^5.2.0"
2240- },
2241- "engines": {
2242- "node": ">=4.0"
2243- }
2244- },
2245- "node_modules/estraverse": {
2246- "version": "5.3.0",
2247- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
2248- "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
2249- "dev": true,
2250- "license": "BSD-2-Clause",
2251- "engines": {
2252- "node": ">=4.0"
2253- }
2254- },
2255- "node_modules/esutils": {
2256- "version": "2.0.3",
2257- "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
2258- "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
2259- "dev": true,
2260- "license": "BSD-2-Clause",
2261- "engines": {
2262- "node": ">=0.10.0"
2263- }
2264- },
2265- "node_modules/fast-deep-equal": {
2266- "version": "3.1.3",
2267- "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
2268- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
2269- "dev": true,
2270- "license": "MIT"
2271- },
2272- "node_modules/fast-json-stable-stringify": {
2273- "version": "2.1.0",
2274- "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
2275- "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
2276- "dev": true,
2277- "license": "MIT"
2278- },
2279- "node_modules/fast-levenshtein": {
2280- "version": "2.0.6",
2281- "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
2282- "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
2283- "dev": true,
2284- "license": "MIT"
2285- },
2286- "node_modules/fdir": {
2287- "version": "6.5.0",
2288- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
2289- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
2290- "dev": true,
2291- "license": "MIT",
2292- "engines": {
2293- "node": ">=12.0.0"
2294- },
2295- "peerDependencies": {
2296- "picomatch": "^3 || ^4"
2297- },
2298- "peerDependenciesMeta": {
2299- "picomatch": {
2300- "optional": true
2301- }
2302- }
2303- },
2304- "node_modules/file-entry-cache": {
2305- "version": "8.0.0",
2306- "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
2307- "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
2308- "dev": true,
2309- "license": "MIT",
2310- "dependencies": {
2311- "flat-cache": "^4.0.0"
2312- },
2313- "engines": {
2314- "node": ">=16.0.0"
2315- }
2316- },
2317- "node_modules/find-up": {
2318- "version": "5.0.0",
2319- "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
2320- "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
2321- "dev": true,
2322- "license": "MIT",
2323- "dependencies": {
2324- "locate-path": "^6.0.0",
2325- "path-exists": "^4.0.0"
2326- },
2327- "engines": {
2328- "node": ">=10"
2329- },
2330- "funding": {
2331- "url": "https://github.com/sponsors/sindresorhus"
2332- }
2333- },
2334- "node_modules/flat-cache": {
2335- "version": "4.0.1",
2336- "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
2337- "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
2338- "dev": true,
2339- "license": "MIT",
2340- "dependencies": {
2341- "flatted": "^3.2.9",
2342- "keyv": "^4.5.4"
2343- },
2344- "engines": {
2345- "node": ">=16"
2346- }
2347- },
2348- "node_modules/flatted": {
2349- "version": "3.3.3",
2350- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
2351- "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
2352- "dev": true,
2353- "license": "ISC"
2354- },
2355- "node_modules/fraction.js": {
2356- "version": "5.3.4",
2357- "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
2358- "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
2359- "dev": true,
2360- "license": "MIT",
2361- "engines": {
2362- "node": "*"
2363- },
2364- "funding": {
2365- "type": "github",
2366- "url": "https://github.com/sponsors/rawify"
2367- }
2368- },
2369- "node_modules/frappe-charts": {
2370- "version": "1.6.2",
2371- "resolved": "https://registry.npmjs.org/frappe-charts/-/frappe-charts-1.6.2.tgz",
2372- "integrity": "sha512-9TC3/+YVUi84yYoEbxFiSqu+1FQ5If/ydUNj6i8FRpwynd08t6a7RkS+IRJozAk6NfdL8/LVTTE1DUOjjKZZxg==",
2373- "license": "MIT"
2374- },
2375- "node_modules/fsevents": {
2376- "version": "2.3.3",
2377- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
2378- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
2379- "dev": true,
2380- "hasInstallScript": true,
2381- "license": "MIT",
2382- "optional": true,
2383- "os": [
2384- "darwin"
2385- ],
2386- "engines": {
2387- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
2388- }
2389- },
2390- "node_modules/glob-parent": {
2391- "version": "6.0.2",
2392- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
2393- "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
2394- "dev": true,
2395- "license": "ISC",
2396- "dependencies": {
2397- "is-glob": "^4.0.3"
2398- },
2399- "engines": {
2400- "node": ">=10.13.0"
2401- }
2402- },
2403- "node_modules/globals": {
2404- "version": "17.4.0",
2405- "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz",
2406- "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==",
2407- "dev": true,
2408- "license": "MIT",
2409- "engines": {
2410- "node": ">=18"
2411- },
2412- "funding": {
2413- "url": "https://github.com/sponsors/sindresorhus"
2414- }
2415- },
2416- "node_modules/graceful-fs": {
2417- "version": "4.2.11",
2418- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
2419- "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
2420- "dev": true,
2421- "license": "ISC"
2422- },
2423- "node_modules/ignore": {
2424- "version": "5.3.2",
2425- "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
2426- "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
2427- "dev": true,
2428- "license": "MIT",
2429- "engines": {
2430- "node": ">= 4"
2431- }
2432- },
2433- "node_modules/imurmurhash": {
2434- "version": "0.1.4",
2435- "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
2436- "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
2437- "dev": true,
2438- "license": "MIT",
2439- "engines": {
2440- "node": ">=0.8.19"
2441- }
2442- },
2443- "node_modules/inline-style-parser": {
2444- "version": "0.2.7",
2445- "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
2446- "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
2447- "license": "MIT"
2448- },
2449- "node_modules/is-extglob": {
2450- "version": "2.1.1",
2451- "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
2452- "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
2453- "dev": true,
2454- "license": "MIT",
2455- "engines": {
2456- "node": ">=0.10.0"
2457- }
2458- },
2459- "node_modules/is-glob": {
2460- "version": "4.0.3",
2461- "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
2462- "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
2463- "dev": true,
2464- "license": "MIT",
2465- "dependencies": {
2466- "is-extglob": "^2.1.1"
2467- },
2468- "engines": {
2469- "node": ">=0.10.0"
2470- }
2471- },
2472- "node_modules/is-reference": {
2473- "version": "3.0.3",
2474- "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
2475- "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
2476- "license": "MIT",
2477- "dependencies": {
2478- "@types/estree": "^1.0.6"
2479- }
2480- },
2481- "node_modules/isexe": {
2482- "version": "2.0.0",
2483- "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
2484- "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
2485- "dev": true,
2486- "license": "ISC"
2487- },
2488- "node_modules/jiti": {
2489- "version": "2.6.1",
2490- "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
2491- "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
2492- "dev": true,
2493- "license": "MIT",
2494- "bin": {
2495- "jiti": "lib/jiti-cli.mjs"
2496- }
2497- },
2498- "node_modules/json-buffer": {
2499- "version": "3.0.1",
2500- "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
2501- "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
2502- "dev": true,
2503- "license": "MIT"
2504- },
2505- "node_modules/json-schema-traverse": {
2506- "version": "0.4.1",
2507- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
2508- "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
2509- "dev": true,
2510- "license": "MIT"
2511- },
2512- "node_modules/json-stable-stringify-without-jsonify": {
2513- "version": "1.0.1",
2514- "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
2515- "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
2516- "dev": true,
2517- "license": "MIT"
2518- },
2519- "node_modules/keyv": {
2520- "version": "4.5.4",
2521- "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
2522- "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
2523- "dev": true,
2524- "license": "MIT",
2525- "dependencies": {
2526- "json-buffer": "3.0.1"
2527- }
2528- },
2529- "node_modules/known-css-properties": {
2530- "version": "0.37.0",
2531- "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz",
2532- "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==",
2533- "dev": true,
2534- "license": "MIT"
2535- },
2536- "node_modules/levn": {
2537- "version": "0.4.1",
2538- "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
2539- "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
2540- "dev": true,
2541- "license": "MIT",
2542- "dependencies": {
2543- "prelude-ls": "^1.2.1",
2544- "type-check": "~0.4.0"
2545- },
2546- "engines": {
2547- "node": ">= 0.8.0"
2548- }
2549- },
2550- "node_modules/lightningcss": {
2551- "version": "1.32.0",
2552- "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
2553- "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
2554- "dev": true,
2555- "license": "MPL-2.0",
2556- "dependencies": {
2557- "detect-libc": "^2.0.3"
2558- },
2559- "engines": {
2560- "node": ">= 12.0.0"
2561- },
2562- "funding": {
2563- "type": "opencollective",
2564- "url": "https://opencollective.com/parcel"
2565- },
2566- "optionalDependencies": {
2567- "lightningcss-android-arm64": "1.32.0",
2568- "lightningcss-darwin-arm64": "1.32.0",
2569- "lightningcss-darwin-x64": "1.32.0",
2570- "lightningcss-freebsd-x64": "1.32.0",
2571- "lightningcss-linux-arm-gnueabihf": "1.32.0",
2572- "lightningcss-linux-arm64-gnu": "1.32.0",
2573- "lightningcss-linux-arm64-musl": "1.32.0",
2574- "lightningcss-linux-x64-gnu": "1.32.0",
2575- "lightningcss-linux-x64-musl": "1.32.0",
2576- "lightningcss-win32-arm64-msvc": "1.32.0",
2577- "lightningcss-win32-x64-msvc": "1.32.0"
2578- }
2579- },
2580- "node_modules/lightningcss-android-arm64": {
2581- "version": "1.32.0",
2582- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
2583- "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
2584- "cpu": [
2585- "arm64"
2586- ],
2587- "dev": true,
2588- "license": "MPL-2.0",
2589- "optional": true,
2590- "os": [
2591- "android"
2592- ],
2593- "engines": {
2594- "node": ">= 12.0.0"
2595- },
2596- "funding": {
2597- "type": "opencollective",
2598- "url": "https://opencollective.com/parcel"
2599- }
2600- },
2601- "node_modules/lightningcss-darwin-arm64": {
2602- "version": "1.32.0",
2603- "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
2604- "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
2605- "cpu": [
2606- "arm64"
2607- ],
2608- "dev": true,
2609- "license": "MPL-2.0",
2610- "optional": true,
2611- "os": [
2612- "darwin"
2613- ],
2614- "engines": {
2615- "node": ">= 12.0.0"
2616- },
2617- "funding": {
2618- "type": "opencollective",
2619- "url": "https://opencollective.com/parcel"
2620- }
2621- },
2622- "node_modules/lightningcss-darwin-x64": {
2623- "version": "1.32.0",
2624- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
2625- "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
2626- "cpu": [
2627- "x64"
2628- ],
2629- "dev": true,
2630- "license": "MPL-2.0",
2631- "optional": true,
2632- "os": [
2633- "darwin"
2634- ],
2635- "engines": {
2636- "node": ">= 12.0.0"
2637- },
2638- "funding": {
2639- "type": "opencollective",
2640- "url": "https://opencollective.com/parcel"
2641- }
2642- },
2643- "node_modules/lightningcss-freebsd-x64": {
2644- "version": "1.32.0",
2645- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
2646- "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
2647- "cpu": [
2648- "x64"
2649- ],
2650- "dev": true,
2651- "license": "MPL-2.0",
2652- "optional": true,
2653- "os": [
2654- "freebsd"
2655- ],
2656- "engines": {
2657- "node": ">= 12.0.0"
2658- },
2659- "funding": {
2660- "type": "opencollective",
2661- "url": "https://opencollective.com/parcel"
2662- }
2663- },
2664- "node_modules/lightningcss-linux-arm-gnueabihf": {
2665- "version": "1.32.0",
2666- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
2667- "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
2668- "cpu": [
2669- "arm"
2670- ],
2671- "dev": true,
2672- "license": "MPL-2.0",
2673- "optional": true,
2674- "os": [
2675- "linux"
2676- ],
2677- "engines": {
2678- "node": ">= 12.0.0"
2679- },
2680- "funding": {
2681- "type": "opencollective",
2682- "url": "https://opencollective.com/parcel"
2683- }
2684- },
2685- "node_modules/lightningcss-linux-arm64-gnu": {
2686- "version": "1.32.0",
2687- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
2688- "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
2689- "cpu": [
2690- "arm64"
2691- ],
2692- "dev": true,
2693- "license": "MPL-2.0",
2694- "optional": true,
2695- "os": [
2696- "linux"
2697- ],
2698- "engines": {
2699- "node": ">= 12.0.0"
2700- },
2701- "funding": {
2702- "type": "opencollective",
2703- "url": "https://opencollective.com/parcel"
2704- }
2705- },
2706- "node_modules/lightningcss-linux-arm64-musl": {
2707- "version": "1.32.0",
2708- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
2709- "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
2710- "cpu": [
2711- "arm64"
2712- ],
2713- "dev": true,
2714- "license": "MPL-2.0",
2715- "optional": true,
2716- "os": [
2717- "linux"
2718- ],
2719- "engines": {
2720- "node": ">= 12.0.0"
2721- },
2722- "funding": {
2723- "type": "opencollective",
2724- "url": "https://opencollective.com/parcel"
2725- }
2726- },
2727- "node_modules/lightningcss-linux-x64-gnu": {
2728- "version": "1.32.0",
2729- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
2730- "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
2731- "cpu": [
2732- "x64"
2733- ],
2734- "dev": true,
2735- "license": "MPL-2.0",
2736- "optional": true,
2737- "os": [
2738- "linux"
2739- ],
2740- "engines": {
2741- "node": ">= 12.0.0"
2742- },
2743- "funding": {
2744- "type": "opencollective",
2745- "url": "https://opencollective.com/parcel"
2746- }
2747- },
2748- "node_modules/lightningcss-linux-x64-musl": {
2749- "version": "1.32.0",
2750- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
2751- "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
2752- "cpu": [
2753- "x64"
2754- ],
2755- "dev": true,
2756- "license": "MPL-2.0",
2757- "optional": true,
2758- "os": [
2759- "linux"
2760- ],
2761- "engines": {
2762- "node": ">= 12.0.0"
2763- },
2764- "funding": {
2765- "type": "opencollective",
2766- "url": "https://opencollective.com/parcel"
2767- }
2768- },
2769- "node_modules/lightningcss-win32-arm64-msvc": {
2770- "version": "1.32.0",
2771- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
2772- "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
2773- "cpu": [
2774- "arm64"
2775- ],
2776- "dev": true,
2777- "license": "MPL-2.0",
2778- "optional": true,
2779- "os": [
2780- "win32"
2781- ],
2782- "engines": {
2783- "node": ">= 12.0.0"
2784- },
2785- "funding": {
2786- "type": "opencollective",
2787- "url": "https://opencollective.com/parcel"
2788- }
2789- },
2790- "node_modules/lightningcss-win32-x64-msvc": {
2791- "version": "1.32.0",
2792- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
2793- "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
2794- "cpu": [
2795- "x64"
2796- ],
2797- "dev": true,
2798- "license": "MPL-2.0",
2799- "optional": true,
2800- "os": [
2801- "win32"
2802- ],
2803- "engines": {
2804- "node": ">= 12.0.0"
2805- },
2806- "funding": {
2807- "type": "opencollective",
2808- "url": "https://opencollective.com/parcel"
2809- }
2810- },
2811- "node_modules/lilconfig": {
2812- "version": "2.1.0",
2813- "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
2814- "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
2815- "dev": true,
2816- "license": "MIT",
2817- "engines": {
2818- "node": ">=10"
2819- }
2820- },
2821- "node_modules/locate-character": {
2822- "version": "3.0.0",
2823- "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
2824- "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
2825- "license": "MIT"
2826- },
2827- "node_modules/locate-path": {
2828- "version": "6.0.0",
2829- "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
2830- "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
2831- "dev": true,
2832- "license": "MIT",
2833- "dependencies": {
2834- "p-locate": "^5.0.0"
2835- },
2836- "engines": {
2837- "node": ">=10"
2838- },
2839- "funding": {
2840- "url": "https://github.com/sponsors/sindresorhus"
2841- }
2842- },
2843- "node_modules/lz-string": {
2844- "version": "1.5.0",
2845- "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
2846- "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
2847- "license": "MIT",
2848- "bin": {
2849- "lz-string": "bin/bin.js"
2850- }
2851- },
2852- "node_modules/magic-string": {
2853- "version": "0.30.21",
2854- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
2855- "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
2856- "license": "MIT",
2857- "dependencies": {
2858- "@jridgewell/sourcemap-codec": "^1.5.5"
2859- }
2860- },
2861- "node_modules/minimatch": {
2862- "version": "10.2.4",
2863- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
2864- "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
2865- "dev": true,
2866- "license": "BlueOak-1.0.0",
2867- "dependencies": {
2868- "brace-expansion": "^5.0.2"
2869- },
2870- "engines": {
2871- "node": "18 || 20 || >=22"
2872- },
2873- "funding": {
2874- "url": "https://github.com/sponsors/isaacs"
2875- }
2876- },
2877- "node_modules/mode-watcher": {
2878- "version": "1.1.0",
2879- "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz",
2880- "integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==",
2881- "license": "MIT",
2882- "dependencies": {
2883- "runed": "^0.25.0",
2884- "svelte-toolbelt": "^0.7.1"
2885- },
2886- "peerDependencies": {
2887- "svelte": "^5.27.0"
2888- }
2889- },
2890- "node_modules/mode-watcher/node_modules/runed": {
2891- "version": "0.25.0",
2892- "resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz",
2893- "integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==",
2894- "funding": [
2895- "https://github.com/sponsors/huntabyte",
2896- "https://github.com/sponsors/tglide"
2897- ],
2898- "dependencies": {
2899- "esm-env": "^1.0.0"
2900- },
2901- "peerDependencies": {
2902- "svelte": "^5.7.0"
2903- }
2904- },
2905- "node_modules/mode-watcher/node_modules/svelte-toolbelt": {
2906- "version": "0.7.1",
2907- "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz",
2908- "integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==",
2909- "funding": [
2910- "https://github.com/sponsors/huntabyte"
2911- ],
2912- "dependencies": {
2913- "clsx": "^2.1.1",
2914- "runed": "^0.23.2",
2915- "style-to-object": "^1.0.8"
2916- },
2917- "engines": {
2918- "node": ">=18",
2919- "pnpm": ">=8.7.0"
2920- },
2921- "peerDependencies": {
2922- "svelte": "^5.0.0"
2923- }
2924- },
2925- "node_modules/mode-watcher/node_modules/svelte-toolbelt/node_modules/runed": {
2926- "version": "0.23.4",
2927- "resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
2928- "integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
2929- "funding": [
2930- "https://github.com/sponsors/huntabyte",
2931- "https://github.com/sponsors/tglide"
2932- ],
2933- "dependencies": {
2934- "esm-env": "^1.0.0"
2935- },
2936- "peerDependencies": {
2937- "svelte": "^5.7.0"
2938- }
2939- },
2940- "node_modules/mri": {
2941- "version": "1.2.0",
2942- "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
2943- "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
2944- "dev": true,
2945- "license": "MIT",
2946- "engines": {
2947- "node": ">=4"
2948- }
2949- },
2950- "node_modules/ms": {
2951- "version": "2.1.3",
2952- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
2953- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
2954- "dev": true,
2955- "license": "MIT"
2956- },
2957- "node_modules/nanoid": {
2958- "version": "3.3.11",
2959- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
2960- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
2961- "dev": true,
2962- "funding": [
2963- {
2964- "type": "github",
2965- "url": "https://github.com/sponsors/ai"
2966- }
2967- ],
2968- "license": "MIT",
2969- "bin": {
2970- "nanoid": "bin/nanoid.cjs"
2971- },
2972- "engines": {
2973- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
2974- }
2975- },
2976- "node_modules/natural-compare": {
2977- "version": "1.4.0",
2978- "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
2979- "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
2980- "dev": true,
2981- "license": "MIT"
2982- },
2983- "node_modules/node-addon-api": {
2984- "version": "7.1.1",
2985- "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
2986- "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
2987- "dev": true,
2988- "license": "MIT"
2989- },
2990- "node_modules/node-releases": {
2991- "version": "2.0.27",
2992- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
2993- "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
2994- "dev": true,
2995- "license": "MIT"
2996- },
2997- "node_modules/obug": {
2998- "version": "2.1.1",
2999- "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
3000- "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
3001- "dev": true,
3002- "funding": [
3003- "https://github.com/sponsors/sxzz",
3004- "https://opencollective.com/debug"
3005- ],
3006- "license": "MIT"
3007- },
3008- "node_modules/optionator": {
3009- "version": "0.9.4",
3010- "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
3011- "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
3012- "dev": true,
3013- "license": "MIT",
3014- "dependencies": {
3015- "deep-is": "^0.1.3",
3016- "fast-levenshtein": "^2.0.6",
3017- "levn": "^0.4.1",
3018- "prelude-ls": "^1.2.1",
3019- "type-check": "^0.4.0",
3020- "word-wrap": "^1.2.5"
3021- },
3022- "engines": {
3023- "node": ">= 0.8.0"
3024- }
3025- },
3026- "node_modules/p-limit": {
3027- "version": "3.1.0",
3028- "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
3029- "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
3030- "dev": true,
3031- "license": "MIT",
3032- "dependencies": {
3033- "yocto-queue": "^0.1.0"
3034- },
3035- "engines": {
3036- "node": ">=10"
3037- },
3038- "funding": {
3039- "url": "https://github.com/sponsors/sindresorhus"
3040- }
3041- },
3042- "node_modules/p-locate": {
3043- "version": "5.0.0",
3044- "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
3045- "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
3046- "dev": true,
3047- "license": "MIT",
3048- "dependencies": {
3049- "p-limit": "^3.0.2"
3050- },
3051- "engines": {
3052- "node": ">=10"
3053- },
3054- "funding": {
3055- "url": "https://github.com/sponsors/sindresorhus"
3056- }
3057- },
3058- "node_modules/path-exists": {
3059- "version": "4.0.0",
3060- "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
3061- "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
3062- "dev": true,
3063- "license": "MIT",
3064- "engines": {
3065- "node": ">=8"
3066- }
3067- },
3068- "node_modules/path-key": {
3069- "version": "3.1.1",
3070- "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
3071- "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
3072- "dev": true,
3073- "license": "MIT",
3074- "engines": {
3075- "node": ">=8"
3076- }
3077- },
3078- "node_modules/picocolors": {
3079- "version": "1.1.1",
3080- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
3081- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
3082- "dev": true,
3083- "license": "ISC"
3084- },
3085- "node_modules/picomatch": {
3086- "version": "4.0.3",
3087- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
3088- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
3089- "dev": true,
3090- "license": "MIT",
3091- "engines": {
3092- "node": ">=12"
3093- },
3094- "funding": {
3095- "url": "https://github.com/sponsors/jonschlinkert"
3096- }
3097- },
3098- "node_modules/postcss": {
3099- "version": "8.5.8",
3100- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
3101- "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
3102- "dev": true,
3103- "funding": [
3104- {
3105- "type": "opencollective",
3106- "url": "https://opencollective.com/postcss/"
3107- },
3108- {
3109- "type": "tidelift",
3110- "url": "https://tidelift.com/funding/github/npm/postcss"
3111- },
3112- {
3113- "type": "github",
3114- "url": "https://github.com/sponsors/ai"
3115- }
3116- ],
3117- "license": "MIT",
3118- "peer": true,
3119- "dependencies": {
3120- "nanoid": "^3.3.11",
3121- "picocolors": "^1.1.1",
3122- "source-map-js": "^1.2.1"
3123- },
3124- "engines": {
3125- "node": "^10 || ^12 || >=14"
3126- }
3127- },
3128- "node_modules/postcss-load-config": {
3129- "version": "3.1.4",
3130- "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz",
3131- "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==",
3132- "dev": true,
3133- "license": "MIT",
3134- "dependencies": {
3135- "lilconfig": "^2.0.5",
3136- "yaml": "^1.10.2"
3137- },
3138- "engines": {
3139- "node": ">= 10"
3140- },
3141- "funding": {
3142- "type": "opencollective",
3143- "url": "https://opencollective.com/postcss/"
3144- },
3145- "peerDependencies": {
3146- "postcss": ">=8.0.9",
3147- "ts-node": ">=9.0.0"
3148- },
3149- "peerDependenciesMeta": {
3150- "postcss": {
3151- "optional": true
3152- },
3153- "ts-node": {
3154- "optional": true
3155- }
3156- }
3157- },
3158- "node_modules/postcss-load-config/node_modules/yaml": {
3159- "version": "1.10.2",
3160- "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
3161- "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
3162- "dev": true,
3163- "license": "ISC",
3164- "engines": {
3165- "node": ">= 6"
3166- }
3167- },
3168- "node_modules/postcss-safe-parser": {
3169- "version": "7.0.1",
3170- "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz",
3171- "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==",
3172- "dev": true,
3173- "funding": [
3174- {
3175- "type": "opencollective",
3176- "url": "https://opencollective.com/postcss/"
3177- },
3178- {
3179- "type": "tidelift",
3180- "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser"
3181- },
3182- {
3183- "type": "github",
3184- "url": "https://github.com/sponsors/ai"
3185- }
3186- ],
3187- "license": "MIT",
3188- "engines": {
3189- "node": ">=18.0"
3190- },
3191- "peerDependencies": {
3192- "postcss": "^8.4.31"
3193- }
3194- },
3195- "node_modules/postcss-scss": {
3196- "version": "4.0.9",
3197- "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz",
3198- "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==",
3199- "dev": true,
3200- "funding": [
3201- {
3202- "type": "opencollective",
3203- "url": "https://opencollective.com/postcss/"
3204- },
3205- {
3206- "type": "tidelift",
3207- "url": "https://tidelift.com/funding/github/npm/postcss-scss"
3208- },
3209- {
3210- "type": "github",
3211- "url": "https://github.com/sponsors/ai"
3212- }
3213- ],
3214- "license": "MIT",
3215- "engines": {
3216- "node": ">=12.0"
3217- },
3218- "peerDependencies": {
3219- "postcss": "^8.4.29"
3220- }
3221- },
3222- "node_modules/postcss-selector-parser": {
3223- "version": "7.1.1",
3224- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
3225- "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
3226- "dev": true,
3227- "license": "MIT",
3228- "dependencies": {
3229- "cssesc": "^3.0.0",
3230- "util-deprecate": "^1.0.2"
3231- },
3232- "engines": {
3233- "node": ">=4"
3234- }
3235- },
3236- "node_modules/postcss-value-parser": {
3237- "version": "4.2.0",
3238- "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
3239- "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
3240- "dev": true,
3241- "license": "MIT"
3242- },
3243- "node_modules/prelude-ls": {
3244- "version": "1.2.1",
3245- "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
3246- "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
3247- "dev": true,
3248- "license": "MIT",
3249- "engines": {
3250- "node": ">= 0.8.0"
3251- }
3252- },
3253- "node_modules/punycode": {
3254- "version": "2.3.1",
3255- "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
3256- "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
3257- "dev": true,
3258- "license": "MIT",
3259- "engines": {
3260- "node": ">=6"
3261- }
3262- },
3263- "node_modules/readdirp": {
3264- "version": "4.1.2",
3265- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
3266- "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
3267- "dev": true,
3268- "license": "MIT",
3269- "engines": {
3270- "node": ">= 14.18.0"
3271- },
3272- "funding": {
3273- "type": "individual",
3274- "url": "https://paulmillr.com/funding/"
3275- }
3276- },
3277- "node_modules/rolldown": {
3278- "version": "1.0.0-rc.9",
3279- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
3280- "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
3281- "dev": true,
3282- "license": "MIT",
3283- "dependencies": {
3284- "@oxc-project/types": "=0.115.0",
3285- "@rolldown/pluginutils": "1.0.0-rc.9"
3286- },
3287- "bin": {
3288- "rolldown": "bin/cli.mjs"
3289- },
3290- "engines": {
3291- "node": "^20.19.0 || >=22.12.0"
3292- },
3293- "optionalDependencies": {
3294- "@rolldown/binding-android-arm64": "1.0.0-rc.9",
3295- "@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
3296- "@rolldown/binding-darwin-x64": "1.0.0-rc.9",
3297- "@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
3298- "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
3299- "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
3300- "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
3301- "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
3302- "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
3303- "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
3304- "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
3305- "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
3306- "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
3307- "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
3308- "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
3309- }
3310- },
3311- "node_modules/runed": {
3312- "version": "0.35.1",
3313- "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz",
3314- "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==",
3315- "funding": [
3316- "https://github.com/sponsors/huntabyte",
3317- "https://github.com/sponsors/tglide"
3318- ],
3319- "license": "MIT",
3320- "dependencies": {
3321- "dequal": "^2.0.3",
3322- "esm-env": "^1.0.0",
3323- "lz-string": "^1.5.0"
3324- },
3325- "peerDependencies": {
3326- "@sveltejs/kit": "^2.21.0",
3327- "svelte": "^5.7.0"
3328- },
3329- "peerDependenciesMeta": {
3330- "@sveltejs/kit": {
3331- "optional": true
3332- }
3333- }
3334- },
3335- "node_modules/sade": {
3336- "version": "1.8.1",
3337- "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
3338- "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
3339- "dev": true,
3340- "license": "MIT",
3341- "dependencies": {
3342- "mri": "^1.1.0"
3343- },
3344- "engines": {
3345- "node": ">=6"
3346- }
3347- },
3348- "node_modules/semver": {
3349- "version": "7.7.3",
3350- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
3351- "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
3352- "dev": true,
3353- "license": "ISC",
3354- "bin": {
3355- "semver": "bin/semver.js"
3356- },
3357- "engines": {
3358- "node": ">=10"
3359- }
3360- },
3361- "node_modules/shebang-command": {
3362- "version": "2.0.0",
3363- "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
3364- "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
3365- "dev": true,
3366- "license": "MIT",
3367- "dependencies": {
3368- "shebang-regex": "^3.0.0"
3369- },
3370- "engines": {
3371- "node": ">=8"
3372- }
3373- },
3374- "node_modules/shebang-regex": {
3375- "version": "3.0.0",
3376- "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
3377- "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
3378- "dev": true,
3379- "license": "MIT",
3380- "engines": {
3381- "node": ">=8"
3382- }
3383- },
3384- "node_modules/source-map-js": {
3385- "version": "1.2.1",
3386- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
3387- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
3388- "dev": true,
3389- "license": "BSD-3-Clause",
3390- "engines": {
3391- "node": ">=0.10.0"
3392- }
3393- },
3394- "node_modules/style-to-object": {
3395- "version": "1.0.14",
3396- "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
3397- "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
3398- "license": "MIT",
3399- "dependencies": {
3400- "inline-style-parser": "0.2.7"
3401- }
3402- },
3403- "node_modules/svelte": {
3404- "version": "5.54.0",
3405- "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.54.0.tgz",
3406- "integrity": "sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==",
3407- "license": "MIT",
3408- "peer": true,
3409- "dependencies": {
3410- "@jridgewell/remapping": "^2.3.4",
3411- "@jridgewell/sourcemap-codec": "^1.5.0",
3412- "@sveltejs/acorn-typescript": "^1.0.5",
3413- "@types/estree": "^1.0.5",
3414- "@types/trusted-types": "^2.0.7",
3415- "acorn": "^8.12.1",
3416- "aria-query": "5.3.1",
3417- "axobject-query": "^4.1.0",
3418- "clsx": "^2.1.1",
3419- "devalue": "^5.6.4",
3420- "esm-env": "^1.2.1",
3421- "esrap": "^2.2.2",
3422- "is-reference": "^3.0.3",
3423- "locate-character": "^3.0.0",
3424- "magic-string": "^0.30.11",
3425- "zimmerframe": "^1.1.2"
3426- },
3427- "engines": {
3428- "node": ">=18"
3429- }
3430- },
3431- "node_modules/svelte-awesome-color-picker": {
3432- "version": "4.1.1",
3433- "resolved": "https://registry.npmjs.org/svelte-awesome-color-picker/-/svelte-awesome-color-picker-4.1.1.tgz",
3434- "integrity": "sha512-k4APFGBFN8EpvUS0UJfdlqj8xDekMbqGBwnDYBK1QCY8kcTXSO8c7SitEk70MkiYOIByOgXlHcjaipy6H7dpAw==",
3435- "dev": true,
3436- "license": "MIT",
3437- "dependencies": {
3438- "colord": "^2.9.3",
3439- "svelte-awesome-slider": "2.0.0"
3440- },
3441- "engines": {
3442- "node": ">=24"
3443- },
3444- "peerDependencies": {
3445- "svelte": "^5.0.0"
3446- }
3447- },
3448- "node_modules/svelte-awesome-slider": {
3449- "version": "2.0.0",
3450- "resolved": "https://registry.npmjs.org/svelte-awesome-slider/-/svelte-awesome-slider-2.0.0.tgz",
3451- "integrity": "sha512-YBkOdYm1Feaqsn2JkJBRs+Kc/X3Qy/3GuVmI7GmoYDjBaHkjx9uH4khTuED22z57Hg3gGWeDhp/clIjWDdLNaw==",
3452- "dev": true,
3453- "license": "MIT",
3454- "peerDependencies": {
3455- "svelte": "^5.0.0"
3456- }
3457- },
3458- "node_modules/svelte-check": {
3459- "version": "4.4.5",
3460- "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz",
3461- "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==",
3462- "dev": true,
3463- "license": "MIT",
3464- "dependencies": {
3465- "@jridgewell/trace-mapping": "^0.3.25",
3466- "chokidar": "^4.0.1",
3467- "fdir": "^6.2.0",
3468- "picocolors": "^1.0.0",
3469- "sade": "^1.7.4"
3470- },
3471- "bin": {
3472- "svelte-check": "bin/svelte-check"
3473- },
3474- "engines": {
3475- "node": ">= 18.0.0"
3476- },
3477- "peerDependencies": {
3478- "svelte": "^4.0.0 || ^5.0.0-next.0",
3479- "typescript": ">=5.0.0"
3480- }
3481- },
3482- "node_modules/svelte-eslint-parser": {
3483- "version": "1.4.1",
3484- "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.1.tgz",
3485- "integrity": "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==",
3486- "dev": true,
3487- "license": "MIT",
3488- "dependencies": {
3489- "eslint-scope": "^8.2.0",
3490- "eslint-visitor-keys": "^4.0.0",
3491- "espree": "^10.0.0",
3492- "postcss": "^8.4.49",
3493- "postcss-scss": "^4.0.9",
3494- "postcss-selector-parser": "^7.0.0"
3495- },
3496- "engines": {
3497- "node": "^18.18.0 || ^20.9.0 || >=21.1.0",
3498- "pnpm": "10.24.0"
3499- },
3500- "funding": {
3501- "url": "https://github.com/sponsors/ota-meshi"
3502- },
3503- "peerDependencies": {
3504- "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0"
3505- },
3506- "peerDependenciesMeta": {
3507- "svelte": {
3508- "optional": true
3509- }
3510- }
3511- },
3512- "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": {
3513- "version": "4.2.1",
3514- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
3515- "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
3516- "dev": true,
3517- "license": "Apache-2.0",
3518- "engines": {
3519- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
3520- },
3521- "funding": {
3522- "url": "https://opencollective.com/eslint"
3523- }
3524- },
3525- "node_modules/svelte-toolbelt": {
3526- "version": "0.10.6",
3527- "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
3528- "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==",
3529- "funding": [
3530- "https://github.com/sponsors/huntabyte"
3531- ],
3532- "dependencies": {
3533- "clsx": "^2.1.1",
3534- "runed": "^0.35.1",
3535- "style-to-object": "^1.0.8"
3536- },
3537- "engines": {
3538- "node": ">=18",
3539- "pnpm": ">=8.7.0"
3540- },
3541- "peerDependencies": {
3542- "svelte": "^5.30.2"
3543- }
3544- },
3545- "node_modules/tabbable": {
3546- "version": "6.4.0",
3547- "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
3548- "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
3549- "license": "MIT"
3550- },
3551- "node_modules/tailwind-merge": {
3552- "version": "3.5.0",
3553- "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
3554- "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
3555- "license": "MIT",
3556- "peer": true,
3557- "funding": {
3558- "type": "github",
3559- "url": "https://github.com/sponsors/dcastil"
3560- }
3561- },
3562- "node_modules/tailwind-variants": {
3563- "version": "3.2.2",
3564- "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz",
3565- "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==",
3566- "dev": true,
3567- "license": "MIT",
3568- "engines": {
3569- "node": ">=16.x",
3570- "pnpm": ">=7.x"
3571- },
3572- "peerDependencies": {
3573- "tailwind-merge": ">=3.0.0",
3574- "tailwindcss": "*"
3575- },
3576- "peerDependenciesMeta": {
3577- "tailwind-merge": {
3578- "optional": true
3579- }
3580- }
3581- },
3582- "node_modules/tailwindcss": {
3583- "version": "4.2.2",
3584- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
3585- "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
3586- "dev": true,
3587- "license": "MIT",
3588- "peer": true
3589- },
3590- "node_modules/tapable": {
3591- "version": "2.3.0",
3592- "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
3593- "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
3594- "dev": true,
3595- "license": "MIT",
3596- "engines": {
3597- "node": ">=6"
3598- },
3599- "funding": {
3600- "type": "opencollective",
3601- "url": "https://opencollective.com/webpack"
3602- }
3603- },
3604- "node_modules/tinyglobby": {
3605- "version": "0.2.15",
3606- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
3607- "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
3608- "dev": true,
3609- "license": "MIT",
3610- "dependencies": {
3611- "fdir": "^6.5.0",
3612- "picomatch": "^4.0.3"
3613- },
3614- "engines": {
3615- "node": ">=12.0.0"
3616- },
3617- "funding": {
3618- "url": "https://github.com/sponsors/SuperchupuDev"
3619- }
3620- },
3621- "node_modules/ts-api-utils": {
3622- "version": "2.4.0",
3623- "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
3624- "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
3625- "dev": true,
3626- "license": "MIT",
3627- "engines": {
3628- "node": ">=18.12"
3629- },
3630- "peerDependencies": {
3631- "typescript": ">=4.8.4"
3632- }
3633- },
3634- "node_modules/tslib": {
3635- "version": "2.8.1",
3636- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
3637- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
3638- "license": "0BSD"
3639- },
3640- "node_modules/tw-animate-css": {
3641- "version": "1.4.0",
3642- "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
3643- "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
3644- "dev": true,
3645- "license": "MIT",
3646- "funding": {
3647- "url": "https://github.com/sponsors/Wombosvideo"
3648- }
3649- },
3650- "node_modules/type-check": {
3651- "version": "0.4.0",
3652- "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
3653- "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
3654- "dev": true,
3655- "license": "MIT",
3656- "dependencies": {
3657- "prelude-ls": "^1.2.1"
3658- },
3659- "engines": {
3660- "node": ">= 0.8.0"
3661- }
3662- },
3663- "node_modules/typescript": {
3664- "version": "5.9.3",
3665- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
3666- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
3667- "dev": true,
3668- "license": "Apache-2.0",
3669- "peer": true,
3670- "bin": {
3671- "tsc": "bin/tsc",
3672- "tsserver": "bin/tsserver"
3673- },
3674- "engines": {
3675- "node": ">=14.17"
3676- }
3677- },
3678- "node_modules/typescript-eslint": {
3679- "version": "8.57.1",
3680- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz",
3681- "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==",
3682- "dev": true,
3683- "license": "MIT",
3684- "dependencies": {
3685- "@typescript-eslint/eslint-plugin": "8.57.1",
3686- "@typescript-eslint/parser": "8.57.1",
3687- "@typescript-eslint/typescript-estree": "8.57.1",
3688- "@typescript-eslint/utils": "8.57.1"
3689- },
3690- "engines": {
3691- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
3692- },
3693- "funding": {
3694- "type": "opencollective",
3695- "url": "https://opencollective.com/typescript-eslint"
3696- },
3697- "peerDependencies": {
3698- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
3699- "typescript": ">=4.8.4 <6.0.0"
3700- }
3701- },
3702- "node_modules/undici-types": {
3703- "version": "7.18.2",
3704- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
3705- "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
3706- "dev": true,
3707- "license": "MIT"
3708- },
3709- "node_modules/update-browserslist-db": {
3710- "version": "1.2.3",
3711- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
3712- "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
3713- "dev": true,
3714- "funding": [
3715- {
3716- "type": "opencollective",
3717- "url": "https://opencollective.com/browserslist"
3718- },
3719- {
3720- "type": "tidelift",
3721- "url": "https://tidelift.com/funding/github/npm/browserslist"
3722- },
3723- {
3724- "type": "github",
3725- "url": "https://github.com/sponsors/ai"
3726- }
3727- ],
3728- "license": "MIT",
3729- "dependencies": {
3730- "escalade": "^3.2.0",
3731- "picocolors": "^1.1.1"
3732- },
3733- "bin": {
3734- "update-browserslist-db": "cli.js"
3735- },
3736- "peerDependencies": {
3737- "browserslist": ">= 4.21.0"
3738- }
3739- },
3740- "node_modules/uri-js": {
3741- "version": "4.4.1",
3742- "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
3743- "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
3744- "dev": true,
3745- "license": "BSD-2-Clause",
3746- "dependencies": {
3747- "punycode": "^2.1.0"
3748- }
3749- },
3750- "node_modules/util-deprecate": {
3751- "version": "1.0.2",
3752- "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
3753- "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
3754- "dev": true,
3755- "license": "MIT"
3756- },
3757- "node_modules/vite": {
3758- "version": "8.0.0",
3759- "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
3760- "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
3761- "dev": true,
3762- "license": "MIT",
3763- "peer": true,
3764- "dependencies": {
3765- "@oxc-project/runtime": "0.115.0",
3766- "lightningcss": "^1.32.0",
3767- "picomatch": "^4.0.3",
3768- "postcss": "^8.5.8",
3769- "rolldown": "1.0.0-rc.9",
3770- "tinyglobby": "^0.2.15"
3771- },
3772- "bin": {
3773- "vite": "bin/vite.js"
3774- },
3775- "engines": {
3776- "node": "^20.19.0 || >=22.12.0"
3777- },
3778- "funding": {
3779- "url": "https://github.com/vitejs/vite?sponsor=1"
3780- },
3781- "optionalDependencies": {
3782- "fsevents": "~2.3.3"
3783- },
3784- "peerDependencies": {
3785- "@types/node": "^20.19.0 || >=22.12.0",
3786- "@vitejs/devtools": "^0.0.0-alpha.31",
3787- "esbuild": "^0.27.0",
3788- "jiti": ">=1.21.0",
3789- "less": "^4.0.0",
3790- "sass": "^1.70.0",
3791- "sass-embedded": "^1.70.0",
3792- "stylus": ">=0.54.8",
3793- "sugarss": "^5.0.0",
3794- "terser": "^5.16.0",
3795- "tsx": "^4.8.1",
3796- "yaml": "^2.4.2"
3797- },
3798- "peerDependenciesMeta": {
3799- "@types/node": {
3800- "optional": true
3801- },
3802- "@vitejs/devtools": {
3803- "optional": true
3804- },
3805- "esbuild": {
3806- "optional": true
3807- },
3808- "jiti": {
3809- "optional": true
3810- },
3811- "less": {
3812- "optional": true
3813- },
3814- "sass": {
3815- "optional": true
3816- },
3817- "sass-embedded": {
3818- "optional": true
3819- },
3820- "stylus": {
3821- "optional": true
3822- },
3823- "sugarss": {
3824- "optional": true
3825- },
3826- "terser": {
3827- "optional": true
3828- },
3829- "tsx": {
3830- "optional": true
3831- },
3832- "yaml": {
3833- "optional": true
3834- }
3835- }
3836- },
3837- "node_modules/vitefu": {
3838- "version": "1.1.2",
3839- "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz",
3840- "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==",
3841- "dev": true,
3842- "license": "MIT",
3843- "workspaces": [
3844- "tests/deps/*",
3845- "tests/projects/*",
3846- "tests/projects/workspace/packages/*"
3847- ],
3848- "peerDependencies": {
3849- "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0"
3850- },
3851- "peerDependenciesMeta": {
3852- "vite": {
3853- "optional": true
3854- }
3855- }
3856- },
3857- "node_modules/which": {
3858- "version": "2.0.2",
3859- "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
3860- "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
3861- "dev": true,
3862- "license": "ISC",
3863- "dependencies": {
3864- "isexe": "^2.0.0"
3865- },
3866- "bin": {
3867- "node-which": "bin/node-which"
3868- },
3869- "engines": {
3870- "node": ">= 8"
3871- }
3872- },
3873- "node_modules/word-wrap": {
3874- "version": "1.2.5",
3875- "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
3876- "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
3877- "dev": true,
3878- "license": "MIT",
3879- "engines": {
3880- "node": ">=0.10.0"
3881- }
3882- },
3883- "node_modules/yocto-queue": {
3884- "version": "0.1.0",
3885- "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
3886- "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
3887- "dev": true,
3888- "license": "MIT",
3889- "engines": {
3890- "node": ">=10"
3891- },
3892- "funding": {
3893- "url": "https://github.com/sponsors/sindresorhus"
3894- }
3895- },
3896- "node_modules/zimmerframe": {
3897- "version": "1.1.4",
3898- "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
3899- "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
3900- "license": "MIT"
3901- }
3902- }
3903-}
D webui/package.json +0 -52
@@ -1,52 +0,0 @@
1-{
2- "name": "@dumber/gui",
3- "version": "0.1.0",
4- "private": true,
5- "type": "module",
6- "scripts": {
7- "build": "VITE_PAGE=homepage vite build && VITE_PAGE=error vite build && VITE_PAGE=config vite build && VITE_PAGE=webrtc vite build",
8- "build:homepage": "VITE_PAGE=homepage vite build",
9- "build:error": "VITE_PAGE=error vite build",
10- "build:config": "VITE_PAGE=config vite build",
11- "build:webrtc": "VITE_PAGE=webrtc vite build",
12- "dev": "VITE_PAGE=homepage vite build --watch",
13- "dev:homepage": "VITE_PAGE=homepage vite build --watch",
14- "dev:error": "VITE_PAGE=error vite build --watch",
15- "dev:config": "VITE_PAGE=config vite build --watch",
16- "dev:webrtc": "VITE_PAGE=webrtc vite build --watch",
17- "check": "svelte-check",
18- "lint": "eslint src --fix",
19- "fmt": "prettier --write ."
20- },
21- "devDependencies": {
22- "@internationalized/date": "^3.12.0",
23- "@lucide/svelte": "^0.577.0",
24- "@sveltejs/vite-plugin-svelte": "^7.0.0",
25- "@tailwindcss/cli": "^4.2.2",
26- "@tailwindcss/postcss": "^4.2.2",
27- "@types/node": "^25.5.0",
28- "@typescript-eslint/eslint-plugin": "^8.57.1",
29- "@typescript-eslint/parser": "^8.57.1",
30- "autoprefixer": "^10.4.27",
31- "eslint": "^10.0.3",
32- "eslint-plugin-svelte": "^3.15.2",
33- "globals": "^17.4.0",
34- "postcss": "^8.5.8",
35- "svelte": "^5.54.0",
36- "svelte-awesome-color-picker": "^4.1.0",
37- "svelte-check": "^4.4.5",
38- "tailwind-variants": "^3.2.2",
39- "tailwindcss": "^4.0.0",
40- "tw-animate-css": "^1.4.0",
41- "typescript": "^5.3.0",
42- "typescript-eslint": "^8.57.1",
43- "vite": "^8.0.0"
44- },
45- "dependencies": {
46- "bits-ui": "^2.16.3",
47- "clsx": "^2.1.1",
48- "frappe-charts": "^1.6.2",
49- "mode-watcher": "^1.1.0",
50- "tailwind-merge": "^3.5.0"
51- }
52-}
D webui/postcss.config.js +0 -6
@@ -1,6 +0,0 @@
1-export default {
2- plugins: {
3- "@tailwindcss/postcss": {},
4- autoprefixer: {},
5- },
6-};
D webui/public/favicon.png +0 -0 binary file: no text diff

binary file: no text diff

D webui/src/lib/bridge.ts +0 -165
@@ -1,165 +0,0 @@
1-/**
2- * WebKit bridge utilities for communicating with the Go backend.
3- *
4- * IMPORTANT: Svelte 5 uses Proxy objects for reactive state ($state, $derived).
5- * WebKit's messageHandlers.postMessage() cannot serialize Proxy objects - it
6- * fails silently, dropping the message entirely. This module provides safe
7- * serialization that converts Proxies to plain objects before sending.
8- */
9-
10-export interface BridgeMessage {
11- type: string;
12- webviewId: number;
13- payload: Record<string, unknown>;
14-}
15-
16-interface WebKitBridge {
17- postMessage: (msg: unknown) => void;
18-}
19-
20-/**
21- * Get the native bridge, if available.
22- * WebKit exposes `window.webkit.messageHandlers.dumber` while CEF injects
23- * `window.dumber.postMessage`.
24- */
25-export function getWebKitBridge(): WebKitBridge | null {
26- // eslint-disable-next-line @typescript-eslint/no-explicit-any
27- const bridge = (window as any).webkit?.messageHandlers?.dumber ?? (window as any).dumber;
28- return bridge && typeof bridge.postMessage === "function" ? bridge : null;
29-}
30-
31-/**
32- * Get the current webview ID assigned by the Go backend.
33- */
34-export function getWebViewId(): number {
35- // eslint-disable-next-line @typescript-eslint/no-explicit-any
36- return (window as any).__dumber_webview_id || 0;
37-}
38-
39-/**
40- * Deep-clone an object, converting all Svelte 5 Proxy objects to plain objects.
41- * Uses JSON serialization which strips Proxy wrappers.
42- *
43- * @throws Error if the object contains non-serializable values (functions, circular refs, etc.)
44- */
45-export function toPlainObject<T>(obj: T): T {
46- try {
47- return JSON.parse(JSON.stringify(obj));
48- } catch (err) {
49- const message = err instanceof Error ? err.message : String(err);
50- throw new Error(`Failed to serialize object for bridge: ${message}`);
51- }
52-}
53-
54-/**
55- * Post a message to the Go backend via the native bridge.
56- *
57- * Automatically:
58- * - Converts Svelte 5 Proxy objects to plain objects
59- * - Adds webviewId if not provided
60- * - Logs errors if serialization fails
61- *
62- * @throws Error if bridge is not available or serialization fails
63- */
64-export function postMessage(msg: BridgeMessage): void {
65- const bridge = getWebKitBridge();
66- if (!bridge) {
67- throw new Error("Dumber bridge not available");
68- }
69-
70- // Ensure webviewId is set
71- if (!msg.webviewId) {
72- msg.webviewId = getWebViewId();
73- }
74-
75- // Convert to plain object to strip Svelte 5 Proxies
76- // This is critical - WebKit silently drops messages with Proxy objects
77- const plainMsg = toPlainObject(msg);
78- bridge.postMessage(plainMsg);
79-}
80-
81-/**
82- * Internal callback registry using Map for tracking pending requests.
83- * Callbacks must also be exposed on window for Go backend to call,
84- * but we track them here for proper cleanup and to prevent leaks.
85- */
86-const pendingCallbacks = new Map<string, {
87- cleanup: () => void;
88- timeoutId: ReturnType<typeof setTimeout>;
89-}>();
90-
91-let callbackCounter = 0;
92-const CALLBACK_TIMEOUT_MS = 30000;
93-
94-/**
95- * Post a message and set up success/error callbacks with automatic cleanup.
96- * Uses unique IDs to prevent naming conflicts between concurrent requests.
97- *
98- * @param type - Message type (e.g., "get_keybindings", "set_keybinding")
99- * @param payload - Message payload (will be serialized to strip Proxies)
100- * @param successCallback - Base name for success callback (will be made unique)
101- * @param errorCallback - Base name for error callback (will be made unique)
102- * @param onSuccess - Handler for success response
103- * @param onError - Handler for error response
104- */
105-export function postMessageWithCallbacks<TResponse, TPayload extends Record<string, unknown>>(
106- type: string,
107- payload: TPayload,
108- successCallback: string,
109- errorCallback: string,
110- onSuccess: (response: TResponse) => void,
111- onError: (error: string) => void,
112-): void {
113- const id = ++callbackCounter;
114- const successKey = `${successCallback}_${id}`;
115- const errorKey = `${errorCallback}_${id}`;
116-
117- // Cleanup removes from both window and our tracking Map
118- const cleanup = () => {
119- const entry = pendingCallbacks.get(successKey);
120- if (entry) {
121- clearTimeout(entry.timeoutId);
122- pendingCallbacks.delete(successKey);
123- }
124- // eslint-disable-next-line @typescript-eslint/no-explicit-any
125- delete (window as any)[successKey];
126- // eslint-disable-next-line @typescript-eslint/no-explicit-any
127- delete (window as any)[errorKey];
128- };
129-
130- // Timeout prevents memory leaks if backend never responds
131- const timeoutId = setTimeout(() => {
132- cleanup();
133- onError("Request timed out");
134- }, CALLBACK_TIMEOUT_MS);
135-
136- // Track in our Map for proper lifecycle management
137- pendingCallbacks.set(successKey, { cleanup, timeoutId });
138-
139- // Expose on window for Go backend (it calls window[callbackName](response))
140- // eslint-disable-next-line @typescript-eslint/no-explicit-any
141- (window as any)[successKey] = (response: TResponse) => {
142- cleanup();
143- onSuccess(response);
144- };
145- // eslint-disable-next-line @typescript-eslint/no-explicit-any
146- (window as any)[errorKey] = (msg: unknown) => {
147- cleanup();
148- onError(typeof msg === "string" ? msg : "Unknown error");
149- };
150-
151- try {
152- postMessage({
153- type,
154- webviewId: getWebViewId(),
155- payload: {
156- ...payload,
157- successCallback: successKey,
158- errorCallback: errorKey,
159- },
160- });
161- } catch (err) {
162- cleanup();
163- onError(err instanceof Error ? err.message : String(err));
164- }
165-}
D webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte +0 -27
@@ -1,27 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { buttonVariants } from "$lib/components/ui/button/index.js";
4- import { cn } from "$lib/utils.js";
5- import type { Snippet } from "svelte";
6- import type { MouseEventHandler } from "svelte/elements";
7-
8- let {
9- ref = $bindable(null),
10- class: className,
11- children,
12- onclick,
13- ...restProps
14- }: AlertDialogPrimitive.ActionProps & {
15- children?: Snippet;
16- onclick?: MouseEventHandler<HTMLButtonElement>;
17- } = $props();
18-</script>
19-
20-<AlertDialogPrimitive.Action
21- bind:ref
22- class={cn(buttonVariants(), className)}
23- {onclick}
24- {...restProps}
25->
26- {@render children?.()}
27-</AlertDialogPrimitive.Action>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { buttonVariants } from "$lib/components/ui/button/index.js";
4- import { cn } from "$lib/utils.js";
5- import type { Snippet } from "svelte";
6-
7- let {
8- ref = $bindable(null),
9- class: className,
10- children,
11- ...restProps
12- }: AlertDialogPrimitive.CancelProps & {
13- children?: Snippet;
14- } = $props();
15-</script>
16-
17-<AlertDialogPrimitive.Cancel
18- bind:ref
19- class={cn(buttonVariants({ variant: "outline" }), className)}
20- {...restProps}
21->
22- {@render children?.()}
23-</AlertDialogPrimitive.Cancel>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte +0 -24
@@ -1,24 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4- import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
5- import AlertDialogPortal from "./alert-dialog-portal.svelte";
6-
7- let {
8- ref = $bindable(null),
9- class: className,
10- ...restProps
11- }: AlertDialogPrimitive.ContentProps = $props();
12-</script>
13-
14-<AlertDialogPortal>
15- <AlertDialogOverlay />
16- <AlertDialogPrimitive.Content
17- bind:ref
18- class={cn(
19- "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border p-6 shadow-lg duration-200 sm:rounded-lg",
20- className
21- )}
22- {...restProps}
23- />
24-</AlertDialogPortal>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte +0 -16
@@ -1,16 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: AlertDialogPrimitive.DescriptionProps = $props();
10-</script>
11-
12-<AlertDialogPrimitive.Description
13- bind:ref
14- class={cn("text-sm text-muted-foreground", className)}
15- {...restProps}
16-/>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte +0 -19
@@ -1,19 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
16- {...restProps}
17->
18- {@render children?.()}
19-</div>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte +0 -19
@@ -1,19 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
16- {...restProps}
17->
18- {@render children?.()}
19-</div>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte +0 -19
@@ -1,19 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: AlertDialogPrimitive.OverlayProps = $props();
10-</script>
11-
12-<AlertDialogPrimitive.Overlay
13- bind:ref
14- class={cn(
15- "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
16- className
17- )}
18- {...restProps}
19-/>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte +0 -7
@@ -1,7 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3-
4- let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
5-</script>
6-
7-<AlertDialogPrimitive.Portal {...restProps} />
D webui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte +0 -16
@@ -1,16 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: AlertDialogPrimitive.TitleProps = $props();
10-</script>
11-
12-<AlertDialogPrimitive.Title
13- bind:ref
14- class={cn("text-lg font-semibold", className)}
15- {...restProps}
16-/>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte +0 -16
@@ -1,16 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: AlertDialogPrimitive.TriggerProps = $props();
10-</script>
11-
12-<AlertDialogPrimitive.Trigger
13- bind:ref
14- class={cn(className)}
15- {...restProps}
16-/>
D webui/src/lib/components/ui/alert-dialog/alert-dialog.svelte +0 -10
@@ -1,10 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3-
4- let {
5- open = $bindable(false),
6- ...restProps
7- }: AlertDialogPrimitive.RootProps = $props();
8-</script>
9-
10-<AlertDialogPrimitive.Root bind:open {...restProps} />
D webui/src/lib/components/ui/alert-dialog/index.ts +0 -37
@@ -1,37 +0,0 @@
1-import Root from "./alert-dialog.svelte";
2-import Trigger from "./alert-dialog-trigger.svelte";
3-import Portal from "./alert-dialog-portal.svelte";
4-import Overlay from "./alert-dialog-overlay.svelte";
5-import Content from "./alert-dialog-content.svelte";
6-import Header from "./alert-dialog-header.svelte";
7-import Footer from "./alert-dialog-footer.svelte";
8-import Title from "./alert-dialog-title.svelte";
9-import Description from "./alert-dialog-description.svelte";
10-import Action from "./alert-dialog-action.svelte";
11-import Cancel from "./alert-dialog-cancel.svelte";
12-
13-export {
14- Root,
15- Trigger,
16- Portal,
17- Overlay,
18- Content,
19- Header,
20- Footer,
21- Title,
22- Description,
23- Action,
24- Cancel,
25- //
26- Root as AlertDialog,
27- Trigger as AlertDialogTrigger,
28- Portal as AlertDialogPortal,
29- Overlay as AlertDialogOverlay,
30- Content as AlertDialogContent,
31- Header as AlertDialogHeader,
32- Footer as AlertDialogFooter,
33- Title as AlertDialogTitle,
34- Description as AlertDialogDescription,
35- Action as AlertDialogAction,
36- Cancel as AlertDialogCancel,
37-};
D webui/src/lib/components/ui/button/button.svelte +0 -82
@@ -1,82 +0,0 @@
1-<script lang="ts" module>
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
4- import { type VariantProps, tv } from "tailwind-variants";
5-
6- export const buttonVariants = tv({
7- base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
8- variants: {
9- variant: {
10- default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
11- destructive:
12- "bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
13- outline:
14- "bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border border-border shadow-xs",
15- secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
16- ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
17- link: "text-primary underline-offset-4 hover:underline",
18- },
19- size: {
20- default: "h-9 px-4 py-2 has-[>svg]:px-3",
21- sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
22- lg: "h-10 px-6 has-[>svg]:px-4",
23- icon: "size-9",
24- "icon-sm": "size-8",
25- "icon-lg": "size-10",
26- },
27- },
28- defaultVariants: {
29- variant: "default",
30- size: "default",
31- },
32- });
33-
34- export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
35- export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
36-
37- export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
38- WithElementRef<HTMLAnchorAttributes> & {
39- variant?: ButtonVariant;
40- size?: ButtonSize;
41- };
42-</script>
43-
44-<script lang="ts">
45- let {
46- class: className,
47- variant = "default",
48- size = "default",
49- ref = $bindable(null),
50- href = undefined,
51- type = "button",
52- disabled,
53- children,
54- ...restProps
55- }: ButtonProps = $props();
56-</script>
57-
58-{#if href}
59- <a
60- bind:this={ref}
61- data-slot="button"
62- class={cn(buttonVariants({ variant, size }), className)}
63- href={disabled ? undefined : href}
64- aria-disabled={disabled}
65- role={disabled ? "link" : undefined}
66- tabindex={disabled ? -1 : undefined}
67- {...restProps}
68- >
69- {@render children?.()}
70- </a>
71-{:else}
72- <button
73- bind:this={ref}
74- data-slot="button"
75- class={cn(buttonVariants({ variant, size }), className)}
76- {type}
77- {disabled}
78- {...restProps}
79- >
80- {@render children?.()}
81- </button>
82-{/if}
D webui/src/lib/components/ui/button/index.ts +0 -17
@@ -1,17 +0,0 @@
1-import Root, {
2- type ButtonProps,
3- type ButtonSize,
4- type ButtonVariant,
5- buttonVariants,
6-} from "./button.svelte";
7-
8-export {
9- Root,
10- type ButtonProps as Props,
11- //
12- Root as Button,
13- buttonVariants,
14- type ButtonProps,
15- type ButtonSize,
16- type ButtonVariant,
17-};
D webui/src/lib/components/ui/card/card-action.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- data-slot="card-action"
16- class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</div>
D webui/src/lib/components/ui/card/card-content.svelte +0 -15
@@ -1,15 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
14- {@render children?.()}
15-</div>
D webui/src/lib/components/ui/card/card-description.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
11-</script>
12-
13-<p
14- bind:this={ref}
15- data-slot="card-description"
16- class={cn("text-muted-foreground text-sm", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</p>
D webui/src/lib/components/ui/card/card-footer.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- data-slot="card-footer"
16- class={cn("flex items-center px-6 [.border-t]:pt-6", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</div>
D webui/src/lib/components/ui/card/card-header.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- data-slot="card-header"
16- class={cn(
17- "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
18- className
19- )}
20- {...restProps}
21->
22- {@render children?.()}
23-</div>
D webui/src/lib/components/ui/card/card-title.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- data-slot="card-title"
16- class={cn("leading-none font-semibold", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</div>
D webui/src/lib/components/ui/card/card.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- data-slot="card"
16- class={cn(
17- "bg-card text-card-foreground flex flex-col gap-6 border border-border py-6 shadow-sm",
18- className
19- )}
20- {...restProps}
21->
22- {@render children?.()}
23-</div>
D webui/src/lib/components/ui/card/index.ts +0 -25
@@ -1,25 +0,0 @@
1-import Root from "./card.svelte";
2-import Content from "./card-content.svelte";
3-import Description from "./card-description.svelte";
4-import Footer from "./card-footer.svelte";
5-import Header from "./card-header.svelte";
6-import Title from "./card-title.svelte";
7-import Action from "./card-action.svelte";
8-
9-export {
10- Root,
11- Content,
12- Description,
13- Footer,
14- Header,
15- Title,
16- Action,
17- //
18- Root as Card,
19- Content as CardContent,
20- Description as CardDescription,
21- Footer as CardFooter,
22- Header as CardHeader,
23- Title as CardTitle,
24- Action as CardAction,
25-};
D webui/src/lib/components/ui/color-picker/color-picker.svelte +0 -80
@@ -1,80 +0,0 @@
1-<script lang="ts" module>
2- export type ColorPickerProps = {
3- value: string;
4- onValueChange?: (value: string) => void;
5- id?: string;
6- class?: string;
7- };
8-</script>
9-
10-<script lang="ts">
11- import ColorPicker from "svelte-awesome-color-picker";
12- import { Popover } from "bits-ui";
13- import { cn } from "$lib/utils.js";
14-
15- let {
16- class: className,
17- value = $bindable("#000000"),
18- onValueChange,
19- id,
20- }: ColorPickerProps = $props();
21-
22- let open = $state(false);
23-
24- function handleInput(event: { hex: string | null }) {
25- if (event.hex) {
26- value = event.hex;
27- onValueChange?.(event.hex);
28- }
29- }
30-</script>
31-
32-<Popover.Root bind:open>
33- <Popover.Trigger
34- {id}
35- class={cn(
36- "inline-flex h-10 w-12 cursor-pointer items-center justify-center border border-border bg-background transition-colors hover:border-foreground/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
37- className
38- )}
39- >
40- <span
41- class="block h-6 w-8 border border-border/50"
42- style="background-color: {value};"
43- aria-hidden="true"
44- ></span>
45- <span class="sr-only">Choose color: {value}</span>
46- </Popover.Trigger>
47-
48- <Popover.Portal>
49- <Popover.Content
50- class="color-picker-popover z-50 border border-border bg-background p-3 shadow-lg"
51- sideOffset={4}
52- >
53- <ColorPicker
54- bind:hex={value}
55- onInput={handleInput}
56- isDialog={false}
57- />
58- <!-- Hex value display -->
59- <div class="mt-2 flex items-center gap-2">
60- <span
61- class="block h-6 w-6 shrink-0 border border-border"
62- style="background-color: {value};"
63- aria-hidden="true"
64- ></span>
65- <span class="font-mono text-sm text-foreground">{value}</span>
66- </div>
67- </Popover.Content>
68- </Popover.Portal>
69-</Popover.Root>
70-
71-<style>
72- /* Override svelte-awesome-color-picker styles to match our theme */
73- :global(.color-picker-popover) {
74- --cp-bg-color: var(--background);
75- --cp-border-color: var(--border);
76- --cp-text-color: var(--foreground);
77- --cp-input-color: var(--background);
78- --cp-button-hover-color: var(--accent);
79- }
80-</style>
D webui/src/lib/components/ui/color-picker/index.ts +0 -8
@@ -1,8 +0,0 @@
1-import Root, { type ColorPickerProps } from "./color-picker.svelte";
2-
3-export {
4- Root,
5- Root as ColorPicker,
6- type ColorPickerProps,
7- type ColorPickerProps as Props,
8-};
D webui/src/lib/components/ui/input/index.ts +0 -7
@@ -1,7 +0,0 @@
1-import Root from "./input.svelte";
2-
3-export {
4- Root,
5- //
6- Root as Input,
7-};
D webui/src/lib/components/ui/input/input.svelte +0 -52
@@ -1,52 +0,0 @@
1-<script lang="ts">
2- import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- type InputType = Exclude<HTMLInputTypeAttribute, "file">;
6-
7- type Props = WithElementRef<
8- Omit<HTMLInputAttributes, "type"> &
9- ({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
10- >;
11-
12- let {
13- ref = $bindable(null),
14- value = $bindable(),
15- type,
16- files = $bindable(),
17- class: className,
18- "data-slot": dataSlot = "input",
19- ...restProps
20- }: Props = $props();
21-</script>
22-
23-{#if type === "file"}
24- <input
25- bind:this={ref}
26- data-slot={dataSlot}
27- class={cn(
28- "selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-border ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
29- "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
30- "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
31- className
32- )}
33- type="file"
34- bind:files
35- bind:value
36- {...restProps}
37- />
38-{:else}
39- <input
40- bind:this={ref}
41- data-slot={dataSlot}
42- class={cn(
43- "border-border bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
44- "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
45- "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
46- className
47- )}
48- {type}
49- bind:value
50- {...restProps}
51- />
52-{/if}
D webui/src/lib/components/ui/label/index.ts +0 -7
@@ -1,7 +0,0 @@
1-import Root from "./label.svelte";
2-
3-export {
4- Root,
5- //
6- Root as Label,
7-};
D webui/src/lib/components/ui/label/label.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { Label as LabelPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: LabelPrimitive.RootProps = $props();
10-</script>
11-
12-<LabelPrimitive.Root
13- bind:ref
14- data-slot="label"
15- class={cn(
16- "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
17- className
18- )}
19- {...restProps}
20-/>
D webui/src/lib/components/ui/skeleton/index.ts +0 -7
@@ -1,7 +0,0 @@
1-import Root from "./skeleton.svelte";
2-
3-export {
4- Root,
5- //
6- Root as Skeleton,
7-};
D webui/src/lib/components/ui/skeleton/skeleton.svelte +0 -17
@@ -1,17 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
10-</script>
11-
12-<div
13- bind:this={ref}
14- data-slot="skeleton"
15- class={cn("bg-accent animate-pulse", className)}
16- {...restProps}
17-></div>
D webui/src/lib/components/ui/spinner/index.ts +0 -1
@@ -1,1 +0,0 @@
1-export { default as Spinner } from "./spinner.svelte";
D webui/src/lib/components/ui/spinner/spinner.svelte +0 -14
@@ -1,14 +0,0 @@
1-<script lang="ts">
2- import { cn } from "$lib/utils.js";
3- import Loader2Icon from "@lucide/svelte/icons/loader-2";
4- import type { ComponentProps } from "svelte";
5-
6- let { class: className, ...restProps }: ComponentProps<typeof Loader2Icon> = $props();
7-</script>
8-
9-<Loader2Icon
10- role="status"
11- aria-label="Loading"
12- class={cn("size-4 animate-spin", className)}
13- {...restProps}
14-/>
D webui/src/lib/components/ui/table/index.ts +0 -28
@@ -1,28 +0,0 @@
1-import Root from "./table.svelte";
2-import Body from "./table-body.svelte";
3-import Caption from "./table-caption.svelte";
4-import Cell from "./table-cell.svelte";
5-import Footer from "./table-footer.svelte";
6-import Head from "./table-head.svelte";
7-import Header from "./table-header.svelte";
8-import Row from "./table-row.svelte";
9-
10-export {
11- Root,
12- Body,
13- Caption,
14- Cell,
15- Footer,
16- Head,
17- Header,
18- Row,
19- //
20- Root as Table,
21- Body as TableBody,
22- Caption as TableCaption,
23- Cell as TableCell,
24- Footer as TableFooter,
25- Head as TableHead,
26- Header as TableHeader,
27- Row as TableRow,
28-};
D webui/src/lib/components/ui/table/table-body.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
11-</script>
12-
13-<tbody
14- bind:this={ref}
15- data-slot="table-body"
16- class={cn("[&_tr:last-child]:border-0", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</tbody>
D webui/src/lib/components/ui/table/table-caption.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
11-</script>
12-
13-<caption
14- bind:this={ref}
15- data-slot="table-caption"
16- class={cn("text-muted-foreground mt-4 text-sm", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</caption>
D webui/src/lib/components/ui/table/table-cell.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLTdAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLTdAttributes> = $props();
11-</script>
12-
13-<td
14- bind:this={ref}
15- data-slot="table-cell"
16- class={cn(
17- "bg-clip-padding p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0",
18- className
19- )}
20- {...restProps}
21->
22- {@render children?.()}
23-</td>
D webui/src/lib/components/ui/table/table-footer.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
11-</script>
12-
13-<tfoot
14- bind:this={ref}
15- data-slot="table-footer"
16- class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</tfoot>
D webui/src/lib/components/ui/table/table-head.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLThAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLThAttributes> = $props();
11-</script>
12-
13-<th
14- bind:this={ref}
15- data-slot="table-head"
16- class={cn(
17- "text-foreground h-10 bg-clip-padding px-2 text-start align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pe-0",
18- className
19- )}
20- {...restProps}
21->
22- {@render children?.()}
23-</th>
D webui/src/lib/components/ui/table/table-header.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
11-</script>
12-
13-<thead
14- bind:this={ref}
15- data-slot="table-header"
16- class={cn("[&_tr]:border-b", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</thead>
D webui/src/lib/components/ui/table/table-row.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
11-</script>
12-
13-<tr
14- bind:this={ref}
15- data-slot="table-row"
16- class={cn(
17- "hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
18- className
19- )}
20- {...restProps}
21->
22- {@render children?.()}
23-</tr>
D webui/src/lib/components/ui/table/table.svelte +0 -22
@@ -1,22 +0,0 @@
1-<script lang="ts">
2- import type { HTMLTableAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLTableAttributes> = $props();
11-</script>
12-
13-<div data-slot="table-container" class="relative w-full overflow-x-auto">
14- <table
15- bind:this={ref}
16- data-slot="table"
17- class={cn("w-full caption-bottom text-sm", className)}
18- {...restProps}
19- >
20- {@render children?.()}
21- </table>
22-</div>
D webui/src/lib/components/ui/tabs/index.ts +0 -16
@@ -1,16 +0,0 @@
1-import Root from "./tabs.svelte";
2-import Content from "./tabs-content.svelte";
3-import List from "./tabs-list.svelte";
4-import Trigger from "./tabs-trigger.svelte";
5-
6-export {
7- Root,
8- Content,
9- List,
10- Trigger,
11- //
12- Root as Tabs,
13- Content as TabsContent,
14- List as TabsList,
15- Trigger as TabsTrigger,
16-};
D webui/src/lib/components/ui/tabs/tabs-content.svelte +0 -17
@@ -1,17 +0,0 @@
1-<script lang="ts">
2- import { Tabs as TabsPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: TabsPrimitive.ContentProps = $props();
10-</script>
11-
12-<TabsPrimitive.Content
13- bind:ref
14- data-slot="tabs-content"
15- class={cn("flex-1 outline-none", className)}
16- {...restProps}
17-/>
D webui/src/lib/components/ui/tabs/tabs-list.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { Tabs as TabsPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: TabsPrimitive.ListProps = $props();
10-</script>
11-
12-<TabsPrimitive.List
13- bind:ref
14- data-slot="tabs-list"
15- class={cn(
16- "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center border border-border p-[3px]",
17- className
18- )}
19- {...restProps}
20-/>
D webui/src/lib/components/ui/tabs/tabs-trigger.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { Tabs as TabsPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: TabsPrimitive.TriggerProps = $props();
10-</script>
11-
12-<TabsPrimitive.Trigger
13- bind:ref
14- data-slot="tabs-trigger"
15- class={cn(
16- "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
17- className
18- )}
19- {...restProps}
20-/>
D webui/src/lib/components/ui/tabs/tabs.svelte +0 -19
@@ -1,19 +0,0 @@
1-<script lang="ts">
2- import { Tabs as TabsPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- value = $bindable(""),
8- class: className,
9- ...restProps
10- }: TabsPrimitive.RootProps = $props();
11-</script>
12-
13-<TabsPrimitive.Root
14- bind:ref
15- bind:value
16- data-slot="tabs"
17- class={cn("flex flex-col gap-2", className)}
18- {...restProps}
19-/>
D webui/src/lib/types.ts +0 -68
@@ -1,68 +0,0 @@
1-import type { HistoryEntry, SearchShortcut } from "../types/generated.js";
2-
3-type ThemeManagerAPI = {
4- setUserPreference: (theme: "light" | "dark") => void;
5- getCurrentTheme: () => "light" | "dark";
6-};
7-
8-// Global window API types for browser integration
9-declare global {
10- interface Window {
11- // Toast system API
12- __dumber_toast_loaded?: boolean;
13- __dumber_showToast?: (
14- message: string,
15- duration?: number,
16- type?: "info" | "success" | "error",
17- ) => number | void;
18- __dumber_dismissToast?: (id: number) => void;
19- __dumber_clearToasts?: () => void;
20- __dumber_showZoomToast?: (zoomLevel: number) => void;
21-
22- // Theme integration
23- __dumber_initial_theme?: string;
24- __dumber_setTheme?: (theme: "light" | "dark") => void;
25- __dumber_color_scheme_manager?: ThemeManagerAPI;
26-
27- // History callbacks
28- __dumber_history_recent?: (data: HistoryEntry[]) => void;
29- __dumber_history_error?: (error: string) => void;
30-
31- // Shortcuts callbacks
32- __dumber_shortcuts_loaded?: (data: Record<string, SearchShortcut>) => void;
33- __dumber_shortcuts_error?: (error: string) => void;
34-
35- // WebKit message handler
36- webkit?: {
37- messageHandlers?: {
38- dumber?: {
39- postMessage: (message: unknown) => void;
40- };
41- };
42- };
43- }
44-}
45-
46-export interface ToastAPI {
47- show: (
48- message: string,
49- duration?: number,
50- type?: "info" | "success" | "error",
51- ) => number;
52- dismiss: (id: number) => void;
53- clear: () => void;
54- showZoom: (zoomLevel: number) => void;
55-}
56-
57-export interface ThemeAPI {
58- setTheme: (theme: "light" | "dark") => void;
59- getCurrentTheme: () => "light" | "dark";
60-}
61-
62-// Message types for WebKit communication
63-export interface BrowserMessage {
64- action: string;
65- data?: unknown;
66-}
67-
68-export {}; // Make this file a module
D webui/src/lib/utils.ts +0 -13
@@ -1,13 +0,0 @@
1-import { clsx, type ClassValue } from "clsx";
2-import { twMerge } from "tailwind-merge";
3-
4-export function cn(...inputs: ClassValue[]) {
5- return twMerge(clsx(inputs));
6-}
7-
8-// eslint-disable-next-line @typescript-eslint/no-explicit-any
9-export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
10-// eslint-disable-next-line @typescript-eslint/no-explicit-any
11-export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
12-export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
13-export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
D webui/src/lib/utils/keys.ts +0 -51
@@ -1,51 +0,0 @@
1-/**
2- * Format a keybinding string for display.
3- * Converts internal key names to user-friendly display format.
4- *
5- * Examples:
6- * "ctrl+shift+a" -> "Ctrl + Shift + A"
7- * "arrowleft" -> "Left"
8- * "alt+[" -> "Alt + ["
9- */
10-export function formatKey(key: string): string {
11- // Split by '+' separator first, then format each part
12- // This avoids replacing '+' characters that are actual key names
13- const parts = key.split("+");
14-
15- const formattedParts = parts.map((part) => {
16- const lower = part.toLowerCase();
17- switch (lower) {
18- case "arrowleft":
19- return "Left";
20- case "arrowright":
21- return "Right";
22- case "arrowup":
23- return "Up";
24- case "arrowdown":
25- return "Down";
26- case "ctrl":
27- return "Ctrl";
28- case "alt":
29- return "Alt";
30- case "shift":
31- return "Shift";
32- case "space":
33- return "Space";
34- case "enter":
35- return "Enter";
36- case "tab":
37- return "Tab";
38- case "backspace":
39- return "Backspace";
40- case "escape":
41- return "Esc";
42- default:
43- // Capitalize single letters, leave symbols as-is
44- return part.length === 1 && /[a-z]/i.test(part)
45- ? part.toUpperCase()
46- : part;
47- }
48- });
49-
50- return formattedParts.join(" + ");
51-}
D webui/src/pages/ConfigPage.svelte +0 -761
@@ -1,761 +0,0 @@
1-<script lang="ts">
2- import { onMount } from "svelte";
3- import { ModeWatcher, resetMode, setMode } from "mode-watcher";
4- import ConfigShell from "./config/ConfigShell.svelte";
5- import ShortcutsTable from "./config/ShortcutsTable.svelte";
6- import KeybindingsTab from "./config/KeybindingsTab.svelte";
7- import * as AlertDialog from "$lib/components/ui/alert-dialog";
8- import { Button } from "$lib/components/ui/button";
9- import { ColorPicker } from "$lib/components/ui/color-picker";
10- import { Input } from "$lib/components/ui/input";
11- import { Label } from "$lib/components/ui/label";
12- import { Spinner } from "$lib/components/ui/spinner";
13- import * as Card from "$lib/components/ui/card";
14- import * as Tabs from "$lib/components/ui/tabs";
15- import { FlaskConical, RefreshCw } from "@lucide/svelte";
16-
17- function getErrorMessage(e: unknown): string {
18- if (e instanceof Error) return e.message;
19- if (typeof e === "string") return e;
20- return "Unknown error";
21- }
22-
23- type ColorPalette = {
24- background: string;
25- surface: string;
26- surface_variant: string;
27- text: string;
28- muted: string;
29- accent: string;
30- border: string;
31- };
32-
33- type SearchShortcut = {
34- url: string;
35- description: string;
36- };
37-
38- type ResolvedPerformance = {
39- skia_cpu_threads: number;
40- skia_gpu_threads: number;
41- web_process_memory_mb: number;
42- network_process_memory_mb: number;
43- webview_pool_prewarm: number;
44- conservative_threshold: number;
45- strict_threshold: number;
46- };
47-
48- type HardwareInfo = {
49- cpu_cores: number;
50- cpu_threads: number;
51- total_ram_mb: number;
52- gpu_vendor: string;
53- gpu_name: string;
54- vram_mb: number;
55- };
56-
57- type CustomPerformance = {
58- skia_cpu_threads: number;
59- skia_gpu_threads: number;
60- web_process_memory_mb: number;
61- network_process_memory_mb: number;
62- webview_pool_prewarm: number;
63- };
64-
65- type PerformanceConfig = {
66- profile: string;
67- custom: CustomPerformance;
68- resolved: ResolvedPerformance;
69- hardware: HardwareInfo;
70- };
71-
72- type ConfigDTO = {
73- appearance: {
74- sans_font: string;
75- serif_font: string;
76- monospace_font: string;
77- default_font_size: number;
78- color_scheme: string;
79- light_palette: ColorPalette;
80- dark_palette: ColorPalette;
81- };
82- performance: PerformanceConfig;
83- default_ui_scale: number;
84- default_search_engine: string;
85- search_shortcuts: Record<string, SearchShortcut>;
86- engine_type: string;
87- };
88-
89- const PERFORMANCE_PROFILES = [
90- { value: "default", label: "Default", description: "No tuning, uses WebKit defaults. Recommended for most users." },
91- { value: "lite", label: "Lite", description: "Reduced resource usage for low-RAM systems (< 4GB) or battery saving." },
92- { value: "max", label: "Max", description: "Maximum responsiveness, scales based on detected hardware." },
93- { value: "custom", label: "Custom", description: "Manual control via config file. Edit config.toml to set individual values." },
94- ];
95-
96- const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [
97- { key: "background", label: "Background" },
98- { key: "surface", label: "Surface" },
99- { key: "surface_variant", label: "Surface Variant" },
100- { key: "text", label: "Text" },
101- { key: "muted", label: "Muted" },
102- { key: "accent", label: "Accent" },
103- { key: "border", label: "Border" },
104- ];
105-
106- function isHexColor(value: string): boolean {
107- return /^#[0-9a-fA-F]{6}$/.test(value);
108- }
109-
110- // UI state
111- let loading = $state(true);
112- let loadError = $state<string | null>(null);
113- let config = $state<ConfigDTO | null>(null);
114- let saving = $state(false);
115- let saveSuccess = $state(false);
116- let saveError = $state<string | null>(null);
117- let showRestartWarning = $state(false);
118- let themeEvents = $state(0);
119- let activeTab = $state("appearance");
120-
121- function getResolvedDarkPreference(fallback?: boolean): boolean {
122- if (typeof fallback === "boolean") {
123- return fallback;
124- }
125- if (typeof window.__dumber_cef_prefers_dark === "boolean") {
126- return window.__dumber_cef_prefers_dark;
127- }
128- if (typeof window.__dumber_gtk_prefers_dark === "boolean") {
129- return window.__dumber_gtk_prefers_dark;
130- }
131- return document.documentElement.classList.contains("dark");
132- }
133-
134- function applyResolvedTheme(prefersDark: boolean): void {
135- const root = document.documentElement;
136- root.classList.toggle("dark", prefersDark);
137- root.classList.toggle("light", !prefersDark);
138- root.style.colorScheme = prefersDark ? "dark" : "light";
139- window.__dumber_cef_prefers_dark = prefersDark;
140- window.__dumber_gtk_prefers_dark = prefersDark;
141- }
142-
143- function syncModeWatcherPreference(colorScheme: string | undefined, prefersDark?: boolean): void {
144- switch (colorScheme) {
145- case "prefer-dark":
146- setMode("dark");
147- applyResolvedTheme(true);
148- break;
149- case "prefer-light":
150- setMode("light");
151- applyResolvedTheme(false);
152- break;
153- default:
154- resetMode();
155- applyResolvedTheme(getResolvedDarkPreference(prefersDark));
156- break;
157- }
158- }
159-
160- function isPerformanceSupported(cfg: ConfigDTO | null): boolean {
161- return cfg != null && cfg.engine_type !== "cef";
162- }
163-
164- // Load config from API
165- async function loadConfig() {
166- loading = true;
167- loadError = null;
168- try {
169- const response = await fetch("/api/config");
170- if (!response.ok) throw new Error("Failed to fetch config");
171- config = (await response.json()) as ConfigDTO;
172- syncModeWatcherPreference(config.appearance.color_scheme);
173- if (!isPerformanceSupported(config) && activeTab === "performance") {
174- activeTab = "appearance";
175- }
176- } catch (e: unknown) {
177- loadError = getErrorMessage(e);
178- console.error("[config] load failed", e);
179- } finally {
180- loading = false;
181- }
182- }
183-
184- // Reload config (used after save to get normalized values)
185- function reloadConfig() {
186- loadConfig();
187- }
188-
189- let resetDialogOpen = $state(false);
190-
191- async function doResetToDefaults() {
192- saveSuccess = false;
193- try {
194- const response = await fetch("/api/config/default");
195- if (!response.ok) throw new Error("Failed to fetch default config");
196- config = (await response.json()) as ConfigDTO;
197- syncModeWatcherPreference(config.appearance.color_scheme);
198- if (!isPerformanceSupported(config) && activeTab === "performance") {
199- activeTab = "appearance";
200- }
201- } catch (e: unknown) {
202- console.error("[config] reset defaults failed", e);
203- }
204- resetDialogOpen = false;
205- }
206-
207- function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
208- const bridge = window.webkit?.messageHandlers?.dumber ?? window.dumber;
209- if (bridge && typeof bridge.postMessage === "function") {
210- return bridge;
211- }
212- return null;
213- }
214-
215- function getWebViewId(): number {
216- return window.__dumber_webview_id || 0;
217- }
218-
219- async function saveConfig() {
220- saving = true;
221- saveSuccess = false;
222- saveError = null;
223-
224- try {
225- const bridge = getWebKitBridge();
226- const webviewId = getWebViewId();
227-
228- console.debug("[config] save click", {
229- hasBridge: Boolean(bridge),
230- webviewId,
231- hasConfig: Boolean(config),
232- });
233-
234- if (!bridge) {
235- throw new Error("Dumber bridge not available (not running inside Dumber)");
236- }
237- if (!config) {
238- throw new Error("Config not loaded");
239- }
240-
241- let timeout: number | null = window.setTimeout(() => {
242- if (saving) {
243- console.warn("[config] save timed out (no callback)");
244- saveError = "Save timed out (no response from native handler)";
245- saving = false;
246- }
247- }, 8000);
248-
249- const clearSaveTimeout = () => {
250- if (timeout != null) {
251- window.clearTimeout(timeout);
252- timeout = null;
253- }
254- };
255-
256- window.__dumber_config_saved = (resp?: unknown) => {
257- clearSaveTimeout();
258- console.debug("[config] save success", resp);
259- saveSuccess = true;
260- saving = false;
261-
262- // Show restart warning if performance tab was active
263- if (activeTab === "performance" && isPerformanceSupported(config)) {
264- showRestartWarning = true;
265- }
266-
267- syncModeWatcherPreference(config?.appearance.color_scheme);
268-
269- // Refresh config from backend (in case watcher normalized values)
270- reloadConfig();
271-
272- setTimeout(() => {
273- saveSuccess = false;
274- }, 3000);
275- };
276- window.__dumber_config_error = (msg: unknown) => {
277- clearSaveTimeout();
278- console.error("[config] save error", msg);
279- saveError = typeof msg === "string" ? msg : "Failed to save config";
280- saving = false;
281- };
282-
283- const payload = $state.snapshot(config);
284-
285- try {
286- bridge.postMessage({
287- type: "save_config",
288- webview_id: webviewId,
289- payload,
290- });
291- } catch (postErr) {
292- console.error("[config] postMessage threw", postErr);
293- throw postErr;
294- }
295- } catch (e: unknown) {
296- console.error("[config] save exception", e);
297- saveError = getErrorMessage(e);
298- saving = false;
299- }
300- }
301-
302- function updateShortcuts(nextShortcuts: Record<string, SearchShortcut>) {
303- if (!config) return;
304- config.search_shortcuts = nextShortcuts;
305- }
306-
307- onMount(() => {
308- console.debug("[config] mount", {
309- webviewId: window.__dumber_webview_id,
310- hasBridge: Boolean(getWebKitBridge()),
311- });
312-
313- const onThemeChanged = (e: Event) => {
314- themeEvents += 1;
315- const detail = (e as CustomEvent<{ prefersDark?: boolean }>).detail;
316- syncModeWatcherPreference(config?.appearance.color_scheme, detail?.prefersDark);
317- console.debug("[config] theme changed", detail);
318- };
319- window.addEventListener("dumber:theme-changed", onThemeChanged);
320-
321- // Load config on mount
322- loadConfig();
323-
324- return () => {
325- window.removeEventListener("dumber:theme-changed", onThemeChanged);
326- };
327- });
328-</script>
329-
330-<ModeWatcher />
331-<ConfigShell>
332- <div class="flex w-full flex-col gap-6 px-6 py-8">
333- {#if loading}
334- <!-- Loading state with spinner -->
335- <div class="flex flex-col items-center justify-center gap-4 py-16">
336- <Spinner class="size-8" />
337- <div class="text-xs font-semibold uppercase tracking-[0.4em] text-muted-foreground">
338- Loading configuration…
339- </div>
340- </div>
341- {:else if loadError}
342- <!-- Error state -->
343- <div class="flex flex-col items-center justify-center gap-4 py-16">
344- <Card.Root class="max-w-md">
345- <Card.Header>
346- <Card.Title class="text-destructive">Failed to load config</Card.Title>
347- <Card.Description>{loadError}</Card.Description>
348- </Card.Header>
349- <Card.Content>
350- <Button variant="outline" onclick={reloadConfig}>Try again</Button>
351- </Card.Content>
352- </Card.Root>
353- </div>
354- {:else if config}
355- <Tabs.Root bind:value={activeTab} class="w-full">
356- <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
357- <div class="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">
358- Config Workspace
359- </div>
360- <Tabs.List class="bg-muted/60">
361- <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
362- <Tabs.Trigger value="search">Search</Tabs.Trigger>
363- <Tabs.Trigger value="keybindings">Keybindings</Tabs.Trigger>
364- <Tabs.Trigger
365- value="performance"
366- disabled={!isPerformanceSupported(config)}
367- title={!isPerformanceSupported(config) ? "Performance tuning is only available with the WebKit engine" : undefined}
368- >
369- Performance
370- </Tabs.Trigger>
371- </Tabs.List>
372- </div>
373-
374- {#if !isPerformanceSupported(config)}
375- <div class="rounded-md border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
376- Performance settings are disabled while using the CEF engine because these options are specific to WebKitGTK.
377- </div>
378- {/if}
379-
380- <Tabs.Content value="appearance">
381- <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
382- <Card.Header>
383- <Card.Title>Appearance</Card.Title>
384- <Card.Description>Fonts, palette, and theme preferences.</Card.Description>
385- </Card.Header>
386- <Card.Content class="space-y-8">
387- <div class="grid gap-6 md:grid-cols-2">
388- <div class="space-y-2">
389- <Label for="sans_font">Sans-Serif Font</Label>
390- <Input id="sans_font" bind:value={config.appearance.sans_font} />
391- </div>
392- <div class="space-y-2">
393- <Label for="serif_font">Serif Font</Label>
394- <Input id="serif_font" bind:value={config.appearance.serif_font} />
395- </div>
396- <div class="space-y-2">
397- <Label for="monospace_font">Monospace Font</Label>
398- <Input id="monospace_font" bind:value={config.appearance.monospace_font} />
399- </div>
400- <div class="space-y-2">
401- <Label for="font_size">Default Font Size</Label>
402- <Input id="font_size" type="number" bind:value={config.appearance.default_font_size} />
403- </div>
404- <div class="space-y-2">
405- <Label for="color_scheme">Color Scheme</Label>
406- <select
407- id="color_scheme"
408- bind:value={config.appearance.color_scheme}
409- class="flex h-10 w-full border border-border bg-background px-3 py-2 text-sm text-foreground ring-offset-background transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
410- >
411- <option value="default">Follow System</option>
412- <option value="prefer-dark">Always Dark</option>
413- <option value="prefer-light">Always Light</option>
414- </select>
415- </div>
416- <div class="space-y-2">
417- <Label for="ui_scale">UI Scale</Label>
418- <Input
419- id="ui_scale"
420- type="number"
421- step="0.1"
422- bind:value={config.default_ui_scale}
423- />
424- </div>
425- </div>
426-
427- <div class="space-y-6">
428- <div class="space-y-3">
429- <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
430- Theme: Light
431- </div>
432- <div class="grid gap-4 md:grid-cols-2">
433- {#each PALETTE_FIELDS as field (field.key)}
434- <div class="space-y-2">
435- <Label for={`light_${field.key}`}>{field.label}</Label>
436- <div class="flex items-center gap-3">
437- <ColorPicker
438- id={`light_${field.key}`}
439- value={config.appearance.light_palette[field.key]}
440- onValueChange={(v) => {
441- config!.appearance.light_palette[field.key] = v;
442- }}
443- />
444- <Input bind:value={config.appearance.light_palette[field.key]} />
445- </div>
446- {#if !isHexColor(config.appearance.light_palette[field.key])}
447- <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
448- {/if}
449- </div>
450- {/each}
451- </div>
452- </div>
453-
454- <div class="space-y-3">
455- <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
456- Theme: Dark
457- </div>
458- <div class="grid gap-4 md:grid-cols-2">
459- {#each PALETTE_FIELDS as field (field.key)}
460- <div class="space-y-2">
461- <Label for={`dark_${field.key}`}>{field.label}</Label>
462- <div class="flex items-center gap-3">
463- <ColorPicker
464- id={`dark_${field.key}`}
465- value={config.appearance.dark_palette[field.key]}
466- onValueChange={(v) => {
467- config!.appearance.dark_palette[field.key] = v;
468- }}
469- />
470- <Input bind:value={config.appearance.dark_palette[field.key]} />
471- </div>
472- {#if !isHexColor(config.appearance.dark_palette[field.key])}
473- <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
474- {/if}
475- </div>
476- {/each}
477- </div>
478- </div>
479- </div>
480- </Card.Content>
481- </Card.Root>
482- </Tabs.Content>
483-
484- <Tabs.Content value="search">
485- <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
486- <Card.Header>
487- <Card.Title>Search</Card.Title>
488- <Card.Description>Default engine and shortcuts.</Card.Description>
489- </Card.Header>
490- <Card.Content class="space-y-8">
491- <div class="space-y-2">
492- <Label for="search_engine">Default Search Engine (use %s for query)</Label>
493- <Input id="search_engine" bind:value={config.default_search_engine} />
494- </div>
495-
496- <div class="space-y-3">
497- <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
498- Search Shortcuts
499- </div>
500- <p class="text-sm text-muted-foreground">Map prefixes like g to URL templates.</p>
501- <ShortcutsTable shortcuts={config.search_shortcuts} onUpdate={updateShortcuts} />
502- </div>
503- </Card.Content>
504- </Card.Root>
505- </Tabs.Content>
506-
507- <Tabs.Content value="keybindings">
508- <KeybindingsTab />
509- </Tabs.Content>
510-
511- <Tabs.Content value="performance">
512- <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
513- <Card.Header>
514- <Card.Title>Performance Profile</Card.Title>
515- <Card.Description>
516- WebKitGTK rendering and memory tuning presets.
517- </Card.Description>
518- </Card.Header>
519- <Card.Content class="space-y-6">
520- {#if !isPerformanceSupported(config)}
521- <div class="rounded-md border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
522- This section is unavailable under the CEF engine.
523- </div>
524- {:else}
525- <!-- Detected Hardware Card -->
526- {#if config.performance?.hardware}
527- {@const hw = config.performance.hardware}
528- <div class="rounded-md border border-border bg-muted/30 px-4 py-3">
529- <div class="mb-2 text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
530- Detected Hardware
531- </div>
532- <div class="flex flex-wrap gap-x-6 gap-y-1 text-sm">
533- <div><span class="text-muted-foreground">CPU:</span> <span class="font-mono">{hw.cpu_cores}c/{hw.cpu_threads}t</span></div>
534- <div><span class="text-muted-foreground">RAM:</span> <span class="font-mono">{hw.total_ram_mb > 0 ? (hw.total_ram_mb / 1024).toFixed(0) + ' GB' : '?'}</span></div>
535- <div><span class="text-muted-foreground">GPU:</span> <span class="font-mono capitalize">{hw.gpu_vendor || 'Unknown'}</span></div>
536- <div><span class="text-muted-foreground">VRAM:</span> <span class="font-mono">{hw.vram_mb > 0 ? (hw.vram_mb / 1024).toFixed(0) + ' GB' : (hw.gpu_vendor === 'intel' ? 'Shared' : '?')}</span></div>
537- </div>
538- </div>
539- {/if}
540-
541- <!-- Experimental feature warning -->
542- <div class="flex items-start gap-3 rounded-md border border-info/30 bg-info/10 px-4 py-3">
543- <FlaskConical class="mt-0.5 size-4 shrink-0 text-info" />
544- <div class="space-y-1">
545- <p class="text-sm font-medium text-info">Experimental Feature</p>
546- <p class="text-xs text-muted-foreground">
547- Performance profiles are experimental. Results may vary depending on your hardware and WebKitGTK version.
548- </p>
549- </div>
550- </div>
551-
552- <!-- Profile selector -->
553- {#if config.performance}
554- <div class="space-y-4">
555- {#each PERFORMANCE_PROFILES as profile (profile.value)}
556- <label
557- class="flex cursor-pointer items-start gap-4 rounded-md border p-4 transition-colors hover:bg-muted/50 {config.performance.profile === profile.value ? 'border-primary bg-primary/5' : ''}"
558- >
559- <input
560- type="radio"
561- name="performance_profile"
562- value={profile.value}
563- checked={config.performance.profile === profile.value}
564- onchange={() => config!.performance.profile = profile.value}
565- class="mt-1 accent-primary"
566- />
567- <div class="space-y-1">
568- <div class="font-medium">{profile.label}</div>
569- <div class="text-sm text-muted-foreground">{profile.description}</div>
570- </div>
571- </label>
572- {/each}
573- </div>
574- {:else}
575- <div class="text-sm text-muted-foreground">
576- Performance settings not available. This may be due to a configuration error.
577- </div>
578- {/if}
579-
580- <!-- Custom profile fields -->
581- {#if config.performance?.profile === "custom"}
582- <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4">
583- <div class="text-sm font-semibold uppercase tracking-[0.15em] text-muted-foreground">
584- Custom Settings
585- </div>
586- <div class="grid gap-4 md:grid-cols-2">
587- <div class="space-y-2">
588- <Label for="skia_cpu_threads">Skia CPU Threads</Label>
589- <Input
590- id="skia_cpu_threads"
591- type="number"
592- min="0"
593- max="8"
594- bind:value={config.performance.custom.skia_cpu_threads}
595- />
596- <p class="text-xs text-muted-foreground">0 = unset (WebKit default). Max 8.</p>
597- </div>
598- <div class="space-y-2">
599- <Label for="skia_gpu_threads">Skia GPU Threads</Label>
600- <Input
601- id="skia_gpu_threads"
602- type="number"
603- min="-1"
604- bind:value={config.performance.custom.skia_gpu_threads}
605- />
606- <p class="text-xs text-muted-foreground">-1 = unset, 0 = disable GPU tiles.</p>
607- </div>
608- <div class="space-y-2">
609- <Label for="web_process_memory">Web Process Memory (MB)</Label>
610- <Input
611- id="web_process_memory"
612- type="number"
613- min="0"
614- step="256"
615- bind:value={config.performance.custom.web_process_memory_mb}
616- />
617- <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p>
618- </div>
619- <div class="space-y-2">
620- <Label for="network_process_memory">Network Process Memory (MB)</Label>
621- <Input
622- id="network_process_memory"
623- type="number"
624- min="0"
625- step="128"
626- bind:value={config.performance.custom.network_process_memory_mb}
627- />
628- <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p>
629- </div>
630- <div class="space-y-2">
631- <Label for="webview_pool_prewarm">WebView Pool Prewarm</Label>
632- <Input
633- id="webview_pool_prewarm"
634- type="number"
635- min="0"
636- max="20"
637- bind:value={config.performance.custom.webview_pool_prewarm}
638- />
639- <p class="text-xs text-muted-foreground">Pre-created WebViews at startup.</p>
640- </div>
641- </div>
642- </div>
643- {/if}
644-
645- <!-- Resolved values display -->
646- {#if config.performance?.resolved}
647- <div class="space-y-3">
648- <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
649- Resolved Settings (applied on restart)
650- </div>
651- <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4 text-sm">
652- <div class="space-y-1">
653- <div class="flex justify-between">
654- <span class="font-medium">Skia CPU Threads</span>
655- <span class="font-mono">{config.performance.resolved.skia_cpu_threads === 0 ? 'unset' : config.performance.resolved.skia_cpu_threads}</span>
656- </div>
657- <p class="text-xs text-muted-foreground">Parallel threads for CPU-based 2D rendering. More threads = faster paint operations.</p>
658- </div>
659- <div class="space-y-1">
660- <div class="flex justify-between">
661- <span class="font-medium">Skia GPU Threads</span>
662- <span class="font-mono">{config.performance.resolved.skia_gpu_threads === -1 ? 'unset' : config.performance.resolved.skia_gpu_threads}</span>
663- </div>
664- <p class="text-xs text-muted-foreground">Threads for GPU texture uploads and shader compilation.</p>
665- </div>
666- <div class="space-y-1">
667- <div class="flex justify-between">
668- <span class="font-medium">Web Process Memory</span>
669- <span class="font-mono">{config.performance.resolved.web_process_memory_mb === 0 ? 'unset' : config.performance.resolved.web_process_memory_mb + ' MB'}</span>
670- </div>
671- <p class="text-xs text-muted-foreground">Memory limit for web content processes (JavaScript, DOM, rendering).</p>
672- </div>
673- <div class="space-y-1">
674- <div class="flex justify-between">
675- <span class="font-medium">Network Process Memory</span>
676- <span class="font-mono">{config.performance.resolved.network_process_memory_mb === 0 ? 'unset' : config.performance.resolved.network_process_memory_mb + ' MB'}</span>
677- </div>
678- <p class="text-xs text-muted-foreground">Memory limit for network operations (HTTP cache, connections).</p>
679- </div>
680- <div class="space-y-1">
681- <div class="flex justify-between">
682- <span class="font-medium">WebView Pool Prewarm</span>
683- <span class="font-mono">{config.performance.resolved.webview_pool_prewarm}</span>
684- </div>
685- <p class="text-xs text-muted-foreground">Pre-created WebViews at startup for faster new tab/pane creation.</p>
686- </div>
687- </div>
688- </div>
689- {/if}
690- {/if}
691- </Card.Content>
692- </Card.Root>
693- </Tabs.Content>
694- </Tabs.Root>
695-
696- <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4">
697- <div class="flex items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground">
698- {#if saveSuccess}
699- <span class="text-primary">Saved</span>
700- {:else}
701- <span>Ready</span>
702- {/if}
703- {#if themeEvents > 0}
704- <span>Theme x{themeEvents}</span>
705- {/if}
706- </div>
707- {#if activeTab !== "keybindings"}
708- <div class="flex items-center gap-3">
709- <AlertDialog.Root bind:open={resetDialogOpen}>
710- <AlertDialog.Trigger disabled={saving}>
711- {#snippet child({ props })}
712- <Button variant="outline" {...props} type="button">
713- Reset Defaults
714- </Button>
715- {/snippet}
716- </AlertDialog.Trigger>
717- <AlertDialog.Content>
718- <AlertDialog.Header>
719- <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>
720- <AlertDialog.Description>
721- This will reset all settings on this page to their default values.
722- You will still need to click Save to persist the changes.
723- </AlertDialog.Description>
724- </AlertDialog.Header>
725- <AlertDialog.Footer>
726- <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
727- <AlertDialog.Action onclick={doResetToDefaults}>
728- Reset Defaults
729- </AlertDialog.Action>
730- </AlertDialog.Footer>
731- </AlertDialog.Content>
732- </AlertDialog.Root>
733- <Button onclick={saveConfig} disabled={saving} type="button">
734- {saving ? "Saving…" : "Save"}
735- </Button>
736- </div>
737- {/if}
738- </div>
739-
740- <!-- Save error display -->
741- {#if saveError}
742- <div class="border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
743- {saveError}
744- </div>
745- {/if}
746-
747- <!-- Restart warning after saving performance settings -->
748- {#if showRestartWarning}
749- <div class="flex items-start gap-3 border border-warning/30 bg-warning/10 px-4 py-3">
750- <RefreshCw class="mt-0.5 size-4 shrink-0 text-warning" />
751- <div class="space-y-1">
752- <p class="text-sm font-medium text-warning">Restart Required</p>
753- <p class="text-xs text-muted-foreground">
754- Performance settings are applied at browser startup. Restart Dumber for changes to take effect.
755- </p>
756- </div>
757- </div>
758- {/if}
759- {/if}
760- </div>
761-</ConfigShell>
D webui/src/pages/ErrorPage.svelte +0 -213
@@ -1,213 +0,0 @@
1-<script lang="ts">
2- import { onMount } from 'svelte';
3- import { ModeWatcher } from 'mode-watcher';
4- import { Button } from '$lib/components/ui/button';
5- import * as Card from '$lib/components/ui/card';
6-
7- type ErrorConfig = {
8- code: number;
9- title: string;
10- message: string;
11- icon: string;
12- actions: string[];
13- };
14-
15- const defaultConfig: ErrorConfig = {
16- code: 404,
17- title: 'Page Not Found',
18- message: 'The page you are looking for does not exist.',
19- icon: 'warning',
20- actions: ['back'],
21- };
22-
23- // Error configurations
24- const errorConfigs: Record<string, ErrorConfig> = {
25- blocked: { code: 0, title: 'Page Blocked', message: 'Content blocked by filter', icon: 'shield', actions: ['whitelist', 'bypass', 'back'] },
26- '404': defaultConfig,
27- '500': { code: 500, title: 'Server Error', message: 'Something went wrong on the server.', icon: 'error', actions: ['retry', 'back'] },
28- '502': { code: 502, title: 'Bad Gateway', message: 'The server received an invalid response.', icon: 'error', actions: ['retry', 'back'] },
29- '503': { code: 503, title: 'Service Unavailable', message: 'The server is temporarily unavailable.', icon: 'error', actions: ['retry', 'back'] },
30- offline: { code: 0, title: 'You Are Offline', message: 'Check your internet connection.', icon: 'offline', actions: ['retry', 'back'] },
31- ssl: { code: 0, title: 'Connection Not Secure', message: 'The security certificate is invalid.', icon: 'lock', actions: ['bypass', 'back'] },
32- timeout: { code: 0, title: 'Connection Timed Out', message: 'The server took too long to respond.', icon: 'warning', actions: ['retry', 'back'] },
33- };
34-
35- let config = $state<ErrorConfig>(defaultConfig);
36- let targetUrl = $state('');
37- let targetDomain = $state('');
38- let customMessage = $state('');
39- let isLoading = $state(false);
40- let actionError = $state('');
41-
42- onMount(() => {
43- const params = new URLSearchParams(window.location.search);
44- const typeParam = params.get('type') || params.get('code') || '404';
45-
46- const selectedConfig = errorConfigs[typeParam];
47- config = selectedConfig ?? {
48- code: parseInt(typeParam) || 0,
49- title: `Error ${typeParam}`,
50- message: 'An unexpected error occurred.',
51- icon: 'error',
52- actions: ['retry', 'back'],
53- };
54-
55- targetUrl = params.get('url') || '';
56- customMessage = params.get('reason') || params.get('message') || '';
57-
58- if (targetUrl) {
59- try {
60- targetDomain = new URL(targetUrl).hostname;
61- } catch {
62- targetDomain = targetUrl;
63- }
64- }
65- });
66-
67- function sendMessage(type: string, payload: Record<string, unknown>): Promise<unknown> {
68- return new Promise((resolve, reject) => {
69- const requestId = `error_${Date.now()}_${Math.random().toString(36).slice(2)}`;
70- const handler = (event: MessageEvent) => {
71- if (event.data?.requestId === requestId) {
72- window.removeEventListener('message', handler);
73- if (event.data.success) {
74- resolve(event.data.data);
75- } else {
76- reject(new Error(event.data.error || 'Unknown error'));
77- }
78- }
79- };
80- window.addEventListener('message', handler);
81- if (window.webkit?.messageHandlers?.dumber) {
82- window.webkit.messageHandlers.dumber.postMessage(JSON.stringify({ type, requestId, payload }));
83- } else {
84- reject(new Error('WebKit message handler not available'));
85- }
86- setTimeout(() => { window.removeEventListener('message', handler); reject(new Error('Timeout')); }, 10000);
87- });
88- }
89-
90- async function addToWhitelist() {
91- if (!targetDomain) return;
92- isLoading = true;
93- actionError = '';
94- try {
95- await sendMessage('addToWhitelist', { domain: targetDomain });
96- window.location.href = targetUrl;
97- } catch (e) {
98- actionError = e instanceof Error ? e.message : 'Failed';
99- isLoading = false;
100- }
101- }
102-
103- async function bypassOnce() {
104- if (!targetUrl) return;
105- isLoading = true;
106- actionError = '';
107- try {
108- await sendMessage('bypassOnce', { url: targetUrl });
109- window.location.href = targetUrl;
110- } catch (e) {
111- actionError = e instanceof Error ? e.message : 'Failed';
112- isLoading = false;
113- }
114- }
115-
116- function retry() {
117- if (targetUrl) {
118- window.location.href = targetUrl;
119- } else {
120- window.location.reload();
121- }
122- }
123-
124- function goBack() {
125- window.history.back();
126- }
127-
128- const icons: Record<string, string> = {
129- shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/>',
130- warning: '<path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>',
131- error: '<circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6"/><path d="M9 9l6 6"/>',
132- offline: '<line x1="1" y1="1" x2="23" y2="23"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.58 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/>',
133- lock: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
134- };
135-
136- const iconColors: Record<string, string> = {
137- shield: 'text-primary',
138- warning: 'text-yellow-500',
139- error: 'text-destructive',
140- offline: 'text-muted-foreground',
141- lock: 'text-yellow-500',
142- };
143-</script>
144-
145-<svelte:head>
146- <title>{config.title}</title>
147-</svelte:head>
148-
149-<ModeWatcher />
150-<div class="min-h-screen flex items-center justify-center p-8 bg-background">
151- <Card.Root class="w-full max-w-md">
152- <Card.Header class="text-center">
153- <div class="flex justify-center mb-4">
154- <svg class="w-16 h-16 {iconColors[config.icon] || 'text-muted-foreground'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
155- <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static SVG paths -->
156- {@html icons[config.icon] || icons.error}
157- </svg>
158- </div>
159- {#if config.code > 0}
160- <div class="text-6xl font-bold text-muted-foreground/30 mb-2">{config.code}</div>
161- {/if}
162- <Card.Title class="text-xl">{config.title}</Card.Title>
163- <Card.Description>{customMessage || config.message}</Card.Description>
164- </Card.Header>
165-
166- <Card.Content class="space-y-4">
167- {#if targetUrl}
168- <div class="bg-muted p-3 rounded-md">
169- <p class="text-xs text-muted-foreground uppercase tracking-wide mb-1">URL</p>
170- <code class="text-sm break-all">{targetUrl}</code>
171- </div>
172- {/if}
173-
174- {#if actionError}
175- <div class="bg-destructive/10 border border-destructive/30 text-destructive p-3 rounded-md text-sm">
176- {actionError}
177- </div>
178- {/if}
179- </Card.Content>
180-
181- <Card.Footer class="flex flex-col gap-3">
182- {#if config.actions.includes('whitelist') && targetDomain}
183- <Button class="w-full" onclick={addToWhitelist} disabled={isLoading}>
184- {isLoading ? 'Adding...' : 'Add to Whitelist'}
185- </Button>
186- {/if}
187-
188- {#if config.actions.includes('bypass') && targetUrl}
189- <Button variant="outline" class="w-full" onclick={bypassOnce} disabled={isLoading}>
190- Proceed Anyway
191- </Button>
192- {/if}
193-
194- {#if config.actions.includes('retry')}
195- <Button class="w-full" onclick={retry} disabled={isLoading}>
196- Try Again
197- </Button>
198- {/if}
199-
200- {#if config.actions.includes('back')}
201- <Button variant="ghost" class="w-full" onclick={goBack} disabled={isLoading}>
202- Go Back
203- </Button>
204- {/if}
205-
206- {#if config.actions.includes('whitelist') && targetDomain}
207- <p class="text-xs text-muted-foreground text-center">
208- Adding <strong class="text-foreground">{targetDomain}</strong> to whitelist will disable content filtering.
209- </p>
210- {/if}
211- </Card.Footer>
212- </Card.Root>
213-</div>
D webui/src/pages/Homepage.svelte +0 -17
@@ -1,17 +0,0 @@
1-<script lang="ts">
2- import { ModeWatcher } from 'mode-watcher';
3- import { HomepageShell } from './homepage/shell';
4- import { HistoryPanel, FavoritesPanel, AnalyticsPanel } from './homepage/panels';
5- import { homepageState } from './homepage/state.svelte';
6-</script>
7-
8-<ModeWatcher />
9-<HomepageShell>
10- {#if homepageState.activePanel === 'history'}
11- <HistoryPanel />
12- {:else if homepageState.activePanel === 'favorites'}
13- <FavoritesPanel />
14- {:else if homepageState.activePanel === 'analytics'}
15- <AnalyticsPanel />
16- {/if}
17-</HomepageShell>
D webui/src/pages/config.ts +0 -10
@@ -1,10 +0,0 @@
1-import { mount } from "svelte";
2-import ConfigPage from "./ConfigPage.svelte";
3-import "../styles/app.css";
4-
5-// Mount the config page
6-const app = mount(ConfigPage, {
7- target: document.body,
8-});
9-
10-export default app;
D webui/src/pages/config/ConfigShell.svelte +0 -227
@@ -1,227 +0,0 @@
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- if (typeof document.startViewTransition !== 'function') {
13- window.location.href = url;
14- return;
15- }
16-
17- const transition = document.startViewTransition(() => {
18- document.documentElement.dataset.vt = 'leaving';
19- });
20-
21- transition.finished.finally(() => {
22- window.location.href = url;
23- });
24- }
25-
26- onMount(() => {
27- // Avoid default WebKit focus rings.
28- document.documentElement.dataset.vt = 'ready';
29- });
30-</script>
31-
32-<svelte:head>
33- <title>dumb://config</title>
34- <meta name="description" content="Dumber Browser - Settings" />
35- <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static styles for shell initialization -->
36- {@html `<style>
37- html, body { margin: 0; padding: 0; }
38- html { background: var(--background, #0a0a0a); }
39- body { background: var(--background, #0a0a0a); color: var(--foreground, #e5e5e5); }
40- *:focus { outline: none !important; }
41- *:focus-visible { outline: none !important; }
42- button:focus, input:focus, a:focus { outline: none !important; box-shadow: none !important; }
43- * { -webkit-overflow-scrolling: touch; }
44- </style>`}
45-</svelte:head>
46-
47-<div class="config-shell browser-ui">
48- <div class="terminal-frame">
49- <header class="terminal-header">
50- <div class="header-left">
51- <span class="terminal-path">dumb://config</span>
52- </div>
53- <div class="header-right">
54- <button
55- class="action-button"
56- type="button"
57- onclick={() => navigateWithViewTransition('dumb://home')}
58- title="Back to Home"
59- >
60- <Home size={14} strokeWidth={2} />
61- <span class="button-text">HOME</span>
62- </button>
63- </div>
64- </header>
65-
66- <main class="terminal-body scrollable">
67- {#if children}
68- {@render children()}
69- {/if}
70- </main>
71-
72- <footer class="terminal-footer">
73- <span class="footer-text">Dumber Browser</span>
74- </footer>
75- </div>
76-</div>
77-
78-<style>
79- :global(html),
80- :global(body) {
81- height: 100%;
82- }
83-
84- :global(body) {
85- overflow: hidden;
86- overscroll-behavior: contain;
87- }
88-
89- .config-shell {
90- height: 100vh;
91- min-height: 100vh;
92- display: flex;
93- flex-direction: column;
94- font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
95- line-height: 1.5;
96- color: var(--foreground);
97- background: var(--background);
98- overflow: hidden;
99- }
100-
101- .terminal-frame {
102- flex: 1;
103- display: grid;
104- grid-template-rows: auto 1fr auto;
105- width: 100%;
106- height: 100%;
107- background: color-mix(in srgb, var(--card) 60%, var(--background) 40%);
108- }
109-
110- :global(.dark) .terminal-frame {
111- box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.5);
112- }
113-
114- .terminal-header {
115- display: flex;
116- align-items: center;
117- justify-content: space-between;
118- gap: 1rem;
119- padding: 0.75rem 1rem;
120- min-height: 3.5rem;
121- border-bottom: 1px solid var(--border);
122- background: color-mix(in srgb, var(--background) 90%, var(--card) 10%);
123- position: sticky;
124- top: 0;
125- z-index: 5;
126- }
127-
128- .header-left {
129- display: flex;
130- align-items: center;
131- gap: 1.5rem;
132- }
133-
134- .terminal-path {
135- font-size: 0.8rem;
136- font-weight: 600;
137- color: var(--primary, #4ade80);
138- letter-spacing: 0.05em;
139- }
140-
141- .header-right {
142- display: flex;
143- align-items: center;
144- gap: 0.5rem;
145- }
146-
147- .action-button {
148- display: flex;
149- align-items: center;
150- gap: 0.5rem;
151- padding: 0.4rem 0.75rem;
152- font-size: 0.7rem;
153- font-weight: 500;
154- letter-spacing: 0.08em;
155- color: var(--muted-foreground);
156- background: transparent;
157- border-width: 1px;
158- border-style: solid;
159- border-color: var(--border);
160- cursor: pointer;
161- transition: all 120ms ease;
162- }
163-
164- .action-button:hover,
165- .action-button:focus-visible {
166- color: var(--foreground);
167- border-color: color-mix(in srgb, var(--border) 50%, var(--foreground) 50%);
168- background: color-mix(in srgb, var(--card) 30%, transparent);
169- outline: none;
170- }
171-
172- .terminal-body {
173- display: flex;
174- flex-direction: column;
175- min-height: 0;
176- overflow: hidden;
177- background: var(--background);
178- }
179-
180- .terminal-body.scrollable {
181- overflow-y: auto;
182- -webkit-overflow-scrolling: touch;
183- touch-action: pan-y;
184- }
185-
186- .terminal-footer {
187- display: flex;
188- align-items: center;
189- justify-content: center;
190- padding: 0.75rem 1rem;
191- border-top: 1px solid var(--border);
192- background: color-mix(in srgb, var(--background) 92%, var(--card) 8%);
193- }
194-
195- .footer-text {
196- font-size: 0.7rem;
197- letter-spacing: 0.08em;
198- color: var(--muted-foreground);
199- }
200-
201- /* View Transition helpers */
202- :global(html[data-vt='leaving']) {
203- view-transition-name: config-root;
204- }
205-
206- :global(::view-transition-old(config-root)) {
207- animation: vt-fade-out 180ms ease both;
208- }
209-
210- :global(::view-transition-new(config-root)) {
211- animation: vt-fade-in 220ms ease both;
212- }
213-
214- @keyframes vt-fade-out {
215- to {
216- opacity: 0;
217- transform: translateY(6px);
218- }
219- }
220-
221- @keyframes vt-fade-in {
222- from {
223- opacity: 0;
224- transform: translateY(-6px);
225- }
226- }
227-</style>
D webui/src/pages/config/KeyCaptureModal.svelte +0 -184
@@ -1,184 +0,0 @@
1-<script lang="ts">
2- import { onMount, untrack } from "svelte";
3- import { Button } from "$lib/components/ui/button";
4- import * as AlertDialog from "$lib/components/ui/alert-dialog";
5- import { Keyboard, X, Plus } from "@lucide/svelte";
6- import { formatKey } from "$lib/utils/keys";
7-
8- type KeybindingEntry = {
9- action: string;
10- description: string;
11- keys: string[];
12- default_keys: string[];
13- is_custom: boolean;
14- };
15-
16- interface Props {
17- binding: KeybindingEntry;
18- onSave: (keys: string[]) => void;
19- onCancel: () => void;
20- }
21-
22- let { binding, onSave, onCancel }: Props = $props();
23-
24- // Initialize with a plain copy of the binding keys using $state.snapshot()
25- // to strip Svelte 5 Proxy wrappers. untrack() ensures we only read the initial value.
26- let newKeys = $state<string[]>(
27- untrack(() => $state.snapshot(binding.keys) as string[])
28- );
29- let isCapturing = $state(false);
30- let capturedKey = $state<string | null>(null);
31- let isSaving = $state(false);
32-
33- // Symbol keys that are already shifted (pressing shift produces these characters)
34- // Don't add 'shift' modifier for these since they're self-describing
35- const SHIFTED_SYMBOLS = new Set([
36- "{", "}", "(", ")", "<", ">", "!", "@", "#", "$", "%", "^", "&", "*",
37- "+", "_", ":", '"', "?", "|", "~",
38- ]);
39-
40- function handleKeyDown(e: KeyboardEvent) {
41- if (!isCapturing) return;
42- e.preventDefault();
43- e.stopPropagation();
44-
45- const parts: string[] = [];
46- if (e.ctrlKey) parts.push("ctrl");
47- if (e.altKey) parts.push("alt");
48-
49- // Ignore modifier-only presses
50- if (["control", "alt", "shift", "meta"].includes(e.key.toLowerCase())) {
51- return;
52- }
53-
54- // Escape closes the modal
55- if (e.key === "Escape") {
56- onCancel();
57- return;
58- }
59-
60- // Normalize key name
61- const key = e.key === " " ? "space" : e.key.toLowerCase();
62-
63- // Only add shift modifier for letter keys and non-symbol keys
64- // Symbol keys like { } + etc. are already the shifted result
65- if (e.shiftKey && !SHIFTED_SYMBOLS.has(e.key)) {
66- parts.push("shift");
67- }
68-
69- parts.push(key);
70- capturedKey = parts.join("+");
71- isCapturing = false;
72- }
73-
74- function addCapturedKey() {
75- if (capturedKey && !newKeys.includes(capturedKey)) {
76- newKeys = [...newKeys, capturedKey];
77- }
78- capturedKey = null;
79- }
80-
81- function removeKey(index: number) {
82- newKeys = newKeys.filter((_, i) => i !== index);
83- }
84-
85- function handleSave() {
86- // Use $state.snapshot() to get plain array without Svelte 5 Proxy wrappers
87- const keysToSave = $state.snapshot(newKeys) as string[];
88- // Auto-add any pending captured key before saving
89- if (capturedKey && !keysToSave.includes(capturedKey)) {
90- keysToSave.push(capturedKey);
91- }
92- isSaving = true;
93- try {
94- onSave(keysToSave);
95- } finally {
96- // Reset isSaving so modal remains dismissible if save fails
97- isSaving = false;
98- }
99- }
100-
101- onMount(() => {
102- const handler = (e: KeyboardEvent) => handleKeyDown(e);
103- window.addEventListener("keydown", handler, true);
104- return () => window.removeEventListener("keydown", handler, true);
105- });
106-</script>
107-
108-<AlertDialog.Root open={true} onOpenChange={(open) => !open && !isSaving && onCancel()}>
109- <AlertDialog.Content class="max-w-md">
110- <AlertDialog.Header>
111- <AlertDialog.Title>Edit Keybinding</AlertDialog.Title>
112- <AlertDialog.Description>
113- Configure keybindings for <strong>{binding.action}</strong>
114- </AlertDialog.Description>
115- </AlertDialog.Header>
116-
117- <div class="space-y-4 py-4">
118- <!-- Current bindings -->
119- <div class="space-y-2">
120- <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
121- Current Bindings
122- </div>
123- <div class="flex flex-wrap gap-2">
124- {#each newKeys as key, i (key)}
125- <div class="flex items-center gap-1 rounded bg-muted px-2 py-1">
126- <kbd class="text-xs font-mono">{formatKey(key)}</kbd>
127- <button
128- type="button"
129- onclick={() => removeKey(i)}
130- class="text-muted-foreground hover:text-foreground"
131- >
132- <X class="size-3" />
133- </button>
134- </div>
135- {/each}
136- {#if newKeys.length === 0}
137- <span class="text-sm text-muted-foreground">(no bindings)</span>
138- {/if}
139- </div>
140- </div>
141-
142- <!-- Capture area -->
143- <div class="space-y-2">
144- <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
145- Add New Binding
146- </div>
147- <div class="flex gap-2">
148- <button
149- type="button"
150- onclick={() => { isCapturing = true; capturedKey = null; }}
151- class="flex flex-1 items-center justify-center gap-2 rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground transition hover:border-foreground hover:text-foreground {isCapturing ? 'border-primary bg-primary/5 text-primary' : ''}"
152- >
153- <Keyboard class="size-5" />
154- {#if isCapturing}
155- Press a key combination...
156- {:else if capturedKey}
157- <kbd class="font-mono">{formatKey(capturedKey)}</kbd>
158- {:else}
159- Click to capture key
160- {/if}
161- </button>
162- {#if capturedKey}
163- <Button variant="outline" onclick={addCapturedKey}>
164- <Plus class="mr-1 size-4" />
165- Add
166- </Button>
167- {/if}
168- </div>
169- </div>
170-
171- <!-- Default hint -->
172- {#if binding.default_keys.length > 0}
173- <div class="text-xs text-muted-foreground">
174- Default: {binding.default_keys.map(formatKey).join(", ")}
175- </div>
176- {/if}
177- </div>
178-
179- <AlertDialog.Footer>
180- <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
181- <AlertDialog.Action onclick={handleSave}>Save</AlertDialog.Action>
182- </AlertDialog.Footer>
183- </AlertDialog.Content>
184-</AlertDialog.Root>
D webui/src/pages/config/KeybindingGroup.svelte +0 -95
@@ -1,95 +0,0 @@
1-<script lang="ts">
2- import { Button } from "$lib/components/ui/button";
3- import * as Table from "$lib/components/ui/table";
4- import { RotateCcw, Edit3 } from "@lucide/svelte";
5- import { formatKey } from "$lib/utils/keys";
6-
7- type KeybindingEntry = {
8- action: string;
9- description: string;
10- keys: string[];
11- default_keys: string[];
12- is_custom: boolean;
13- };
14-
15- type KeybindingGroupData = {
16- mode: string;
17- display_name: string;
18- bindings: KeybindingEntry[];
19- activation?: string;
20- };
21-
22- interface Props {
23- group: KeybindingGroupData;
24- onEdit: (action: string, entry: KeybindingEntry) => void;
25- onReset: (action: string) => void;
26- }
27-
28- let { group, onEdit, onReset }: Props = $props();
29-</script>
30-
31-<div class="space-y-3">
32- <div class="flex items-center gap-2">
33- <h3 class="text-sm font-semibold uppercase tracking-[0.15em] text-muted-foreground">
34- {group.display_name}
35- </h3>
36- {#if group.activation}
37- <span class="rounded-md bg-muted px-2 py-0.5 text-xs font-mono text-muted-foreground">
38- {formatKey(group.activation)}
39- </span>
40- {/if}
41- </div>
42-
43- <Table.Root class="rounded-md border border-border">
44- <Table.Header>
45- <Table.Row>
46- <Table.Head class="w-[180px]">Action</Table.Head>
47- <Table.Head>Description</Table.Head>
48- <Table.Head class="w-[200px]">Keybinding</Table.Head>
49- <Table.Head class="w-[100px] text-right">Actions</Table.Head>
50- </Table.Row>
51- </Table.Header>
52- <Table.Body>
53- {#each group.bindings as binding (binding.action)}
54- <Table.Row class={binding.is_custom ? "bg-accent/5" : ""}>
55- <Table.Cell class="font-mono text-sm">{binding.action}</Table.Cell>
56- <Table.Cell class="text-muted-foreground">{binding.description}</Table.Cell>
57- <Table.Cell>
58- <div class="flex flex-wrap gap-1">
59- {#each binding.keys as key (key)}
60- <kbd class="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
61- {formatKey(key)}
62- </kbd>
63- {/each}
64- {#if binding.keys.length === 0}
65- <span class="text-xs text-muted-foreground">(unbound)</span>
66- {/if}
67- </div>
68- </Table.Cell>
69- <Table.Cell class="text-right">
70- <div class="flex justify-end gap-1">
71- <Button
72- variant="ghost"
73- size="icon"
74- onclick={() => onEdit(binding.action, binding)}
75- aria-label="Edit binding"
76- >
77- <Edit3 class="size-4" />
78- </Button>
79- {#if binding.is_custom}
80- <Button
81- variant="ghost"
82- size="icon"
83- onclick={() => onReset(binding.action)}
84- aria-label="Reset to default"
85- >
86- <RotateCcw class="size-4" />
87- </Button>
88- {/if}
89- </div>
90- </Table.Cell>
91- </Table.Row>
92- {/each}
93- </Table.Body>
94- </Table.Root>
95-</div>
D webui/src/pages/config/KeybindingsTab.svelte +0 -340
@@ -1,340 +0,0 @@
1-<script lang="ts">
2- import { onMount } from "svelte";
3- import { Button } from "$lib/components/ui/button";
4- import { Spinner } from "$lib/components/ui/spinner";
5- import * as Card from "$lib/components/ui/card";
6- import * as AlertDialog from "$lib/components/ui/alert-dialog";
7- import { RefreshCw, RotateCcw, AlertTriangle, CheckCircle } from "@lucide/svelte";
8- import { postMessage, getWebKitBridge, getWebViewId } from "$lib/bridge";
9- import KeybindingGroup from "./KeybindingGroup.svelte";
10- import KeyCaptureModal from "./KeyCaptureModal.svelte";
11-
12- type KeybindingEntry = {
13- action: string;
14- description: string;
15- keys: string[];
16- default_keys: string[];
17- is_custom: boolean;
18- };
19-
20- type KeybindingGroupData = {
21- mode: string;
22- display_name: string;
23- bindings: KeybindingEntry[];
24- activation?: string;
25- };
26-
27- type KeybindingsConfig = {
28- groups: KeybindingGroupData[];
29- };
30-
31- type KeybindingConflict = {
32- conflicting_action: string;
33- conflicting_mode: string;
34- key: string;
35- };
36-
37- let loading = $state(true);
38- let error = $state<string | null>(null);
39- let keybindings = $state<KeybindingsConfig | null>(null);
40- let editingBinding = $state<{ mode: string; action: string; entry: KeybindingEntry } | null>(null);
41- let resetAllDialogOpen = $state(false);
42- let successMessage = $state<string | null>(null);
43- let conflicts = $state<KeybindingConflict[]>([]);
44- let hasPendingReset = $state(false);
45- let savingReset = $state(false);
46-
47- // Type-safe callback setup helper
48- function setupCallback<T>(name: string, handler: (data: T) => void): void {
49- (window as unknown as Record<string, (data: T) => void>)[name] = handler;
50- }
51-
52- async function loadKeybindings() {
53- loading = true;
54- error = null;
55-
56- if (!getWebKitBridge()) {
57- error = "Dumber bridge not available";
58- loading = false;
59- return;
60- }
61-
62- setupCallback<KeybindingsConfig>("__dumber_keybindings_loaded", (data) => {
63- keybindings = data;
64- loading = false;
65- });
66- setupCallback<string>("__dumber_keybindings_error", (msg) => {
67- error = typeof msg === "string" ? msg : "Failed to load keybindings";
68- loading = false;
69- });
70-
71- try {
72- postMessage({
73- type: "get_keybindings",
74- webviewId: getWebViewId(),
75- payload: {},
76- });
77- } catch (err) {
78- error = err instanceof Error ? err.message : "Failed to load keybindings";
79- loading = false;
80- }
81- }
82-
83- function startEdit(mode: string, action: string, entry: KeybindingEntry) {
84- editingBinding = { mode, action, entry };
85- }
86-
87- function showSuccess(message: string) {
88- successMessage = message;
89- setTimeout(() => { successMessage = null; }, 3000);
90- }
91-
92- async function saveBinding(keys: string[]) {
93- if (!editingBinding) return;
94-
95- if (!getWebKitBridge()) {
96- error = "Dumber bridge not available";
97- return;
98- }
99-
100- // Timeout to detect if backend never responds (message silently dropped)
101- const timeoutId = setTimeout(() => {
102- error = "Save timed out - backend did not respond";
103- }, 5000);
104-
105- setupCallback<{ status: string; conflicts?: KeybindingConflict[] }>(
106- "__dumber_keybinding_set",
107- (response) => {
108- clearTimeout(timeoutId);
109- if (!response || response.status !== "success") {
110- error = "Failed to save keybinding: invalid response";
111- return;
112- }
113- conflicts = response.conflicts?.length ? response.conflicts : [];
114- loadKeybindings();
115- editingBinding = null;
116- showSuccess("Keybinding saved successfully");
117- }
118- );
119- setupCallback<string>("__dumber_keybinding_set_error", (msg) => {
120- clearTimeout(timeoutId);
121- error = typeof msg === "string" ? msg : "Failed to save keybinding";
122- });
123-
124- // Create plain payload - extract primitive values explicitly to avoid any proxy issues
125- const mode = String(editingBinding.mode);
126- const action = String(editingBinding.action);
127- const payload = { mode, action, keys: [...keys] };
128-
129- try {
130- postMessage({
131- type: "set_keybinding",
132- webviewId: getWebViewId(),
133- payload,
134- });
135- } catch (err) {
136- clearTimeout(timeoutId);
137- error = err instanceof Error ? err.message : "Failed to save keybinding";
138- }
139- }
140-
141- async function resetBinding(mode: string, action: string) {
142- if (!getWebKitBridge()) return;
143-
144- setupCallback<void>("__dumber_keybinding_reset", () => {
145- conflicts = [];
146- loadKeybindings();
147- showSuccess("Keybinding reset to default");
148- });
149- setupCallback<string>("__dumber_keybinding_reset_error", (msg) => {
150- error = typeof msg === "string" ? msg : "Failed to reset keybinding";
151- });
152-
153- try {
154- postMessage({
155- type: "reset_keybinding",
156- webviewId: getWebViewId(),
157- payload: { mode, action },
158- });
159- } catch (err) {
160- error = err instanceof Error ? err.message : "Failed to reset keybinding";
161- }
162- }
163-
164- // Reset All: update local state to defaults WITHOUT saving
165- function resetAllBindings() {
166- if (!keybindings) return;
167-
168- // Reset each binding's keys to default_keys in local state
169- const resetGroups = keybindings.groups.map(group => ({
170- ...group,
171- bindings: group.bindings.map(binding => ({
172- ...binding,
173- keys: [...binding.default_keys],
174- is_custom: false,
175- })),
176- }));
177-
178- keybindings = { groups: resetGroups };
179- hasPendingReset = true;
180- conflicts = [];
181- resetAllDialogOpen = false;
182- showSuccess("Preview: all keybindings reset to defaults. Click Save to apply.");
183- }
184-
185- // Actually save the reset to backend
186- async function saveResetAll() {
187- if (!getWebKitBridge()) return;
188-
189- savingReset = true;
190-
191- setupCallback<void>("__dumber_keybindings_reset_all", () => {
192- hasPendingReset = false;
193- savingReset = false;
194- loadKeybindings();
195- showSuccess("All keybindings saved successfully");
196- });
197- setupCallback<string>("__dumber_keybindings_reset_all_error", (msg) => {
198- error = typeof msg === "string" ? msg : "Failed to save keybindings";
199- savingReset = false;
200- });
201-
202- try {
203- postMessage({
204- type: "reset_all_keybindings",
205- webviewId: getWebViewId(),
206- payload: {},
207- });
208- } catch (err) {
209- error = err instanceof Error ? err.message : "Failed to save keybindings";
210- savingReset = false;
211- }
212- }
213-
214- // Cancel pending reset and reload from backend
215- function cancelReset() {
216- hasPendingReset = false;
217- loadKeybindings();
218- }
219-
220- onMount(() => {
221- loadKeybindings();
222- });
223-</script>
224-
225-<Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
226- <Card.Header>
227- <div class="flex items-center justify-between">
228- <div>
229- <Card.Title>Keybindings</Card.Title>
230- <Card.Description>
231- Customize keyboard shortcuts for all modes.
232- </Card.Description>
233- </div>
234- <div class="flex gap-2">
235- {#if hasPendingReset}
236- <Button variant="outline" size="sm" onclick={cancelReset} disabled={savingReset}>
237- Cancel
238- </Button>
239- <Button size="sm" onclick={saveResetAll} disabled={savingReset}>
240- {savingReset ? "Saving…" : "Save"}
241- </Button>
242- {:else}
243- <Button variant="outline" size="sm" onclick={loadKeybindings}>
244- <RefreshCw class="mr-1 size-4" />
245- Refresh
246- </Button>
247- <AlertDialog.Root bind:open={resetAllDialogOpen}>
248- <AlertDialog.Trigger>
249- {#snippet child({ props })}
250- <Button variant="outline" size="sm" {...props}>
251- <RotateCcw class="mr-1 size-4" />
252- Reset All
253- </Button>
254- {/snippet}
255- </AlertDialog.Trigger>
256- <AlertDialog.Content>
257- <AlertDialog.Header>
258- <AlertDialog.Title>Reset all keybindings?</AlertDialog.Title>
259- <AlertDialog.Description>
260- This will preview all keybindings with their default values. You'll need to click Save to apply.
261- </AlertDialog.Description>
262- </AlertDialog.Header>
263- <AlertDialog.Footer>
264- <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
265- <AlertDialog.Action onclick={resetAllBindings}>Reset All</AlertDialog.Action>
266- </AlertDialog.Footer>
267- </AlertDialog.Content>
268- </AlertDialog.Root>
269- {/if}
270- </div>
271- </div>
272- </Card.Header>
273- <Card.Content class="space-y-6">
274- {#if hasPendingReset}
275- <div class="flex items-center gap-2 rounded-md border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400">
276- <AlertTriangle class="size-4" />
277- <span>Unsaved changes: all keybindings previewed with defaults. Click <strong>Save</strong> to apply or <strong>Cancel</strong> to discard.</span>
278- </div>
279- {/if}
280- {#if loading}
281- <div class="flex items-center justify-center py-8">
282- <Spinner class="size-6" />
283- </div>
284- {:else if error}
285- <div class="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
286- <AlertTriangle class="size-4" />
287- {error}
288- </div>
289- {/if}
290- {#if successMessage}
291- <div class="flex items-center gap-2 rounded-md border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-600 dark:text-green-400">
292- <CheckCircle class="size-4" />
293- {successMessage}
294- </div>
295- {/if}
296- {#if conflicts.length > 0}
297- <div class="rounded-md border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-sm">
298- <div class="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
299- <AlertTriangle class="size-4" />
300- <span class="font-medium">Keybinding conflicts detected</span>
301- </div>
302- <ul class="mt-2 list-disc pl-6 text-muted-foreground">
303- {#each conflicts as conflict (conflict.key + conflict.conflicting_action)}
304- <li>
305- <kbd class="font-mono text-xs">{conflict.key}</kbd> is also bound to
306- <strong>{conflict.conflicting_action}</strong> in <em>{conflict.conflicting_mode}</em> mode
307- </li>
308- {/each}
309- </ul>
310- <button
311- type="button"
312- class="mt-2 text-xs text-muted-foreground hover:text-foreground"
313- onclick={() => conflicts = []}
314- >
315- Dismiss
316- </button>
317- </div>
318- {/if}
319- {#if !loading && !error && keybindings}
320- {#each keybindings.groups as group (group.mode)}
321- <KeybindingGroup
322- {group}
323- onEdit={(action, entry) => startEdit(group.mode, action, entry)}
324- onReset={(action) => resetBinding(group.mode, action)}
325- />
326- {/each}
327- {/if}
328- {#if !loading && !keybindings && !error}
329- <div class="text-center text-muted-foreground py-8">No keybindings available</div>
330- {/if}
331- </Card.Content>
332-</Card.Root>
333-
334-{#if editingBinding}
335- <KeyCaptureModal
336- binding={editingBinding.entry}
337- onSave={saveBinding}
338- onCancel={() => (editingBinding = null)}
339- />
340-{/if}
D webui/src/pages/config/ShortcutsTable.svelte +0 -170
@@ -1,170 +0,0 @@
1-<script lang="ts">
2- import { Check, Plus, Trash2, X } from "@lucide/svelte";
3- import { Button } from "$lib/components/ui/button";
4- import { Input } from "$lib/components/ui/input";
5- import * as Table from "$lib/components/ui/table";
6-
7- type SearchShortcut = {
8- url: string;
9- description: string;
10- };
11-
12- interface Props {
13- shortcuts: Record<string, SearchShortcut>;
14- onUpdate: (shortcuts: Record<string, SearchShortcut>) => void;
15- }
16-
17- let { shortcuts, onUpdate }: Props = $props();
18-
19- let editingKey = $state<string | null>(null);
20- let draftKey = $state("");
21- let draftUrl = $state("");
22- let draftDescription = $state("");
23-
24- let newKey = $state("");
25- let newUrl = $state("");
26- let newDescription = $state("");
27-
28- const entries = $derived(
29- Object.entries(shortcuts ?? {}).sort(([a], [b]) => a.localeCompare(b)),
30- );
31-
32- function startEdit(key: string, shortcut: SearchShortcut) {
33- editingKey = key;
34- draftKey = key;
35- draftUrl = shortcut.url;
36- draftDescription = shortcut.description;
37- }
38-
39- function cancelEdit() {
40- editingKey = null;
41- draftKey = "";
42- draftUrl = "";
43- draftDescription = "";
44- }
45-
46- function commitEdit() {
47- if (!editingKey) return;
48- const nextKey = draftKey.trim();
49- const nextUrl = draftUrl.trim();
50- const nextDescription = draftDescription.trim();
51-
52- const updated: Record<string, SearchShortcut> = { ...shortcuts };
53- delete updated[editingKey];
54- if (nextKey) {
55- updated[nextKey] = { url: nextUrl, description: nextDescription };
56- }
57- onUpdate(updated);
58- cancelEdit();
59- }
60-
61- function removeShortcut(key: string) {
62- const updated: Record<string, SearchShortcut> = { ...shortcuts };
63- delete updated[key];
64- onUpdate(updated);
65- }
66-
67- function addShortcut() {
68- const key = newKey.trim();
69- if (!key) return;
70- const updated: Record<string, SearchShortcut> = { ...shortcuts };
71- updated[key] = { url: newUrl.trim(), description: newDescription.trim() };
72- onUpdate(updated);
73- newKey = "";
74- newUrl = "";
75- newDescription = "";
76- }
77-</script>
78-
79-<div class="space-y-4">
80- <Table.Root class="rounded-md border border-border">
81- <Table.Header>
82- <Table.Row>
83- <Table.Head class="w-[140px]">Prefix</Table.Head>
84- <Table.Head>URL Template</Table.Head>
85- <Table.Head>Description</Table.Head>
86- <Table.Head class="w-[160px] text-right">Actions</Table.Head>
87- </Table.Row>
88- </Table.Header>
89- <Table.Body>
90- {#if entries.length === 0}
91- <Table.Row>
92- <Table.Cell colspan={4} class="py-8 text-center text-sm text-muted-foreground">
93- No shortcuts yet. Add one below.
94- </Table.Cell>
95- </Table.Row>
96- {/if}
97- {#each entries as [key, shortcut] (key)}
98- <Table.Row>
99- {#if editingKey === key}
100- <Table.Cell>
101- <Input bind:value={draftKey} class="h-8" />
102- </Table.Cell>
103- <Table.Cell>
104- <Input bind:value={draftUrl} class="h-8" />
105- </Table.Cell>
106- <Table.Cell>
107- <Input bind:value={draftDescription} class="h-8" />
108- </Table.Cell>
109- <Table.Cell class="text-right">
110- <div class="flex justify-end gap-2">
111- <Button variant="ghost" size="icon" onclick={commitEdit} type="button" aria-label="Save">
112- <Check size={16} />
113- </Button>
114- <Button variant="ghost" size="icon" onclick={cancelEdit} type="button" aria-label="Cancel">
115- <X size={16} />
116- </Button>
117- </div>
118- </Table.Cell>
119- {:else}
120- <Table.Cell class="font-mono text-sm">{key}</Table.Cell>
121- <Table.Cell class="truncate max-w-[360px]">{shortcut.url}</Table.Cell>
122- <Table.Cell class="text-muted-foreground">{shortcut.description}</Table.Cell>
123- <Table.Cell class="text-right">
124- <div class="flex justify-end gap-2">
125- <Button
126- variant="outline"
127- size="sm"
128- onclick={() => startEdit(key, shortcut)}
129- type="button"
130- >
131- Edit
132- </Button>
133- <Button
134- variant="ghost"
135- size="icon"
136- onclick={() => removeShortcut(key)}
137- type="button"
138- aria-label={`Delete ${key}`}
139- >
140- <Trash2 size={16} />
141- </Button>
142- </div>
143- </Table.Cell>
144- {/if}
145- </Table.Row>
146- {/each}
147- </Table.Body>
148- </Table.Root>
149-
150- <div class="grid gap-3 md:grid-cols-[140px_1fr_1fr_140px] items-end">
151- <div class="space-y-1">
152- <span class="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">Prefix</span>
153- <Input bind:value={newKey} placeholder="g" />
154- </div>
155- <div class="space-y-1">
156- <span class="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">URL Template</span>
157- <Input bind:value={newUrl} placeholder="https://www.google.com/search?q=%s" />
158- </div>
159- <div class="space-y-1">
160- <span class="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">Description</span>
161- <Input bind:value={newDescription} placeholder="Google" />
162- </div>
163- <div class="flex md:justify-end">
164- <Button variant="default" onclick={addShortcut} type="button" class="w-full md:w-auto">
165- <Plus size={16} />
166- Add Shortcut
167- </Button>
168- </div>
169- </div>
170-</div>
D webui/src/pages/error.ts +0 -11
@@ -1,11 +0,0 @@
1-import { mount } from "svelte";
2-import "../styles/app.css";
3-import ErrorPage from "./ErrorPage.svelte";
4-
5-console.log("[dumber] Mounting ErrorPage component");
6-try {
7- const app = mount(ErrorPage, { target: document.body });
8- console.log("[dumber] ErrorPage mounted successfully", app);
9-} catch (error) {
10- console.error("[dumber] Failed to mount ErrorPage:", error);
11-}
D webui/src/pages/homepage.ts +0 -37
@@ -1,37 +0,0 @@
1-import { mount } from "svelte";
2-import "../styles/app.css";
3-import Homepage from "./Homepage.svelte";
4-
5-// Proactively unregister any Service Workers and clear caches that may have been
6-// installed by previous builds (e.g., SvelteKit), which can otherwise attempt to
7-// request non-existent _app/immutable/* assets under dumb://homepage.
8-async function cleanupServiceWorkersAndCaches() {
9- try {
10- if ("serviceWorker" in navigator) {
11- const regs = await navigator.serviceWorker.getRegistrations();
12- await Promise.all(regs.map((r) => r.unregister().catch(() => {})));
13- }
14- } catch {
15- /* ignore */
16- }
17-
18- try {
19- if ("caches" in window) {
20- const keys = await caches.keys();
21- await Promise.all(keys.map((k) => caches.delete(k).catch(() => false)));
22- }
23- } catch {
24- /* ignore */
25- }
26-}
27-
28-// Mount the Homepage component to the DOM
29-cleanupServiceWorkersAndCaches().finally(() => {
30- console.log("[dumber] Mounting Homepage component to document.body");
31- try {
32- const app = mount(Homepage, { target: document.body });
33- console.log("[dumber] Homepage component mounted successfully", app);
34- } catch (error) {
35- console.error("[dumber] Failed to mount Homepage component:", error);
36- }
37-});
D webui/src/pages/homepage/analytics/DailyVisitsChart.svelte +0 -124
@@ -1,124 +0,0 @@
1-<script lang="ts">
2- import { Chart } from 'frappe-charts';
3- import { onMount } from 'svelte';
4- import type { DailyVisitCount } from '../types';
5-
6- interface Props {
7- dailyVisits: DailyVisitCount[];
8- }
9-
10- let { dailyVisits }: Props = $props();
11-
12- let container: HTMLDivElement;
13- let chart: Chart | null = null;
14-
15- // Get accent color from CSS variable
16- function getAccentColor(): string {
17- return getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#4ade80';
18- }
19-
20- // Convert data to Frappe Charts format
21- const chartData = $derived.by(() => {
22- if (!dailyVisits || dailyVisits.length === 0) {
23- return { labels: [], datasets: [{ values: [] }] };
24- }
25-
26- const labels = dailyVisits.map(d => {
27- const date = new Date(d.day + 'T12:00:00');
28- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
29- });
30- const values = dailyVisits.map(d => d.visits);
31-
32- return {
33- labels,
34- datasets: [{ name: 'Visits', values }]
35- };
36- });
37-
38- const totalVisits = $derived(dailyVisits.reduce((sum, d) => sum + d.visits, 0));
39-
40- // Initialize chart on mount
41- onMount(() => {
42- if (chartData.labels.length === 0) return;
43-
44- chart = new Chart(container, {
45- data: chartData,
46- type: 'line',
47- height: 160,
48- colors: [getAccentColor()],
49- axisOptions: { xAxisMode: 'tick', xIsSeries: true },
50- lineOptions: { regionFill: 1, hideDots: 0, dotSize: 4 },
51- tooltipOptions: { formatTooltipY: (d: number) => `${d} visits` }
52- });
53-
54- return () => {
55- chart?.destroy();
56- chart = null;
57- };
58- });
59-
60- // Update chart when data changes (after initial mount)
61- $effect(() => {
62- // Skip if chart not initialized yet or no data
63- if (!chart) return;
64-
65- // Read chartData to track changes
66- const data = chartData;
67- if (data.labels.length === 0) return;
68-
69- chart.update(data);
70- });
71-</script>
72-
73-<div class="chart-wrapper">
74- <div class="chart-header">
75- <span class="chart-label">Visits</span>
76- <span class="chart-value">{totalVisits} total</span>
77- </div>
78- <div bind:this={container} class="chart-container"></div>
79-</div>
80-
81-<style>
82- .chart-wrapper {
83- width: 100%;
84- display: flex;
85- flex-direction: column;
86- }
87-
88- .chart-header {
89- display: flex;
90- align-items: center;
91- gap: 0.5rem;
92- padding: 0 0.25rem 0.5rem;
93- font-size: 0.65rem;
94- font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
95- }
96-
97- .chart-label {
98- color: var(--primary, #4ade80);
99- font-weight: 600;
100- }
101-
102- .chart-value {
103- color: var(--muted-foreground, #737373);
104- }
105-
106- .chart-container {
107- width: 100%;
108- }
109-
110- .chart-container :global(.frappe-chart) {
111- font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
112- }
113-
114- .chart-container :global(.frappe-chart .axis text),
115- .chart-container :global(.frappe-chart .chart-label) {
116- fill: var(--muted-foreground, #737373);
117- font-size: 10px;
118- }
119-
120- .chart-container :global(.frappe-chart .axis line),
121- .chart-container :global(.frappe-chart .chart-strokes) {
122- stroke: var(--border, #333);
123- }
124-</style>
D webui/src/pages/homepage/analytics/HourlyActivityChart.svelte +0 -130
@@ -1,130 +0,0 @@
1-<script lang="ts">
2- import { Chart } from 'frappe-charts';
3- import { onMount } from 'svelte';
4- import type { HourlyDistribution } from '../types';
5-
6- interface Props {
7- hourlyDistribution: HourlyDistribution[];
8- }
9-
10- let { hourlyDistribution }: Props = $props();
11-
12- let container: HTMLDivElement;
13- let chart: Chart | null = null;
14-
15- // Get accent color from CSS variable
16- function getAccentColor(): string {
17- return getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#4ade80';
18- }
19-
20- // Convert data to Frappe Charts format (ensure all 24 hours)
21- const chartData = $derived.by(() => {
22- // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for data transformation
23- const visitsByHour = new Map<number, number>();
24- hourlyDistribution.forEach(h => visitsByHour.set(h.hour, h.visit_count));
25-
26- const labels = Array.from({ length: 24 }, (_, i) =>
27- i % 6 === 0 ? `${String(i).padStart(2, '0')}h` : ''
28- );
29- const values = Array.from({ length: 24 }, (_, i) => visitsByHour.get(i) ?? 0);
30-
31- return {
32- labels,
33- datasets: [{ name: 'Activity', values }]
34- };
35- });
36-
37- // Peak hour for display
38- const peakHour = $derived.by(() => {
39- let maxVisits = 0;
40- let peak = 0;
41- hourlyDistribution.forEach(h => {
42- if (h.visit_count > maxVisits) {
43- maxVisits = h.visit_count;
44- peak = h.hour;
45- }
46- });
47- return `${String(peak).padStart(2, '0')}:00`;
48- });
49-
50- // Initialize chart on mount
51- onMount(() => {
52- chart = new Chart(container, {
53- data: chartData,
54- type: 'bar',
55- height: 120,
56- colors: [getAccentColor()],
57- axisOptions: { xAxisMode: 'tick' },
58- barOptions: { spaceRatio: 0.3 },
59- tooltipOptions: { formatTooltipY: (d: number) => `${d} visits` }
60- });
61-
62- return () => {
63- chart?.destroy();
64- chart = null;
65- };
66- });
67-
68- // Update chart when data changes (after initial mount)
69- $effect(() => {
70- if (!chart) return;
71-
72- const data = chartData;
73- if (data.labels.length === 0) return;
74-
75- chart.update(data);
76- });
77-</script>
78-
79-<div class="chart-wrapper">
80- <div class="chart-header">
81- <span class="chart-label">Activity</span>
82- <span class="chart-value">peak at {peakHour}</span>
83- </div>
84- <div bind:this={container} class="chart-container"></div>
85-</div>
86-
87-<style>
88- .chart-wrapper {
89- width: 100%;
90- display: flex;
91- flex-direction: column;
92- }
93-
94- .chart-header {
95- display: flex;
96- align-items: center;
97- gap: 0.5rem;
98- padding: 0 0.25rem 0.5rem;
99- font-size: 0.65rem;
100- font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
101- }
102-
103- .chart-label {
104- color: var(--primary, #4ade80);
105- font-weight: 600;
106- }
107-
108- .chart-value {
109- color: var(--muted-foreground, #737373);
110- }
111-
112- .chart-container {
113- width: 100%;
114- }
115-
116- .chart-container :global(.frappe-chart) {
117- font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
118- }
119-
120- .chart-container :global(.frappe-chart .axis text),
121- .chart-container :global(.frappe-chart .chart-label) {
122- fill: var(--muted-foreground, #737373);
123- font-size: 10px;
124- }
125-
126- .chart-container :global(.frappe-chart .axis line),
127- .chart-container :global(.frappe-chart .chart-strokes) {
128- stroke: var(--border, #333);
129- }
130-</style>
D webui/src/pages/homepage/analytics/index.ts +0 -2
@@ -1,2 +0,0 @@
1-export { default as DailyVisitsChart } from './DailyVisitsChart.svelte';
2-export { default as HourlyActivityChart } from './HourlyActivityChart.svelte';
D webui/src/pages/homepage/favorites/FavoriteCard.svelte +0 -280
@@ -1,280 +0,0 @@
1-<script lang="ts">
2- import type { Favorite } from '../types';
3-
4- interface Props {
5- favorite: Favorite;
6- focused?: boolean;
7- onSelect?: (fav: Favorite) => void;
8- onEdit?: (fav: Favorite) => void;
9- }
10-
11- let { favorite, focused = false, onSelect, onEdit }: Props = $props();
12-
13- const getDomain = (url: string): string => {
14- try {
15- return new URL(url).hostname.replace('www.', '');
16- } catch {
17- return url;
18- }
19- };
20-
21- const getFaviconUrl = (fav: Favorite): string => {
22- // Use provided favicon_url if available
23- if (fav.favicon_url) return fav.favicon_url;
24- // Handle internal dumb:// URLs - use embedded favicon
25- if (fav.url.startsWith('dumb://')) {
26- return 'favicon.png';
27- }
28- // Construct DuckDuckGo favicon URL from domain
29- const domain = getDomain(fav.url);
30- return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;
31- };
32-
33- const getDisplayTitle = (fav: Favorite): string => {
34- const trimmed = fav.title?.trim();
35- if (trimmed) return trimmed;
36- return getDomain(fav.url) || fav.url;
37- };
38-
39- const handleSelect = () => {
40- onSelect?.(favorite);
41- };
42-
43- const handleEdit = (e: Event) => {
44- e.stopPropagation();
45- onEdit?.(favorite);
46- };
47-
48- const handleKeyDown = (e: KeyboardEvent) => {
49- switch (e.key) {
50- case 'Enter':
51- handleSelect();
52- break;
53- case 'e':
54- onEdit?.(favorite);
55- break;
56- }
57- };
58-</script>
59-
60-<div
61- class="favorite-card"
62- class:focused
63- role="button"
64- tabindex="0"
65- data-favorite-id={favorite.id}
66- onclick={handleSelect}
67- onkeydown={handleKeyDown}
68->
69- <div class="card-favicon">
70- <img
71- src={getFaviconUrl(favorite)}
72- alt=""
73- class="favicon-img"
74- onerror={(e) => {
75- const target = e.target as HTMLImageElement;
76- target.style.display = 'none';
77- const fallback = target.nextElementSibling as HTMLElement;
78- if (fallback) fallback.style.display = 'flex';
79- }}
80- />
81- <div class="favicon-fallback" style="display: none;"></div>
82- {#if favorite.shortcut_key}
83- <div class="shortcut-badge">{favorite.shortcut_key}</div>
84- {/if}
85- </div>
86-
87- <div class="card-content">
88- <span class="card-title">{getDisplayTitle(favorite)}</span>
89- <span class="card-domain">{getDomain(favorite.url)}</span>
90- </div>
91-
92- {#if favorite.tags && favorite.tags.length > 0}
93- <div class="card-tags">
94- {#each favorite.tags.slice(0, 2) as tag (tag.id)}
95- <span class="tag-chip" style="--tag-color: {tag.color}">
96- {tag.name}
97- </span>
98- {/each}
99- {#if favorite.tags.length > 2}
100- <span class="tag-more">+{favorite.tags.length - 2}</span>
101- {/if}
102- </div>
103- {/if}
104-
105- <div class="card-actions">
106- <button
107- class="action-btn"
108- type="button"
109- onclick={handleEdit}
110- title="Edit (e)"
111- >
112-
113- </button>
114- </div>
115-</div>
116-
117-<style>
118- .favorite-card {
119- display: grid;
120- grid-template-columns: auto 1fr auto auto;
121- align-items: center;
122- gap: 0.75rem;
123- padding: 0.65rem 0.85rem;
124- border-width: 1px;
125- border-style: solid;
126- border-color: var(--border);
127- background: color-mix(in srgb, var(--background) 92%, var(--card) 8%);
128- transition: all 100ms ease;
129- cursor: pointer;
130- }
131-
132- .favorite-card:hover,
133- .favorite-card.focused {
134- background: color-mix(in srgb, var(--card) 40%, var(--background) 60%);
135- border-color: color-mix(in srgb, var(--border) 50%, var(--foreground) 50%);
136- }
137-
138- .favorite-card.focused {
139- outline: 1px solid var(--primary, #4ade80);
140- outline-offset: -1px;
141- }
142-
143- .card-favicon {
144- position: relative;
145- width: 32px;
146- height: 32px;
147- border-width: 1px;
148- border-style: solid;
149- border-color: var(--border);
150- background: var(--background);
151- display: flex;
152- align-items: center;
153- justify-content: center;
154- flex-shrink: 0;
155- }
156-
157- .favicon-img {
158- width: 18px;
159- height: 18px;
160- object-fit: contain;
161- }
162-
163- .favicon-fallback {
164- width: 18px;
165- height: 18px;
166- display: flex;
167- align-items: center;
168- justify-content: center;
169- color: var(--muted-foreground);
170- }
171-
172- .favicon-fallback::before {
173- content: '';
174- font-size: 0.9rem;
175- }
176-
177- .shortcut-badge {
178- position: absolute;
179- bottom: -4px;
180- right: -4px;
181- width: 14px;
182- height: 14px;
183- display: flex;
184- align-items: center;
185- justify-content: center;
186- font-size: 0.55rem;
187- font-weight: 700;
188- color: var(--background);
189- background: var(--primary, #4ade80);
190- border-width: 1px;
191- border-style: solid;
192- border-color: var(--background);
193- }
194-
195- .card-content {
196- display: flex;
197- flex-direction: column;
198- gap: 0.15rem;
199- min-width: 0;
200- }
201-
202- .card-title {
203- font-size: 0.8rem;
204- font-weight: 500;
205- color: var(--foreground);
206- white-space: nowrap;
207- overflow: hidden;
208- text-overflow: ellipsis;
209- }
210-
211- .card-domain {
212- font-size: 0.65rem;
213- color: var(--muted-foreground);
214- letter-spacing: 0.06em;
215- white-space: nowrap;
216- overflow: hidden;
217- text-overflow: ellipsis;
218- }
219-
220- .card-tags {
221- display: flex;
222- align-items: center;
223- gap: 0.3rem;
224- }
225-
226- .tag-chip {
227- font-size: 0.55rem;
228- font-weight: 500;
229- padding: 0.15rem 0.4rem;
230- background: color-mix(in srgb, var(--tag-color) 20%, transparent);
231- border-width: 1px;
232- border-style: solid;
233- border-color: var(--tag-color);
234- color: var(--tag-color);
235- letter-spacing: 0.05em;
236- }
237-
238- .tag-more {
239- font-size: 0.55rem;
240- color: var(--muted-foreground);
241- padding: 0.15rem 0.3rem;
242- border-width: 1px;
243- border-style: solid;
244- border-color: var(--border);
245- }
246-
247- .card-actions {
248- display: flex;
249- gap: 0.3rem;
250- opacity: 0;
251- transition: opacity 100ms ease;
252- }
253-
254- .favorite-card:hover .card-actions,
255- .favorite-card.focused .card-actions {
256- opacity: 1;
257- }
258-
259- .action-btn {
260- width: 24px;
261- height: 24px;
262- display: flex;
263- align-items: center;
264- justify-content: center;
265- font-size: 0.8rem;
266- border-width: 1px;
267- border-style: solid;
268- border-color: var(--border);
269- background: transparent;
270- color: var(--muted-foreground);
271- cursor: pointer;
272- transition: all 100ms ease;
273- }
274-
275- .action-btn:hover {
276- color: var(--foreground);
277- border-color: var(--foreground);
278- background: color-mix(in srgb, var(--card) 50%, transparent);
279- }
280-</style>
D webui/src/pages/homepage/favorites/FavoriteEditor.svelte +0 -495
@@ -1,495 +0,0 @@
1-<script lang="ts">
2- import type { Favorite, Folder, Tag } from '../types';
3-
4- interface Props {
5- favorite: Favorite;
6- folders: Folder[];
7- tags: Tag[];
8- onSave: (updates: Partial<Favorite>) => void;
9- onDelete: (id: number) => void;
10- onClose: () => void;
11- onAssignTag: (favoriteId: number, tagId: number) => void;
12- onRemoveTag: (favoriteId: number, tagId: number) => void;
13- }
14-
15- const props: Props = $props();
16- const { folders, tags, onSave, onDelete, onClose, onAssignTag, onRemoveTag } = props;
17- // Capture initial values for form state (component remounts for different favorites)
18- const initialFavorite = props.favorite;
19-
20- // Form state
21- let title = $state(initialFavorite.title || '');
22- let folderId = $state<number | null>(initialFavorite.folder_id);
23- let shortcutKey = $state<number | null>(initialFavorite.shortcut_key);
24- let confirmDelete = $state(false);
25-
26- // Track current favorite for reactive operations
27- const favorite = $derived(props.favorite);
28-
29- // Get favorite's current tags
30- const favoriteTags = $derived(favorite.tags || []);
31- const hasTag = (tagId: number) => favoriteTags.some(t => t.id === tagId);
32-
33- const handleSave = () => {
34- onSave({
35- id: favorite.id,
36- title: title.trim() || null,
37- folder_id: folderId,
38- shortcut_key: shortcutKey,
39- });
40- onClose();
41- };
42-
43- const handleDelete = () => {
44- if (!confirmDelete) {
45- confirmDelete = true;
46- return;
47- }
48- onDelete(favorite.id);
49- onClose();
50- };
51-
52- const handleTagClick = (tagId: number) => {
53- if (hasTag(tagId)) {
54- onRemoveTag(favorite.id, tagId);
55- } else {
56- onAssignTag(favorite.id, tagId);
57- }
58- };
59-
60- const handleKeyDown = (e: KeyboardEvent) => {
61- switch (e.key) {
62- case 'Escape':
63- e.preventDefault();
64- if (confirmDelete) {
65- confirmDelete = false;
66- } else {
67- onClose();
68- }
69- break;
70- case 'Enter':
71- if (e.ctrlKey || e.metaKey) {
72- e.preventDefault();
73- handleSave();
74- }
75- break;
76- }
77- };
78-
79- const handleOverlayClick = (e: Event) => {
80- if (e.target === e.currentTarget) {
81- onClose();
82- }
83- };
84-</script>
85-
86-<svelte:window onkeydown={handleKeyDown} />
87-
88-<div
89- class="editor-overlay"
90- onclick={handleOverlayClick}
91- onkeydown={(e) => { if (e.key === 'Escape') handleOverlayClick(e); }}
92- role="button"
93- tabindex="0"
94- aria-label="Close editor"
95->
96- <div class="editor-modal">
97- <div class="modal-header">
98- <span class="modal-icon"></span>
99- <span class="modal-title">EDIT FAVORITE</span>
100- <button class="close-btn" type="button" onclick={onClose}>
101- <kbd>Esc</kbd>
102- </button>
103- </div>
104-
105- <div class="modal-body">
106- <!-- URL (readonly) -->
107- <div class="form-group">
108- <span class="form-label" id="url-label">URL</span>
109- <div class="url-display" aria-labelledby="url-label">{favorite.url}</div>
110- </div>
111-
112- <!-- Title -->
113- <div class="form-group">
114- <label class="form-label" for="fav-title">TITLE</label>
115- <input
116- id="fav-title"
117- type="text"
118- class="form-input"
119- bind:value={title}
120- placeholder="Custom title (optional)"
121- />
122- </div>
123-
124- <!-- Folder -->
125- <div class="form-group">
126- <label class="form-label" for="fav-folder">FOLDER</label>
127- <select id="fav-folder" class="form-select" bind:value={folderId}>
128- <option value={null}>— No folder —</option>
129- {#each folders as folder (folder.id)}
130- <option value={folder.id}>
131- {folder.icon || ''} {folder.name}
132- </option>
133- {/each}
134- </select>
135- </div>
136-
137- <!-- Shortcut Key -->
138- <div class="form-group">
139- <label class="form-label" for="fav-shortcut">QUICK ACCESS KEY</label>
140- <select id="fav-shortcut" class="form-select" bind:value={shortcutKey}>
141- <option value={null}>— None —</option>
142- {#each [1, 2, 3, 4, 5, 6, 7, 8, 9] as key (key)}
143- <option value={key}>{key}</option>
144- {/each}
145- </select>
146- <span class="form-hint">Press 1-9 on homepage to open directly</span>
147- </div>
148-
149- <!-- Tags -->
150- <div class="form-group">
151- <span class="form-label" id="tags-label">TAGS</span>
152- <div class="tag-selector" role="group" aria-labelledby="tags-label">
153- {#if tags.length === 0}
154- <span class="no-tags">No tags available</span>
155- {:else}
156- {#each tags as tag (tag.id)}
157- <button
158- class="tag-chip"
159- class:selected={hasTag(tag.id)}
160- type="button"
161- onclick={() => handleTagClick(tag.id)}
162- style="--tag-color: {tag.color}"
163- >
164- <span class="tag-dot"></span>
165- <span class="tag-name">{tag.name}</span>
166- </button>
167- {/each}
168- {/if}
169- </div>
170- </div>
171- </div>
172-
173- <div class="modal-footer">
174- {#if confirmDelete}
175- <div class="confirm-delete">
176- <span class="confirm-text">Delete this favorite?</span>
177- <button class="btn btn-cancel" type="button" onclick={() => confirmDelete = false}>
178- CANCEL
179- </button>
180- <button class="btn btn-danger" type="button" onclick={handleDelete}>
181- DELETE
182- </button>
183- </div>
184- {:else}
185- <button class="btn btn-danger-outline" type="button" onclick={handleDelete}>
186- DELETE
187- </button>
188- <div class="footer-right">
189- <button class="btn btn-secondary" type="button" onclick={onClose}>
190- CANCEL
191- </button>
192- <button class="btn btn-primary" type="button" onclick={handleSave}>
193- SAVE
194- <kbd>Ctrl+Enter</kbd>
195- </button>
196- </div>
197- {/if}
198- </div>
199- </div>
200-</div>
201-
202-<style>
203- .editor-overlay {
204- position: fixed;
205- inset: 0;
206- z-index: 100;
207- display: flex;
208- align-items: center;
209- justify-content: center;
210- background: rgb(0 0 0 / 0.75);
211- backdrop-filter: blur(4px);
212- animation: fade-in 150ms ease;
213- }
214-
215- @keyframes fade-in {
216- from { opacity: 0; }
217- }
218-
219- .editor-modal {
220- width: 100%;
221- max-width: 480px;
222- margin: 1rem;
223- background: var(--card);
224- border-width: 1px;
225- border-style: solid;
226- border-color: var(--border);
227- box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.6);
228- animation: modal-in 150ms ease;
229- }
230-
231- @keyframes modal-in {
232- from {
233- opacity: 0;
234- transform: scale(0.96) translateY(-8px);
235- }
236- }
237-
238- .modal-header {
239- display: flex;
240- align-items: center;
241- gap: 0.6rem;
242- padding: 0.75rem 1rem;
243- border-bottom: 1px solid var(--border);
244- background: color-mix(in srgb, var(--background) 80%, transparent);
245- }
246-
247- .modal-icon::before {
248- content: '';
249- font-size: 0.9rem;
250- color: var(--primary, #4ade80);
251- }
252-
253- .modal-title {
254- flex: 1;
255- font-size: 0.72rem;
256- font-weight: 600;
257- letter-spacing: 0.1em;
258- color: var(--foreground);
259- }
260-
261- .close-btn {
262- padding: 0.25rem 0.5rem;
263- background: transparent;
264- border-width: 1px;
265- border-style: solid;
266- border-color: var(--border);
267- cursor: pointer;
268- }
269-
270- .close-btn kbd {
271- font-size: 0.6rem;
272- color: var(--muted-foreground);
273- }
274-
275- .close-btn:hover {
276- border-color: var(--foreground);
277- }
278-
279- .close-btn:hover kbd {
280- color: var(--foreground);
281- }
282-
283- .modal-body {
284- padding: 1rem;
285- display: flex;
286- flex-direction: column;
287- gap: 1rem;
288- }
289-
290- .form-group {
291- display: flex;
292- flex-direction: column;
293- gap: 0.35rem;
294- }
295-
296- .form-label {
297- font-size: 0.6rem;
298- font-weight: 600;
299- letter-spacing: 0.12em;
300- color: var(--muted-foreground);
301- }
302-
303- .url-display {
304- font-size: 0.72rem;
305- color: var(--foreground);
306- padding: 0.5rem 0.65rem;
307- border-width: 1px;
308- border-style: solid;
309- border-color: var(--border);
310- background: var(--background);
311- white-space: nowrap;
312- overflow: hidden;
313- text-overflow: ellipsis;
314- }
315-
316- .form-input,
317- .form-select {
318- font-family: inherit;
319- font-size: 0.75rem;
320- padding: 0.5rem 0.65rem;
321- border-width: 1px;
322- border-style: solid;
323- border-color: var(--border);
324- background: var(--background);
325- color: var(--foreground);
326- outline: none;
327- transition: border-color 100ms ease;
328- }
329-
330- .form-input:focus,
331- .form-select:focus {
332- border-color: var(--primary, #4ade80);
333- }
334-
335- .form-input::placeholder {
336- color: var(--muted-foreground);
337- }
338-
339- .form-hint {
340- font-size: 0.6rem;
341- color: var(--muted-foreground);
342- letter-spacing: 0.05em;
343- }
344-
345- .tag-selector {
346- display: flex;
347- flex-wrap: wrap;
348- gap: 0.35rem;
349- }
350-
351- .no-tags {
352- font-size: 0.65rem;
353- color: var(--muted-foreground);
354- font-style: italic;
355- }
356-
357- .tag-chip {
358- display: inline-flex;
359- align-items: center;
360- gap: 0.35rem;
361- padding: 0.25rem 0.5rem;
362- font-size: 0.62rem;
363- font-weight: 500;
364- letter-spacing: 0.05em;
365- border-width: 1px;
366- border-style: solid;
367- border-color: var(--tag-color);
368- background: transparent;
369- color: var(--foreground);
370- cursor: pointer;
371- transition: all 100ms ease;
372- }
373-
374- .tag-chip:hover {
375- background: color-mix(in srgb, var(--tag-color) 15%, transparent);
376- }
377-
378- .tag-chip.selected {
379- background: color-mix(in srgb, var(--tag-color) 25%, transparent);
380- border-width: 2px;
381- padding: calc(0.25rem - 1px) calc(0.5rem - 1px);
382- }
383-
384- .tag-dot {
385- width: 6px;
386- height: 6px;
387- background: var(--tag-color);
388- border-radius: 50%;
389- }
390-
391- .tag-name {
392- text-transform: lowercase;
393- }
394-
395- .modal-footer {
396- display: flex;
397- align-items: center;
398- justify-content: space-between;
399- gap: 0.5rem;
400- padding: 0.75rem 1rem;
401- border-top: 1px solid var(--border);
402- background: color-mix(in srgb, var(--background) 60%, transparent);
403- }
404-
405- .footer-right {
406- display: flex;
407- gap: 0.5rem;
408- }
409-
410- .btn {
411- display: inline-flex;
412- align-items: center;
413- gap: 0.5rem;
414- padding: 0.5rem 0.85rem;
415- font-size: 0.68rem;
416- font-weight: 500;
417- letter-spacing: 0.08em;
418- border-width: 1px;
419- border-style: solid;
420- border-color: var(--border);
421- cursor: pointer;
422- transition: all 100ms ease;
423- }
424-
425- .btn kbd {
426- font-size: 0.55rem;
427- padding: 0.1rem 0.3rem;
428- border: 1px solid;
429- opacity: 0.6;
430- }
431-
432- .btn-secondary {
433- background: transparent;
434- color: var(--muted-foreground);
435- }
436-
437- .btn-secondary:hover {
438- color: var(--foreground);
439- border-color: var(--foreground);
440- }
441-
442- .btn-primary {
443- background: var(--primary, #4ade80);
444- color: var(--background);
445- border-color: var(--primary, #4ade80);
446- }
447-
448- .btn-primary:hover {
449- filter: brightness(1.1);
450- }
451-
452- .btn-danger-outline {
453- background: transparent;
454- color: var(--muted-foreground);
455- }
456-
457- .btn-danger-outline:hover {
458- color: var(--destructive);
459- border-color: var(--destructive);
460- }
461-
462- .btn-danger {
463- background: var(--destructive);
464- color: var(--destructive-foreground);
465- border-color: color-mix(in srgb, var(--destructive) 80%, black 20%);
466- }
467-
468- .btn-danger:hover {
469- background: color-mix(in srgb, var(--destructive) 85%, white 15%);
470- }
471-
472- .btn-cancel {
473- background: transparent;
474- color: var(--muted-foreground);
475- }
476-
477- .btn-cancel:hover {
478- color: var(--foreground);
479- border-color: var(--foreground);
480- }
481-
482- .confirm-delete {
483- display: flex;
484- align-items: center;
485- gap: 0.75rem;
486- width: 100%;
487- }
488-
489- .confirm-text {
490- flex: 1;
491- font-size: 0.72rem;
492- color: var(--destructive);
493- font-weight: 500;
494- }
495-</style>
D webui/src/pages/homepage/favorites/FavoriteGrid.svelte +0 -108
@@ -1,108 +0,0 @@
1-<script lang="ts">
2- import type { Favorite } from '../types';
3- import { homepageState } from '../state.svelte';
4- import FavoriteCard from './FavoriteCard.svelte';
5-
6- interface Props {
7- favorites: Favorite[];
8- onSelectFavorite?: (fav: Favorite) => void;
9- onEditFavorite?: (fav: Favorite) => void;
10- }
11-
12- let { favorites, onSelectFavorite, onEditFavorite }: Props = $props();
13-
14- let containerRef = $state<HTMLElement | null>(null);
15-
16- const getFocusedFavorite = () => {
17- return favorites[homepageState.focusedIndex] ?? null;
18- };
19-
20- // Track previous focused index to only scroll on actual navigation
21- let prevFocusedIndex = $state(-1);
22-
23- // Scroll focused item into view when focusedIndex changes via keyboard navigation
24- $effect(() => {
25- // Skip if panel not active or command palette is open
26- if (!containerRef || homepageState.activePanel !== 'favorites') return;
27- if (homepageState.commandPaletteOpen) return;
28-
29- const currentIndex = homepageState.focusedIndex;
30-
31- // Only scroll if index actually changed (not on initial render or modal close)
32- if (prevFocusedIndex === currentIndex) return;
33- if (prevFocusedIndex === -1) {
34- // First render - just track, don't scroll
35- prevFocusedIndex = currentIndex;
36- return;
37- }
38-
39- prevFocusedIndex = currentIndex;
40-
41- const focusedFav = getFocusedFavorite();
42- if (!focusedFav) return;
43-
44- // Find the focused element by favorite ID
45- const el = containerRef.querySelector(`[data-favorite-id="${focusedFav.id}"]`);
46- if (el) {
47- el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
48- }
49- });
50-</script>
51-
52-<div class="favorite-grid" bind:this={containerRef}>
53- {#if favorites.length === 0}
54- <div class="empty-state">
55- <span class="empty-icon"></span>
56- <span class="empty-text">NO FAVORITES</span>
57- <span class="empty-hint">Pin sites from history to add them here</span>
58- </div>
59- {:else}
60- {#each favorites as favorite (favorite.id)}
61- <FavoriteCard
62- {favorite}
63- focused={getFocusedFavorite()?.id === favorite.id}
64- onSelect={onSelectFavorite}
65- onEdit={onEditFavorite}
66- />
67- {/each}
68- {/if}
69-</div>
70-
71-<style>
72- .favorite-grid {
73- display: flex;
74- flex-direction: column;
75- gap: 0.5rem;
76- }
77-
78- .empty-state {
79- flex: 1;
80- display: flex;
81- flex-direction: column;
82- align-items: center;
83- justify-content: center;
84- gap: 0.5rem;
85- padding: 2rem;
86- color: var(--muted-foreground);
87- text-align: center;
88- border: 1px dashed var(--border);
89- background: color-mix(in srgb, var(--background) 95%, var(--card) 5%);
90- }
91-
92- .empty-icon {
93- font-size: 1.5rem;
94- opacity: 0.5;
95- }
96-
97- .empty-text {
98- font-size: 0.75rem;
99- font-weight: 600;
100- letter-spacing: 0.12em;
101- }
102-
103- .empty-hint {
104- font-size: 0.68rem;
105- letter-spacing: 0.06em;
106- opacity: 0.7;
107- }
108-</style>
D webui/src/pages/homepage/favorites/FolderList.svelte +0 -312
@@ -1,312 +0,0 @@
1-<script lang="ts">
2- import type { Folder, Favorite } from '../types';
3-
4- interface Props {
5- folders: Folder[];
6- favorites: Favorite[];
7- selectedFolderId?: number | null;
8- onSelectFolder?: (id: number | null) => void;
9- onCreateFolder?: (name: string) => Promise<void>;
10- }
11-
12- let {
13- folders,
14- favorites,
15- selectedFolderId = null,
16- onSelectFolder,
17- onCreateFolder
18- }: Props = $props();
19-
20- // Inline creation state
21- let isCreating = $state(false);
22- let newFolderName = $state('');
23- let inputRef = $state<HTMLInputElement | null>(null);
24-
25- const startCreating = () => {
26- isCreating = true;
27- newFolderName = '';
28- // Focus input after it renders
29- setTimeout(() => inputRef?.focus(), 0);
30- };
31-
32- const cancelCreating = () => {
33- isCreating = false;
34- newFolderName = '';
35- };
36-
37- const submitCreate = async () => {
38- const name = newFolderName.trim();
39- if (!name || !onCreateFolder) {
40- cancelCreating();
41- return;
42- }
43- try {
44- await onCreateFolder(name);
45- cancelCreating();
46- } catch (e) {
47- console.error('Failed to create folder:', e);
48- }
49- };
50-
51- const handleKeydown = (e: KeyboardEvent) => {
52- if (e.key === 'Enter') {
53- e.preventDefault();
54- submitCreate();
55- } else if (e.key === 'Escape') {
56- cancelCreating();
57- }
58- };
59-
60- // Count favorites per folder
61- const getFolderCount = (folderId: number | null): number => {
62- if (folderId === null) {
63- return favorites.length;
64- }
65- return favorites.filter(f => f.folder_id === folderId).length;
66- };
67-
68- // Count unfiled favorites
69- const unfiledCount = $derived(favorites.filter(f => f.folder_id === null).length);
70-</script>
71-
72-<div class="folder-list">
73- <div class="folder-header">
74- <span class="header-label">FOLDERS</span>
75- {#if isCreating}
76- <button
77- class="add-folder-btn cancel"
78- type="button"
79- onclick={cancelCreating}
80- title="Cancel"
81- >
82- ×
83- </button>
84- {:else}
85- <button
86- class="add-folder-btn"
87- type="button"
88- onclick={startCreating}
89- title="Create folder"
90- >
91- +
92- </button>
93- {/if}
94- </div>
95-
96- {#if isCreating}
97- <div class="create-input-row">
98- <input
99- bind:this={inputRef}
100- bind:value={newFolderName}
101- onkeydown={handleKeydown}
102- onblur={cancelCreating}
103- class="create-input"
104- type="text"
105- placeholder="Folder name..."
106- maxlength="50"
107- />
108- </div>
109- {/if}
110-
111- <div class="folder-items">
112- <!-- All favorites -->
113- <button
114- class="folder-item"
115- class:active={selectedFolderId === null}
116- type="button"
117- onclick={() => onSelectFolder?.(null)}
118- >
119- <span class="folder-icon"></span>
120- <span class="folder-name">All</span>
121- <span class="folder-count">{favorites.length}</span>
122- </button>
123-
124- <!-- User folders -->
125- {#each folders as folder (folder.id)}
126- <button
127- class="folder-item"
128- class:active={selectedFolderId === folder.id}
129- type="button"
130- onclick={() => onSelectFolder?.(folder.id)}
131- >
132- <span class="folder-icon">{folder.icon || ''}</span>
133- <span class="folder-name">{folder.name}</span>
134- <span class="folder-count">{getFolderCount(folder.id)}</span>
135- </button>
136- {/each}
137-
138- <!-- Unfiled -->
139- {#if unfiledCount > 0 && folders.length > 0}
140- <button
141- class="folder-item unfiled"
142- class:active={selectedFolderId === -1}
143- type="button"
144- onclick={() => onSelectFolder?.(-1)}
145- >
146- <span class="folder-icon"></span>
147- <span class="folder-name">Unfiled</span>
148- <span class="folder-count">{unfiledCount}</span>
149- </button>
150- {/if}
151- </div>
152-</div>
153-
154-<style>
155- .folder-list {
156- display: flex;
157- flex-direction: column;
158- border-width: 1px;
159- border-style: solid;
160- border-color: var(--border);
161- background: color-mix(in srgb, var(--background) 92%, var(--card) 8%);
162- }
163-
164- .folder-header {
165- display: flex;
166- align-items: center;
167- justify-content: space-between;
168- padding: 0.5rem 0.75rem;
169- border-bottom: 1px solid var(--border);
170- background: color-mix(in srgb, var(--background) 80%, var(--card) 20%);
171- }
172-
173- .header-label {
174- font-size: 0.6rem;
175- font-weight: 600;
176- color: var(--muted-foreground);
177- letter-spacing: 0.12em;
178- }
179-
180- .add-folder-btn {
181- width: 20px;
182- height: 20px;
183- display: flex;
184- align-items: center;
185- justify-content: center;
186- font-size: 0.9rem;
187- font-weight: 500;
188- border-width: 1px;
189- border-style: solid;
190- border-color: var(--border);
191- background: transparent;
192- color: var(--muted-foreground);
193- cursor: pointer;
194- transition: all 100ms ease;
195- }
196-
197- .add-folder-btn:hover {
198- color: var(--foreground);
199- border-color: var(--foreground);
200- }
201-
202- .add-folder-btn.cancel {
203- color: var(--muted-foreground);
204- }
205-
206- .add-folder-btn.cancel:hover {
207- color: var(--destructive);
208- border-color: var(--destructive);
209- }
210-
211- .create-input-row {
212- padding: 0.4rem 0.5rem;
213- border-bottom: 1px solid var(--border);
214- background: color-mix(in srgb, var(--primary, #4ade80) 8%, var(--background) 92%);
215- }
216-
217- .create-input {
218- width: 100%;
219- padding: 0.35rem 0.5rem;
220- font-size: 0.7rem;
221- font-family: inherit;
222- color: var(--foreground);
223- background: var(--background);
224- border-width: 1px;
225- border-style: solid;
226- border-color: var(--primary, #4ade80);
227- outline: none;
228- }
229-
230- .create-input::placeholder {
231- color: var(--muted-foreground);
232- }
233-
234- .folder-items {
235- display: flex;
236- flex-direction: column;
237- }
238-
239- .folder-item {
240- display: flex;
241- align-items: center;
242- gap: 0.5rem;
243- padding: 0.5rem 0.75rem;
244- text-align: left;
245- background: transparent;
246- border: none;
247- border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
248- cursor: pointer;
249- transition: background-color 100ms ease;
250- }
251-
252- .folder-item:last-child {
253- border-bottom: none;
254- }
255-
256- .folder-item:hover {
257- background: color-mix(in srgb, var(--card) 30%, transparent);
258- }
259-
260- .folder-item.active {
261- background: color-mix(in srgb, var(--card) 50%, transparent);
262- }
263-
264- .folder-item.active::before {
265- content: '';
266- position: absolute;
267- left: 0;
268- top: 0;
269- bottom: 0;
270- width: 2px;
271- background: var(--primary, #4ade80);
272- }
273-
274- .folder-item {
275- position: relative;
276- }
277-
278- .folder-icon {
279- font-size: 0.8rem;
280- width: 1.2rem;
281- text-align: center;
282- color: var(--muted-foreground);
283- }
284-
285- .folder-item.active .folder-icon {
286- color: var(--foreground);
287- }
288-
289- .folder-name {
290- flex: 1;
291- font-size: 0.72rem;
292- color: var(--foreground);
293- white-space: nowrap;
294- overflow: hidden;
295- text-overflow: ellipsis;
296- }
297-
298- .folder-item.unfiled .folder-name {
299- font-style: italic;
300- color: var(--muted-foreground);
301- }
302-
303- .folder-count {
304- font-size: 0.6rem;
305- padding: 0.1rem 0.35rem;
306- border-width: 1px;
307- border-style: solid;
308- border-color: var(--border);
309- background: var(--background);
310- color: var(--muted-foreground);
311- }
312-</style>
D webui/src/pages/homepage/favorites/TagCloud.svelte +0 -292
@@ -1,292 +0,0 @@
1-<script lang="ts">
2- import type { Tag } from '../types';
3-
4- interface Props {
5- tags: Tag[];
6- selectedTagIds?: number[];
7- onToggleTag?: (id: number) => void;
8- onCreateTag?: (name: string, color: string) => Promise<void>;
9- onClearSelection?: () => void;
10- }
11-
12- let {
13- tags,
14- selectedTagIds = [],
15- onToggleTag,
16- onCreateTag,
17- onClearSelection
18- }: Props = $props();
19-
20- // Inline creation state
21- let isCreating = $state(false);
22- let newTagName = $state('');
23- let inputRef = $state<HTMLInputElement | null>(null);
24-
25- // Predefined tag colors
26- const tagColors = [
27- '#6b7280', '#ef4444', '#f59e0b', '#22c55e',
28- '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6'
29- ];
30-
31- const getRandomColor = () => tagColors[Math.floor(Math.random() * tagColors.length)] ?? '#6b7280';
32-
33- const startCreating = () => {
34- isCreating = true;
35- newTagName = '';
36- setTimeout(() => inputRef?.focus(), 0);
37- };
38-
39- const cancelCreating = () => {
40- isCreating = false;
41- newTagName = '';
42- };
43-
44- const submitCreate = async () => {
45- const name = newTagName.trim();
46- if (!name || !onCreateTag) {
47- cancelCreating();
48- return;
49- }
50- try {
51- await onCreateTag(name, getRandomColor());
52- cancelCreating();
53- } catch (e) {
54- console.error('Failed to create tag:', e);
55- }
56- };
57-
58- const handleKeydown = (e: KeyboardEvent) => {
59- if (e.key === 'Enter') {
60- e.preventDefault();
61- submitCreate();
62- } else if (e.key === 'Escape') {
63- cancelCreating();
64- }
65- };
66-
67- const isSelected = (id: number) => selectedTagIds.includes(id);
68-</script>
69-
70-<div class="tag-cloud">
71- <div class="tag-header">
72- <span class="header-label">TAGS</span>
73- <div class="header-actions">
74- {#if selectedTagIds.length > 0}
75- <button
76- class="clear-btn"
77- type="button"
78- onclick={() => onClearSelection?.()}
79- title="Clear selection"
80- >
81- CLEAR
82- </button>
83- {/if}
84- {#if isCreating}
85- <button
86- class="add-tag-btn cancel"
87- type="button"
88- onclick={cancelCreating}
89- title="Cancel"
90- >
91- ×
92- </button>
93- {:else}
94- <button
95- class="add-tag-btn"
96- type="button"
97- onclick={startCreating}
98- title="Create tag"
99- >
100- +
101- </button>
102- {/if}
103- </div>
104- </div>
105-
106- {#if isCreating}
107- <div class="create-input-row">
108- <input
109- bind:this={inputRef}
110- bind:value={newTagName}
111- onkeydown={handleKeydown}
112- onblur={cancelCreating}
113- class="create-input"
114- type="text"
115- placeholder="Tag name..."
116- maxlength="30"
117- />
118- </div>
119- {/if}
120-
121- <div class="tag-chips">
122- {#if tags.length === 0}
123- <span class="no-tags">No tags yet</span>
124- {:else}
125- {#each tags as tag (tag.id)}
126- <button
127- class="tag-chip"
128- class:selected={isSelected(tag.id)}
129- type="button"
130- onclick={() => onToggleTag?.(tag.id)}
131- style="--tag-color: {tag.color}"
132- >
133- <span class="tag-dot"></span>
134- <span class="tag-name">{tag.name}</span>
135- </button>
136- {/each}
137- {/if}
138- </div>
139-</div>
140-
141-<style>
142- .tag-cloud {
143- display: flex;
144- flex-direction: column;
145- gap: 0.5rem;
146- padding: 0.6rem 0.75rem;
147- border-width: 1px;
148- border-style: solid;
149- border-color: var(--border);
150- background: color-mix(in srgb, var(--background) 92%, var(--card) 8%);
151- }
152-
153- .tag-header {
154- display: flex;
155- align-items: center;
156- justify-content: space-between;
157- }
158-
159- .header-label {
160- font-size: 0.6rem;
161- font-weight: 600;
162- color: var(--muted-foreground);
163- letter-spacing: 0.12em;
164- }
165-
166- .header-actions {
167- display: flex;
168- gap: 0.35rem;
169- }
170-
171- .clear-btn {
172- padding: 0.2rem 0.4rem;
173- font-size: 0.55rem;
174- font-weight: 500;
175- letter-spacing: 0.08em;
176- border-width: 1px;
177- border-style: solid;
178- border-color: var(--border);
179- background: transparent;
180- color: var(--muted-foreground);
181- cursor: pointer;
182- transition: all 100ms ease;
183- }
184-
185- .clear-btn:hover {
186- color: var(--foreground);
187- border-color: var(--foreground);
188- }
189-
190- .add-tag-btn {
191- width: 20px;
192- height: 20px;
193- display: flex;
194- align-items: center;
195- justify-content: center;
196- font-size: 0.9rem;
197- font-weight: 500;
198- border-width: 1px;
199- border-style: solid;
200- border-color: var(--border);
201- background: transparent;
202- color: var(--muted-foreground);
203- cursor: pointer;
204- transition: all 100ms ease;
205- }
206-
207- .add-tag-btn:hover {
208- color: var(--foreground);
209- border-color: var(--foreground);
210- }
211-
212- .add-tag-btn.cancel {
213- color: var(--muted-foreground);
214- }
215-
216- .add-tag-btn.cancel:hover {
217- color: var(--destructive);
218- border-color: var(--destructive);
219- }
220-
221- .create-input-row {
222- margin-bottom: 0.35rem;
223- }
224-
225- .create-input {
226- width: 100%;
227- padding: 0.35rem 0.5rem;
228- font-size: 0.68rem;
229- font-family: inherit;
230- color: var(--foreground);
231- background: var(--background);
232- border-width: 1px;
233- border-style: solid;
234- border-color: var(--primary, #4ade80);
235- outline: none;
236- }
237-
238- .create-input::placeholder {
239- color: var(--muted-foreground);
240- }
241-
242- .tag-chips {
243- display: flex;
244- flex-wrap: wrap;
245- gap: 0.35rem;
246- }
247-
248- .no-tags {
249- font-size: 0.65rem;
250- color: var(--muted-foreground);
251- font-style: italic;
252- padding: 0.25rem 0;
253- }
254-
255- .tag-chip {
256- display: inline-flex;
257- align-items: center;
258- gap: 0.35rem;
259- padding: 0.25rem 0.5rem;
260- font-size: 0.62rem;
261- font-weight: 500;
262- letter-spacing: 0.05em;
263- border-width: 1px;
264- border-style: solid;
265- border-color: var(--tag-color);
266- background: transparent;
267- color: var(--foreground);
268- cursor: pointer;
269- transition: all 100ms ease;
270- }
271-
272- .tag-chip:hover {
273- background: color-mix(in srgb, var(--tag-color) 15%, transparent);
274- }
275-
276- .tag-chip.selected {
277- background: color-mix(in srgb, var(--tag-color) 25%, transparent);
278- border-width: 2px;
279- padding: calc(0.25rem - 1px) calc(0.5rem - 1px);
280- }
281-
282- .tag-dot {
283- width: 6px;
284- height: 6px;
285- background: var(--tag-color);
286- border-radius: 50%;
287- }
288-
289- .tag-name {
290- text-transform: lowercase;
291- }
292-</style>
D webui/src/pages/homepage/favorites/index.ts +0 -6
@@ -1,6 +0,0 @@
1-// Favorites Components - Folders, Tags, Grid, Cards, Editor
2-export { default as FavoriteCard } from './FavoriteCard.svelte';
3-export { default as FavoriteGrid } from './FavoriteGrid.svelte';
4-export { default as FolderList } from './FolderList.svelte';
5-export { default as TagCloud } from './TagCloud.svelte';
6-export { default as FavoriteEditor } from './FavoriteEditor.svelte';
D webui/src/pages/homepage/history/HistoryCleanup.svelte +0 -396
@@ -1,396 +0,0 @@
1-<script lang="ts">
2- import type { HistoryCleanupRange } from '../types';
3-
4- interface Props {
5- onCleanup: (range: HistoryCleanupRange) => void;
6- onClose: () => void;
7- }
8-
9- let { onCleanup, onClose }: Props = $props();
10-
11- type CleanupOption = {
12- range: HistoryCleanupRange;
13- label: string;
14- shortcut: string;
15- description: string;
16- destructive?: boolean;
17- };
18-
19- const options: CleanupOption[] = [
20- { range: 'hour', label: 'LAST HOUR', shortcut: 'D h', description: 'Delete entries from the past 60 minutes' },
21- { range: 'day', label: 'LAST DAY', shortcut: 'D d', description: 'Delete entries from the past 24 hours' },
22- { range: 'week', label: 'LAST WEEK', shortcut: 'D w', description: 'Delete entries from the past 7 days' },
23- { range: 'month', label: 'LAST MONTH', shortcut: 'D m', description: 'Delete entries from the past 30 days' },
24- { range: 'all', label: 'CLEAR ALL', shortcut: 'D D', description: 'Permanently delete all browsing history', destructive: true },
25- ];
26-
27- let selectedIndex = $state(0);
28- let confirming = $state(false);
29-
30- const handleKeyDown = (e: KeyboardEvent) => {
31- switch (e.key) {
32- case 'Escape':
33- e.preventDefault();
34- if (confirming) {
35- confirming = false;
36- } else {
37- onClose();
38- }
39- break;
40- case 'ArrowDown':
41- case 'j':
42- e.preventDefault();
43- selectedIndex = Math.min(selectedIndex + 1, options.length - 1);
44- break;
45- case 'ArrowUp':
46- case 'k':
47- e.preventDefault();
48- selectedIndex = Math.max(selectedIndex - 1, 0);
49- break;
50- case 'Enter':
51- e.preventDefault();
52- handleSelect(options[selectedIndex]!);
53- break;
54- }
55- };
56-
57- const handleSelect = (option: CleanupOption) => {
58- if (option.destructive && !confirming) {
59- confirming = true;
60- return;
61- }
62- onCleanup(option.range);
63- onClose();
64- };
65-
66- const handleOverlayClick = (e: Event) => {
67- if (e.target === e.currentTarget) {
68- onClose();
69- }
70- };
71-</script>
72-
73-<svelte:window onkeydown={handleKeyDown} />
74-
75-<div
76- class="cleanup-overlay"
77- onclick={handleOverlayClick}
78- onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}
79- role="button"
80- tabindex="0"
81- aria-label="Close cleanup modal"
82->
83- <div class="cleanup-modal" role="presentation">
84- <div class="modal-header">
85- <span class="modal-icon"></span>
86- <span class="modal-title">CLEAR HISTORY</span>
87- <button class="close-btn" type="button" onclick={onClose}>
88- <kbd>Esc</kbd>
89- </button>
90- </div>
91-
92- {#if confirming}
93- <div class="confirm-panel">
94- <div class="confirm-icon"></div>
95- <p class="confirm-message">
96- This will permanently delete <strong>ALL</strong> browsing history.
97- This action cannot be undone.
98- </p>
99- <div class="confirm-actions">
100- <button
101- class="confirm-btn cancel"
102- type="button"
103- onclick={() => confirming = false}
104- >
105- CANCEL
106- <kbd>Esc</kbd>
107- </button>
108- <button
109- class="confirm-btn destructive"
110- type="button"
111- onclick={() => { onCleanup('all'); onClose(); }}
112- >
113- DELETE ALL
114- <kbd>Enter</kbd>
115- </button>
116- </div>
117- </div>
118- {:else}
119- <div class="cleanup-options">
120- {#each options as option, i (option.range)}
121- <button
122- class="cleanup-option"
123- class:selected={selectedIndex === i}
124- class:destructive={option.destructive}
125- type="button"
126- onclick={() => handleSelect(option)}
127- onmouseenter={() => selectedIndex = i}
128- >
129- <div class="option-main">
130- <span class="option-label">{option.label}</span>
131- <span class="option-desc">{option.description}</span>
132- </div>
133- <kbd class="option-shortcut">{option.shortcut}</kbd>
134- </button>
135- {/each}
136- </div>
137- {/if}
138-
139- <div class="modal-footer">
140- <span class="hint"><kbd>j</kbd><kbd>k</kbd> navigate</span>
141- <span class="hint"><kbd>Enter</kbd> select</span>
142- </div>
143- </div>
144-</div>
145-
146-<style>
147- .cleanup-overlay {
148- position: fixed;
149- inset: 0;
150- z-index: 100;
151- display: flex;
152- align-items: center;
153- justify-content: center;
154- background: rgb(0 0 0 / 0.75);
155- backdrop-filter: blur(4px);
156- animation: fade-in 150ms ease;
157- }
158-
159- @keyframes fade-in {
160- from { opacity: 0; }
161- }
162-
163- .cleanup-modal {
164- width: 100%;
165- max-width: 420px;
166- margin: 1rem;
167- background: var(--card);
168- border-width: 1px;
169- border-style: solid;
170- border-color: var(--border);
171- box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.6);
172- animation: modal-in 150ms ease;
173- }
174-
175- @keyframes modal-in {
176- from {
177- opacity: 0;
178- transform: scale(0.96) translateY(-8px);
179- }
180- }
181-
182- .modal-header {
183- display: flex;
184- align-items: center;
185- gap: 0.6rem;
186- padding: 0.75rem 1rem;
187- border-bottom: 1px solid var(--border);
188- background: color-mix(in srgb, var(--background) 80%, transparent);
189- }
190-
191- .modal-icon::before {
192- content: '';
193- font-size: 0.9rem;
194- color: var(--warning);
195- }
196-
197- .modal-title {
198- flex: 1;
199- font-size: 0.72rem;
200- font-weight: 600;
201- letter-spacing: 0.1em;
202- color: var(--foreground);
203- }
204-
205- .close-btn {
206- padding: 0.25rem 0.5rem;
207- background: transparent;
208- border-width: 1px;
209- border-style: solid;
210- border-color: var(--border);
211- cursor: pointer;
212- }
213-
214- .close-btn kbd {
215- font-size: 0.6rem;
216- color: var(--muted-foreground);
217- }
218-
219- .close-btn:hover {
220- border-color: var(--foreground);
221- }
222-
223- .close-btn:hover kbd {
224- color: var(--foreground);
225- }
226-
227- .cleanup-options {
228- display: flex;
229- flex-direction: column;
230- }
231-
232- .cleanup-option {
233- display: flex;
234- align-items: center;
235- justify-content: space-between;
236- gap: 1rem;
237- padding: 0.85rem 1rem;
238- text-align: left;
239- background: transparent;
240- border: none;
241- border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
242- cursor: pointer;
243- transition: background-color 100ms ease;
244- }
245-
246- .cleanup-option:last-child {
247- border-bottom: none;
248- }
249-
250- .cleanup-option:hover,
251- .cleanup-option.selected {
252- background: color-mix(in srgb, var(--card) 80%, var(--background) 20%);
253- }
254-
255- .cleanup-option.selected {
256- outline: 1px solid var(--primary, #4ade80);
257- outline-offset: -1px;
258- }
259-
260- .cleanup-option.destructive .option-label {
261- color: var(--destructive);
262- }
263-
264- .option-main {
265- display: flex;
266- flex-direction: column;
267- gap: 0.2rem;
268- }
269-
270- .option-label {
271- font-size: 0.75rem;
272- font-weight: 600;
273- letter-spacing: 0.08em;
274- color: var(--foreground);
275- }
276-
277- .option-desc {
278- font-size: 0.68rem;
279- color: var(--muted-foreground);
280- letter-spacing: 0.04em;
281- }
282-
283- .option-shortcut {
284- padding: 0.2rem 0.45rem;
285- font-size: 0.58rem;
286- letter-spacing: 0.05em;
287- border-width: 1px;
288- border-style: solid;
289- border-color: var(--border);
290- background: var(--background);
291- color: var(--muted-foreground);
292- }
293-
294- .confirm-panel {
295- padding: 1.5rem 1rem;
296- display: flex;
297- flex-direction: column;
298- align-items: center;
299- gap: 1rem;
300- text-align: center;
301- }
302-
303- .confirm-icon::before {
304- content: '';
305- font-size: 2rem;
306- color: var(--destructive);
307- }
308-
309- .confirm-message {
310- margin: 0;
311- font-size: 0.8rem;
312- line-height: 1.5;
313- color: var(--foreground);
314- }
315-
316- .confirm-message strong {
317- color: var(--destructive);
318- }
319-
320- .confirm-actions {
321- display: flex;
322- gap: 0.5rem;
323- width: 100%;
324- margin-top: 0.5rem;
325- }
326-
327- .confirm-btn {
328- flex: 1;
329- display: flex;
330- align-items: center;
331- justify-content: center;
332- gap: 0.5rem;
333- padding: 0.6rem 1rem;
334- font-size: 0.7rem;
335- font-weight: 500;
336- letter-spacing: 0.08em;
337- border-width: 1px;
338- border-style: solid;
339- border-color: var(--border);
340- cursor: pointer;
341- transition: all 100ms ease;
342- }
343-
344- .confirm-btn kbd {
345- font-size: 0.55rem;
346- padding: 0.1rem 0.3rem;
347- border: 1px solid;
348- opacity: 0.7;
349- }
350-
351- .confirm-btn.cancel {
352- background: transparent;
353- color: var(--muted-foreground);
354- }
355-
356- .confirm-btn.cancel:hover {
357- color: var(--foreground);
358- border-color: var(--foreground);
359- }
360-
361- .confirm-btn.destructive {
362- background: var(--destructive);
363- color: var(--destructive-foreground);
364- border-color: color-mix(in srgb, var(--destructive) 80%, black 20%);
365- }
366-
367- .confirm-btn.destructive:hover {
368- background: color-mix(in srgb, var(--destructive) 85%, white 15%);
369- }
370-
371- .modal-footer {
372- display: flex;
373- align-items: center;
374- justify-content: center;
375- gap: 1rem;
376- padding: 0.6rem 1rem;
377- border-top: 1px solid var(--border);
378- background: color-mix(in srgb, var(--background) 60%, transparent);
379- }
380-
381- .hint {
382- font-size: 0.6rem;
383- color: var(--muted-foreground);
384- letter-spacing: 0.05em;
385- }
386-
387- .hint kbd {
388- font-size: 0.55rem;
389- padding: 0.1rem 0.25rem;
390- margin-right: 0.25rem;
391- border-width: 1px;
392- border-style: solid;
393- border-color: var(--border);
394- background: var(--background);
395- }
396-</style>
D webui/src/pages/homepage/history/HistoryFilters.svelte +0 -155
@@ -1,155 +0,0 @@
1-<script lang="ts">
2- import type { DomainStat } from '../types';
3-
4- interface Props {
5- domains?: DomainStat[];
6- selectedDomain?: string | null;
7- onSelectDomain?: (domain: string | null) => void;
8- onOpenCleanup?: () => void;
9- }
10-
11- let {
12- domains = [],
13- selectedDomain = null,
14- onSelectDomain,
15- onOpenCleanup
16- }: Props = $props();
17-
18- const topDomains = $derived(domains.slice(0, 5));
19-</script>
20-
21-<div class="history-filters">
22- <div class="filter-section">
23- <span class="filter-label">FILTER</span>
24- <div class="filter-chips">
25- <button
26- class="filter-chip"
27- class:active={selectedDomain === null}
28- type="button"
29- onclick={() => onSelectDomain?.(null)}
30- >
31- ALL
32- </button>
33- {#each topDomains as domain (domain.domain)}
34- <button
35- class="filter-chip"
36- class:active={selectedDomain === domain.domain}
37- type="button"
38- onclick={() => onSelectDomain?.(domain.domain)}
39- title="{domain.page_count} pages, {domain.total_visits} visits"
40- >
41- {domain.domain.replace('www.', '').slice(0, 12)}
42- </button>
43- {/each}
44- </div>
45- </div>
46-
47- <div class="filter-actions">
48- <button
49- class="cleanup-btn"
50- type="button"
51- onclick={() => onOpenCleanup?.()}
52- title="Clear history (D)"
53- >
54- <span class="cleanup-icon"></span>
55- <span>CLEAR</span>
56- </button>
57- </div>
58-</div>
59-
60-<style>
61- .history-filters {
62- display: flex;
63- align-items: center;
64- justify-content: space-between;
65- gap: 1rem;
66- padding: 0.5rem 1rem;
67- }
68-
69- .filter-section {
70- display: flex;
71- align-items: center;
72- gap: 0.6rem;
73- flex: 1;
74- min-width: 0;
75- }
76-
77- .filter-label {
78- font-size: 0.6rem;
79- font-weight: 600;
80- color: var(--muted-foreground);
81- letter-spacing: 0.12em;
82- flex-shrink: 0;
83- }
84-
85- .filter-chips {
86- display: flex;
87- gap: 0.35rem;
88- overflow-x: auto;
89- scrollbar-width: none;
90- }
91-
92- .filter-chips::-webkit-scrollbar {
93- display: none;
94- }
95-
96- .filter-chip {
97- padding: 0.3rem 0.55rem;
98- font-size: 0.6rem;
99- font-weight: 500;
100- letter-spacing: 0.08em;
101- color: var(--muted-foreground);
102- background: transparent;
103- border-width: 1px;
104- border-style: solid;
105- border-color: var(--border);
106- cursor: pointer;
107- transition: all 100ms ease;
108- white-space: nowrap;
109- flex-shrink: 0;
110- }
111-
112- .filter-chip:hover {
113- color: var(--foreground);
114- border-color: var(--foreground);
115- }
116-
117- .filter-chip.active {
118- color: var(--primary-foreground, var(--background));
119- background: var(--primary);
120- border-color: var(--primary);
121- }
122-
123- .filter-actions {
124- display: flex;
125- gap: 0.35rem;
126- flex-shrink: 0;
127- }
128-
129- .cleanup-btn {
130- display: flex;
131- align-items: center;
132- gap: 0.4rem;
133- padding: 0.35rem 0.6rem;
134- font-size: 0.6rem;
135- font-weight: 500;
136- letter-spacing: 0.08em;
137- color: var(--muted-foreground);
138- background: transparent;
139- border-width: 1px;
140- border-style: solid;
141- border-color: var(--border);
142- cursor: pointer;
143- transition: all 100ms ease;
144- }
145-
146- .cleanup-btn:hover {
147- color: var(--destructive);
148- border-color: var(--destructive);
149- }
150-
151- .cleanup-icon::before {
152- content: '';
153- font-size: 0.75rem;
154- }
155-</style>
D webui/src/pages/homepage/history/HistoryItem.svelte +0 -280
@@ -1,280 +0,0 @@
1-<script lang="ts">
2- import type { HistoryEntry } from '../types';
3-
4- interface Props {
5- entry: HistoryEntry;
6- focused?: boolean;
7- onSelect?: (entry: HistoryEntry) => void;
8- onDelete?: (entry: HistoryEntry) => void;
9- }
10-
11- let { entry, focused = false, onSelect, onDelete }: Props = $props();
12-
13- let deleting = $state(false);
14-
15- const getDomain = (url: string): string => {
16- try {
17- return new URL(url).hostname;
18- } catch {
19- return url;
20- }
21- };
22-
23- const getFaviconUrl = (item: HistoryEntry): string => {
24- // Use provided favicon_url if available
25- if (item.favicon_url) return item.favicon_url;
26- // Handle internal dumb:// URLs - use embedded favicon
27- if (item.url.startsWith('dumb://')) {
28- return 'favicon.png';
29- }
30- // Construct DuckDuckGo favicon URL from domain
31- const domain = getDomain(item.url).replace(/^www\./, '');
32- return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;
33- };
34-
35- const getDisplayTitle = (item: HistoryEntry): string => {
36- const trimmed = item.title?.trim();
37- if (trimmed) return trimmed;
38- return getDomain(item.url) || item.url;
39- };
40-
41- const formatTime = (timestamp: string): string => {
42- try {
43- const date = new Date(timestamp);
44- const now = new Date();
45- const diff = now.getTime() - date.getTime();
46-
47- const minutes = Math.floor(diff / (1000 * 60));
48- const hours = Math.floor(diff / (1000 * 60 * 60));
49-
50- if (minutes < 1) return 'now';
51- if (minutes < 60) return `${minutes}m`;
52- if (hours < 24) return `${hours}h`;
53-
54- return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
55- } catch {
56- return '';
57- }
58- };
59-
60- const handleSelect = () => {
61- onSelect?.(entry);
62- };
63-
64- const handleDelete = (e: Event) => {
65- e.stopPropagation();
66- deleting = true;
67- onDelete?.(entry);
68- };
69-
70- const handleKeyDown = (e: KeyboardEvent) => {
71- if (e.key === 'Enter') {
72- handleSelect();
73- } else if (e.key === 'd' || e.key === 'x') {
74- handleDelete(e);
75- }
76- };
77-</script>
78-
79-<div
80- class="history-item"
81- class:focused
82- class:deleting
83- role="button"
84- tabindex="0"
85- data-entry-id={entry.id}
86- onclick={handleSelect}
87- onkeydown={handleKeyDown}
88->
89- <div class="item-favicon">
90- <img
91- src={getFaviconUrl(entry)}
92- alt=""
93- class="favicon-img"
94- onerror={(e) => {
95- const target = e.target as HTMLImageElement;
96- target.style.display = 'none';
97- const fallback = target.nextElementSibling as HTMLElement;
98- if (fallback) fallback.style.display = 'flex';
99- }}
100- />
101- <div class="favicon-fallback" style="display: none;"></div>
102- </div>
103-
104- <div class="item-content">
105- <div class="item-row">
106- <span class="item-title">{getDisplayTitle(entry)}</span>
107- <span class="item-time">{formatTime(entry.last_visited)}</span>
108- </div>
109- <div class="item-url">{entry.url}</div>
110- </div>
111-
112- <div class="item-actions">
113- {#if entry.visit_count > 1}
114- <span class="visit-count">{entry.visit_count}x</span>
115- {/if}
116- <button
117- class="action-btn delete-btn"
118- type="button"
119- onclick={handleDelete}
120- title="Delete entry (d)"
121- >
122- <span aria-hidden="true">×</span>
123- </button>
124- </div>
125-</div>
126-
127-<style>
128- .history-item {
129- display: grid;
130- grid-template-columns: auto 1fr auto;
131- align-items: center;
132- gap: 0.75rem;
133- padding: 0.55rem 0.85rem;
134- border-bottom-width: 1px;
135- border-bottom-style: solid;
136- border-bottom-color: color-mix(in srgb, var(--border) 50%, transparent);
137- transition: background-color 100ms ease;
138- cursor: pointer;
139- }
140-
141- .history-item:last-child {
142- border-bottom: none;
143- }
144-
145- .history-item:hover,
146- .history-item.focused {
147- background: color-mix(in srgb, var(--card) 40%, transparent);
148- }
149-
150- .history-item.focused {
151- outline: 1px solid var(--primary, #4ade80);
152- outline-offset: -1px;
153- }
154-
155- .history-item.deleting {
156- opacity: 0.3;
157- pointer-events: none;
158- }
159-
160- .item-favicon {
161- width: 28px;
162- height: 28px;
163- border-width: 1px;
164- border-style: solid;
165- border-color: var(--border);
166- background: var(--background);
167- display: flex;
168- align-items: center;
169- justify-content: center;
170- flex-shrink: 0;
171- }
172-
173- .favicon-img {
174- width: 16px;
175- height: 16px;
176- object-fit: contain;
177- }
178-
179- .favicon-fallback {
180- width: 16px;
181- height: 16px;
182- display: flex;
183- align-items: center;
184- justify-content: center;
185- color: var(--muted-foreground);
186- }
187-
188- .favicon-fallback::before {
189- content: '';
190- font-size: 0.75rem;
191- }
192-
193- .item-content {
194- display: flex;
195- flex-direction: column;
196- gap: 0.15rem;
197- min-width: 0;
198- }
199-
200- .item-row {
201- display: flex;
202- align-items: center;
203- justify-content: space-between;
204- gap: 0.75rem;
205- }
206-
207- .item-title {
208- font-size: 0.8rem;
209- font-weight: 500;
210- color: var(--foreground);
211- white-space: nowrap;
212- overflow: hidden;
213- text-overflow: ellipsis;
214- }
215-
216- .item-time {
217- font-size: 0.65rem;
218- color: var(--muted-foreground);
219- text-transform: uppercase;
220- letter-spacing: 0.08em;
221- white-space: nowrap;
222- flex-shrink: 0;
223- }
224-
225- .item-url {
226- font-size: 0.68rem;
227- color: var(--muted-foreground);
228- white-space: nowrap;
229- overflow: hidden;
230- text-overflow: ellipsis;
231- }
232-
233- .item-actions {
234- display: flex;
235- align-items: center;
236- gap: 0.5rem;
237- }
238-
239- .visit-count {
240- font-size: 0.6rem;
241- color: var(--muted-foreground);
242- padding: 0.15rem 0.35rem;
243- border-width: 1px;
244- border-style: solid;
245- border-color: var(--border);
246- background: var(--background);
247- }
248-
249- .action-btn {
250- width: 24px;
251- height: 24px;
252- display: flex;
253- align-items: center;
254- justify-content: center;
255- border-width: 1px;
256- border-style: solid;
257- border-color: var(--border);
258- background: transparent;
259- color: var(--muted-foreground);
260- cursor: pointer;
261- transition: all 100ms ease;
262- opacity: 0;
263- }
264-
265- .history-item:hover .action-btn,
266- .history-item.focused .action-btn {
267- opacity: 1;
268- }
269-
270- .action-btn:hover {
271- color: var(--foreground);
272- border-color: var(--foreground);
273- background: color-mix(in srgb, var(--card) 50%, transparent);
274- }
275-
276- .delete-btn:hover {
277- color: var(--destructive);
278- border-color: var(--destructive);
279- }
280-</style>
D webui/src/pages/homepage/history/HistorySearch.svelte +0 -179
@@ -1,179 +0,0 @@
1-<script lang="ts">
2- import { onMount } from 'svelte';
3- import { homepageState } from '../state.svelte';
4- import { handleSearchInput } from '../keyboard';
5-
6- interface Props {
7- placeholder?: string;
8- autofocus?: boolean;
9- }
10-
11- let { placeholder = 'Search history...', autofocus = false }: Props = $props();
12-
13- let inputRef = $state<HTMLInputElement | null>(null);
14-
15- // Auto-focus on mount if requested
16- onMount(() => {
17- if (autofocus && inputRef) {
18- inputRef.focus();
19- }
20- });
21-
22- const handleInput = (e: Event) => {
23- const target = e.target as HTMLInputElement;
24- handleSearchInput(target.value);
25- };
26-
27- const handleKeyDown = (e: KeyboardEvent) => {
28- switch (e.key) {
29- case 'Escape':
30- e.preventDefault();
31- e.stopPropagation();
32- if (homepageState.historySearchQuery) {
33- // First Esc: clear search
34- homepageState.setHistorySearchQuery('');
35- } else {
36- // Second Esc (or if no query): blur input
37- inputRef?.blur();
38- }
39- break;
40- case 'ArrowDown':
41- case 'j':
42- if (e.key === 'j' && !e.ctrlKey) break; // Only Ctrl+j or ArrowDown
43- e.preventDefault();
44- homepageState.focusNext();
45- inputRef?.blur();
46- break;
47- case 'ArrowUp':
48- case 'k':
49- if (e.key === 'k' && !e.ctrlKey) break; // Only Ctrl+k or ArrowUp
50- e.preventDefault();
51- // Don't move focus - already at top
52- break;
53- }
54- };
55-
56- // Focus input when focusedIndex becomes -1 (user pressed up from first item)
57- $effect(() => {
58- if (homepageState.focusedIndex === -1 && homepageState.activePanel === 'history') {
59- inputRef?.focus();
60- }
61- });
62-
63- const clearSearch = () => {
64- homepageState.setHistorySearchQuery('');
65- inputRef?.focus();
66- };
67-</script>
68-
69-<div class="history-search">
70- <div class="search-icon"></div>
71- <input
72- bind:this={inputRef}
73- type="text"
74- class="search-input"
75- {placeholder}
76- value={homepageState.historySearchQuery}
77- oninput={handleInput}
78- onkeydown={handleKeyDown}
79- />
80- {#if homepageState.historySearching}
81- <div class="search-spinner"></div>
82- {:else if homepageState.historySearchQuery}
83- <button
84- class="clear-btn"
85- type="button"
86- onclick={clearSearch}
87- title="Clear search (Esc)"
88- >
89- ×
90- </button>
91- {:else}
92- <kbd class="search-hint">/</kbd>
93- {/if}
94-</div>
95-
96-<style>
97- .history-search {
98- display: flex;
99- align-items: center;
100- gap: 0.6rem;
101- padding: 0.5rem 0.75rem;
102- border-width: 1px;
103- border-style: solid;
104- border-color: var(--border);
105- background: var(--background);
106- transition: border-color 150ms ease;
107- }
108-
109- .history-search:focus-within {
110- border-color: var(--primary, #4ade80);
111- }
112-
113- .search-icon::before {
114- content: '';
115- font-size: 0.85rem;
116- color: var(--muted-foreground);
117- }
118-
119- .search-input {
120- flex: 1;
121- border: none;
122- background: transparent;
123- color: var(--foreground);
124- font-family: inherit;
125- font-size: 0.8rem;
126- outline: none;
127- }
128-
129- .search-input::placeholder {
130- color: var(--muted-foreground);
131- letter-spacing: 0.05em;
132- }
133-
134- .search-spinner {
135- width: 14px;
136- height: 14px;
137- border-width: 2px;
138- border-style: solid;
139- border-color: var(--border);
140- border-top-color: var(--primary, #4ade80);
141- border-radius: 50%;
142- animation: spin 0.8s linear infinite;
143- }
144-
145- @keyframes spin {
146- to { transform: rotate(360deg); }
147- }
148-
149- .clear-btn {
150- width: 20px;
151- height: 20px;
152- display: flex;
153- align-items: center;
154- justify-content: center;
155- border-width: 1px;
156- border-style: solid;
157- border-color: var(--border);
158- background: transparent;
159- color: var(--muted-foreground);
160- font-size: 0.9rem;
161- cursor: pointer;
162- transition: all 100ms ease;
163- }
164-
165- .clear-btn:hover {
166- color: var(--foreground);
167- border-color: var(--foreground);
168- }
169-
170- .search-hint {
171- font-size: 0.6rem;
172- padding: 0.15rem 0.35rem;
173- border-width: 1px;
174- border-style: solid;
175- border-color: var(--border);
176- background: color-mix(in srgb, var(--card) 30%, transparent);
177- color: var(--muted-foreground);
178- }
179-</style>
D webui/src/pages/homepage/history/HistoryTimeline.svelte +0 -223
@@ -1,223 +0,0 @@
1-<script lang="ts">
2- import type { TimelineGroup, HistoryEntry } from '../types';
3- import { homepageState } from '../state.svelte';
4- import HistoryItem from './HistoryItem.svelte';
5-
6- interface Props {
7- groups: TimelineGroup[];
8- onSelectEntry?: (entry: HistoryEntry) => void;
9- onDeleteEntry?: (entry: HistoryEntry) => void;
10- onLoadMore?: () => void;
11- }
12-
13- let { groups, onSelectEntry, onDeleteEntry, onLoadMore }: Props = $props();
14-
15- let containerRef = $state<HTMLElement | null>(null);
16- let sentinelRef = $state<HTMLElement | null>(null);
17-
18- // Flatten groups to get global index for keyboard navigation
19- const flatEntries = $derived(() => {
20- const entries: HistoryEntry[] = [];
21- for (const group of groups) {
22- entries.push(...group.entries);
23- }
24- return entries;
25- });
26-
27- // Track which entry is focused based on global focusedIndex
28- const getFocusedEntry = () => {
29- const entries = flatEntries();
30- return entries[homepageState.focusedIndex] ?? null;
31- };
32-
33- // Track previous focused index to only scroll on actual navigation
34- let prevFocusedIndex = $state(-1);
35-
36- // Scroll focused item into view when focusedIndex changes via keyboard navigation
37- $effect(() => {
38- // Skip if panel not active or command palette is open
39- if (!containerRef || homepageState.activePanel !== 'history') return;
40- if (homepageState.commandPaletteOpen) return;
41-
42- const currentIndex = homepageState.focusedIndex;
43-
44- // Only scroll if index actually changed (not on initial render or modal close)
45- if (prevFocusedIndex === currentIndex) return;
46- if (prevFocusedIndex === -1) {
47- // First render - just track, don't scroll
48- prevFocusedIndex = currentIndex;
49- return;
50- }
51-
52- prevFocusedIndex = currentIndex;
53-
54- const focusedEntry = getFocusedEntry();
55- if (!focusedEntry) return;
56-
57- // Find the focused element by entry ID
58- const el = containerRef.querySelector(`[data-entry-id="${focusedEntry.id}"]`);
59- if (el) {
60- el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
61- }
62- });
63-
64- // Setup infinite scroll observer
65- $effect(() => {
66- if (!sentinelRef || !onLoadMore) return;
67-
68- const observer = new IntersectionObserver(
69- (entries) => {
70- const entry = entries[0];
71- if (entry?.isIntersecting && homepageState.hasMoreHistory && !homepageState.historyLoading) {
72- onLoadMore();
73- }
74- },
75- { threshold: 0.1, rootMargin: '100px' }
76- );
77-
78- observer.observe(sentinelRef);
79-
80- return () => observer.disconnect();
81- });
82-</script>
83-
84-<div class="history-timeline" bind:this={containerRef}>
85- {#if groups.length === 0}
86- <div class="empty-state">
87- <span class="empty-icon"></span>
88- <span class="empty-text">NO HISTORY RECORDED</span>
89- <span class="empty-hint">Start browsing to see your history here</span>
90- </div>
91- {:else}
92- {#each groups as group (group.date)}
93- <div class="timeline-group">
94- <div class="group-header">
95- <span class="group-label">{group.label}</span>
96- <span class="group-count">{group.entries.length}</span>
97- </div>
98- <div class="group-entries">
99- {#each group.entries as entry (entry.id)}
100- <HistoryItem
101- {entry}
102- focused={getFocusedEntry()?.id === entry.id}
103- onSelect={onSelectEntry}
104- onDelete={onDeleteEntry}
105- />
106- {/each}
107- </div>
108- </div>
109- {/each}
110-
111- {#if homepageState.hasMoreHistory}
112- <div class="load-more-sentinel" bind:this={sentinelRef}>
113- {#if homepageState.historyLoading}
114- <span class="loading-text">LOADING...</span>
115- {/if}
116- </div>
117- {/if}
118- {/if}
119-</div>
120-
121-<style>
122- .history-timeline {
123- display: flex;
124- flex-direction: column;
125- }
126-
127- .empty-state {
128- flex: 1;
129- display: flex;
130- flex-direction: column;
131- align-items: center;
132- justify-content: center;
133- gap: 0.5rem;
134- padding: 2rem;
135- color: var(--muted-foreground);
136- text-align: center;
137- }
138-
139- .empty-icon {
140- font-size: 1.5rem;
141- opacity: 0.5;
142- }
143-
144- .empty-text {
145- font-size: 0.75rem;
146- font-weight: 600;
147- letter-spacing: 0.12em;
148- }
149-
150- .empty-hint {
151- font-size: 0.7rem;
152- letter-spacing: 0.08em;
153- opacity: 0.7;
154- }
155-
156- .timeline-group {
157- border-bottom-width: 1px;
158- border-bottom-style: solid;
159- border-bottom-color: var(--border);
160- }
161-
162- .timeline-group:last-child {
163- border-bottom: none;
164- }
165-
166- .group-header {
167- display: flex;
168- align-items: center;
169- justify-content: space-between;
170- gap: 0.75rem;
171- padding: 0.5rem 0.85rem;
172- background: color-mix(in srgb, var(--background) 80%, var(--card) 20%);
173- border-bottom-width: 1px;
174- border-bottom-style: solid;
175- border-bottom-color: color-mix(in srgb, var(--border) 50%, transparent);
176- position: sticky;
177- top: 0;
178- z-index: 1;
179- }
180-
181- .group-label {
182- font-size: 0.68rem;
183- font-weight: 600;
184- text-transform: uppercase;
185- letter-spacing: 0.12em;
186- color: var(--foreground);
187- }
188-
189- .group-count {
190- font-size: 0.6rem;
191- padding: 0.15rem 0.4rem;
192- border-width: 1px;
193- border-style: solid;
194- border-color: var(--border);
195- background: var(--background);
196- color: var(--muted-foreground);
197- }
198-
199- .group-entries {
200- display: flex;
201- flex-direction: column;
202- }
203-
204- .load-more-sentinel {
205- padding: 0.75rem;
206- display: flex;
207- align-items: center;
208- justify-content: center;
209- min-height: 40px;
210- }
211-
212- .loading-text {
213- font-size: 0.65rem;
214- color: var(--muted-foreground);
215- letter-spacing: 0.12em;
216- animation: pulse 1.5s ease-in-out infinite;
217- }
218-
219- @keyframes pulse {
220- 0%, 100% { opacity: 0.4; }
221- 50% { opacity: 1; }
222- }
223-</style>
D webui/src/pages/homepage/history/index.ts +0 -6
@@ -1,6 +0,0 @@
1-// History Components - Timeline, Search, Filters, Cleanup
2-export { default as HistoryItem } from './HistoryItem.svelte';
3-export { default as HistoryTimeline } from './HistoryTimeline.svelte';
4-export { default as HistorySearch } from './HistorySearch.svelte';
5-export { default as HistoryFilters } from './HistoryFilters.svelte';
6-export { default as HistoryCleanup } from './HistoryCleanup.svelte';
D webui/src/pages/homepage/keyboard.ts +0 -744
@@ -1,744 +0,0 @@
1-// ═══════════════════════════════════════════════════════════════════════════════
2-// Homepage Keyboard Navigation (Telescope-style)
3-// Vim-inspired navigation with fuzzy filtering and quick shortcuts
4-// ═══════════════════════════════════════════════════════════════════════════════
5-
6-import { homepageState } from './state.svelte';
7-import {
8- navigateTo,
9- deleteHistoryEntry,
10- deleteHistoryByRange,
11- clearAllHistory,
12- deleteHistoryByDomain,
13- getFavoriteByShortcut,
14- searchHistoryFTS,
15-} from './messaging';
16-import type { HistoryEntry, Favorite, HistoryCleanupRange } from './types';
17-
18-// ═══════════════════════════════════════════════════════════════════════════════
19-// TYPES
20-// ═══════════════════════════════════════════════════════════════════════════════
21-
22-interface KeyBinding {
23- key: string;
24- ctrl?: boolean;
25- shift?: boolean;
26- alt?: boolean;
27- meta?: boolean;
28- description: string;
29- action: () => void | Promise<void>;
30- context?: 'global' | 'history' | 'favorites' | 'command-palette' | 'cleanup';
31-}
32-
33-// ═══════════════════════════════════════════════════════════════════════════════
34-// STATE
35-// ═══════════════════════════════════════════════════════════════════════════════
36-
37-let pendingPrefix: string | null = null;
38-let prefixTimeout: ReturnType<typeof setTimeout> | null = null;
39-const PREFIX_TIMEOUT_MS = 1000;
40-
41-// ═══════════════════════════════════════════════════════════════════════════════
42-// HELPER FUNCTIONS
43-// ═══════════════════════════════════════════════════════════════════════════════
44-
45-function getDomain(url: string): string {
46- try {
47- return new URL(url).hostname;
48- } catch {
49- return url;
50- }
51-}
52-
53-function getCurrentItem(): HistoryEntry | Favorite | null {
54- const list = homepageState.currentList;
55- const idx = homepageState.focusedIndex;
56- return list[idx] ?? null;
57-}
58-
59-function clearPendingPrefix(): void {
60- pendingPrefix = null;
61- homepageState.setPendingKeyPrefix(null);
62- if (prefixTimeout) {
63- clearTimeout(prefixTimeout);
64- prefixTimeout = null;
65- }
66-}
67-
68-function setPendingPrefix(prefix: string): void {
69- pendingPrefix = prefix;
70- homepageState.setPendingKeyPrefix(prefix);
71- if (prefixTimeout) {
72- clearTimeout(prefixTimeout);
73- }
74- prefixTimeout = setTimeout(clearPendingPrefix, PREFIX_TIMEOUT_MS);
75-}
76-
77-// ═══════════════════════════════════════════════════════════════════════════════
78-// ACTIONS
79-// ═══════════════════════════════════════════════════════════════════════════════
80-
81-async function openCurrentItem(): Promise<void> {
82- const item = getCurrentItem();
83- if (item && 'url' in item) {
84- navigateTo(item.url);
85- }
86-}
87-
88-async function deleteCurrentHistoryEntry(): Promise<void> {
89- if (homepageState.activePanel !== 'history') return;
90-
91- const item = getCurrentItem() as HistoryEntry | null;
92- if (!item) return;
93-
94- await deleteHistoryEntry(item.id);
95-
96- // Adjust focus if needed
97- const newLength = homepageState.currentListLength;
98- if (homepageState.focusedIndex >= newLength) {
99- homepageState.setFocusedIndex(Math.max(0, newLength - 1));
100- }
101-}
102-
103-async function handleCleanupShortcut(range: HistoryCleanupRange): Promise<void> {
104- if (homepageState.activePanel !== 'history') return;
105-
106- const messages: Record<HistoryCleanupRange, string> = {
107- hour: 'Clear history from the last hour?',
108- day: 'Clear history from the last day?',
109- week: 'Clear history from the last week?',
110- month: 'Clear history from the last month?',
111- all: 'Clear ALL history? This cannot be undone.',
112- };
113-
114- homepageState.showConfirm(messages[range], async () => {
115- if (range === 'all') {
116- await clearAllHistory();
117- } else {
118- await deleteHistoryByRange(range);
119- }
120- });
121-}
122-
123-async function clearCurrentDomain(): Promise<void> {
124- if (homepageState.activePanel !== 'history') return;
125-
126- const item = getCurrentItem() as HistoryEntry | null;
127- if (!item) return;
128-
129- const domain = getDomain(item.url);
130- homepageState.showConfirm(
131- `Clear all history for ${domain}?`,
132- async () => {
133- await deleteHistoryByDomain(domain);
134- }
135- );
136-}
137-
138-async function handleQuickShortcut(key: number): Promise<void> {
139- const favorite = await getFavoriteByShortcut(key);
140- if (favorite) {
141- navigateTo(favorite.url);
142- }
143-}
144-
145-// ═══════════════════════════════════════════════════════════════════════════════
146-// KEY BINDINGS
147-// ═══════════════════════════════════════════════════════════════════════════════
148-
149-const globalBindings: KeyBinding[] = [
150- // Command palette
151- {
152- key: 'p',
153- ctrl: true,
154- description: 'Open command palette',
155- action: () => homepageState.openCommandPalette(),
156- },
157- // Note: "/" may require Shift on some keyboard layouts (e.g., AZERTY)
158- // We handle it specially in handleKeyDown to ignore Shift state
159- {
160- key: '/',
161- description: 'Open command palette / search',
162- action: () => homepageState.openCommandPalette(),
163- },
164-
165- // Quick navigation (1-9)
166- ...Array.from({ length: 9 }, (_, i) => ({
167- key: String(i + 1),
168- description: `Navigate to favorite shortcut ${i + 1}`,
169- action: () => handleQuickShortcut(i + 1),
170- })),
171-
172- // Panel navigation (g prefix)
173- {
174- key: 'h',
175- description: 'Go to History panel',
176- action: () => {
177- if (pendingPrefix === 'g') {
178- homepageState.setActivePanel('history');
179- clearPendingPrefix();
180- }
181- },
182- context: 'global',
183- },
184- {
185- key: 'f',
186- description: 'Go to Favorites panel',
187- action: () => {
188- if (pendingPrefix === 'g') {
189- homepageState.setActivePanel('favorites');
190- clearPendingPrefix();
191- }
192- },
193- context: 'global',
194- },
195- {
196- key: 'a',
197- description: 'Go to Analytics panel',
198- action: () => {
199- if (pendingPrefix === 'g') {
200- homepageState.setActivePanel('analytics');
201- clearPendingPrefix();
202- }
203- },
204- context: 'global',
205- },
206-
207- // Escape
208- {
209- key: 'Escape',
210- description: 'Close modals / clear search',
211- action: () => {
212- clearPendingPrefix();
213- if (homepageState.commandPaletteOpen) {
214- homepageState.closeCommandPalette();
215- } else if (homepageState.confirmModalOpen) {
216- homepageState.cancelConfirm();
217- } else if (homepageState.cleanupModalOpen) {
218- homepageState.closeCleanupModal();
219- } else if (homepageState.historySearchQuery) {
220- homepageState.setHistorySearchQuery('');
221- }
222- },
223- },
224-
225- // Tab - cycle through panels
226- {
227- key: 'Tab',
228- description: 'Next panel',
229- action: () => {
230- const panels = ['history', 'favorites', 'analytics'] as const;
231- const currentIdx = panels.indexOf(homepageState.activePanel);
232- const nextIdx = (currentIdx + 1) % panels.length;
233- const nextPanel = panels[nextIdx];
234- if (nextPanel) {
235- homepageState.setActivePanel(nextPanel);
236- }
237- },
238- },
239- {
240- key: 'Tab',
241- shift: true,
242- description: 'Previous panel',
243- action: () => {
244- const panels = ['history', 'favorites', 'analytics'] as const;
245- const currentIdx = panels.indexOf(homepageState.activePanel);
246- const prevIdx = (currentIdx - 1 + panels.length) % panels.length;
247- const prevPanel = panels[prevIdx];
248- if (prevPanel) {
249- homepageState.setActivePanel(prevPanel);
250- }
251- },
252- },
253-];
254-
255-const listBindings: KeyBinding[] = [
256- // Navigation
257- {
258- key: 'j',
259- description: 'Move down',
260- action: () => homepageState.focusNext(),
261- },
262- {
263- key: 'ArrowDown',
264- description: 'Move down',
265- action: () => homepageState.focusNext(),
266- },
267- {
268- key: 'k',
269- description: 'Move up',
270- action: () => homepageState.focusPrev(),
271- },
272- {
273- key: 'ArrowUp',
274- description: 'Move up',
275- action: () => homepageState.focusPrev(),
276- },
277- {
278- key: 'n',
279- ctrl: true,
280- description: 'Move down',
281- action: () => homepageState.focusNext(),
282- },
283- {
284- key: 'p',
285- ctrl: true,
286- description: 'Move up',
287- action: () => homepageState.focusPrev(),
288- },
289- {
290- key: 'g',
291- description: 'Go to first / prefix for panel nav',
292- action: () => {
293- if (pendingPrefix === 'g') {
294- homepageState.focusFirst();
295- clearPendingPrefix();
296- } else {
297- setPendingPrefix('g');
298- }
299- },
300- },
301- {
302- key: 'G',
303- shift: true,
304- description: 'Go to last',
305- action: () => homepageState.focusLast(),
306- },
307-
308- // Selection
309- {
310- key: 'Enter',
311- description: 'Open selected item',
312- action: openCurrentItem,
313- },
314- {
315- key: 'o',
316- description: 'Open selected item',
317- action: openCurrentItem,
318- },
319-];
320-
321-const historyBindings: KeyBinding[] = [
322- // Delete
323- {
324- key: 'd',
325- description: 'Delete entry / prefix for cleanup',
326- action: () => {
327- if (pendingPrefix === 'D') {
328- // D d - clear last day
329- handleCleanupShortcut('day');
330- clearPendingPrefix();
331- } else {
332- deleteCurrentHistoryEntry();
333- }
334- },
335- context: 'history',
336- },
337- {
338- key: 'x',
339- description: 'Delete entry',
340- action: deleteCurrentHistoryEntry,
341- context: 'history',
342- },
343-
344- // Cleanup shortcuts (D prefix)
345- {
346- key: 'D',
347- shift: true,
348- description: 'Cleanup prefix / Clear ALL',
349- action: () => {
350- if (pendingPrefix === 'D') {
351- // D D - clear all
352- handleCleanupShortcut('all');
353- clearPendingPrefix();
354- } else {
355- setPendingPrefix('D');
356- }
357- },
358- context: 'history',
359- },
360- {
361- key: 'h',
362- description: 'Clear last hour',
363- action: () => {
364- if (pendingPrefix === 'D') {
365- handleCleanupShortcut('hour');
366- clearPendingPrefix();
367- }
368- },
369- context: 'history',
370- },
371- {
372- key: 'w',
373- description: 'Clear last week',
374- action: () => {
375- if (pendingPrefix === 'D') {
376- handleCleanupShortcut('week');
377- clearPendingPrefix();
378- }
379- },
380- context: 'history',
381- },
382- {
383- key: 'm',
384- description: 'Clear last month',
385- action: () => {
386- if (pendingPrefix === 'D') {
387- handleCleanupShortcut('month');
388- clearPendingPrefix();
389- }
390- },
391- context: 'history',
392- },
393- {
394- key: '@',
395- shift: true,
396- description: 'Clear current domain',
397- action: () => {
398- if (pendingPrefix === 'D') {
399- clearCurrentDomain();
400- clearPendingPrefix();
401- }
402- },
403- context: 'history',
404- },
405-];
406-
407-const commandPaletteBindings: KeyBinding[] = [
408- {
409- key: 'j',
410- ctrl: true,
411- description: 'Move down',
412- action: () => homepageState.focusNext(),
413- context: 'command-palette',
414- },
415- {
416- key: 'n',
417- ctrl: true,
418- description: 'Move down',
419- action: () => homepageState.focusNext(),
420- context: 'command-palette',
421- },
422- {
423- key: 'ArrowDown',
424- description: 'Move down',
425- action: () => homepageState.focusNext(),
426- context: 'command-palette',
427- },
428- {
429- key: 'k',
430- ctrl: true,
431- description: 'Move up',
432- action: () => homepageState.focusPrev(),
433- context: 'command-palette',
434- },
435- {
436- key: 'p',
437- ctrl: true,
438- description: 'Move up',
439- action: () => homepageState.focusPrev(),
440- context: 'command-palette',
441- },
442- {
443- key: 'ArrowUp',
444- description: 'Move up',
445- action: () => homepageState.focusPrev(),
446- context: 'command-palette',
447- },
448- {
449- key: 'Enter',
450- description: 'Execute selected command',
451- action: () => {
452- // This will be handled by the command palette component
453- },
454- context: 'command-palette',
455- },
456-];
457-
458-// ═══════════════════════════════════════════════════════════════════════════════
459-// KEY HANDLER
460-// ═══════════════════════════════════════════════════════════════════════════════
461-
462-function matchBinding(e: KeyboardEvent, binding: KeyBinding): boolean {
463- if (binding.key !== e.key) return false;
464- if (!!binding.ctrl !== e.ctrlKey) return false;
465- if (!!binding.shift !== e.shiftKey) return false;
466- if (!!binding.alt !== e.altKey) return false;
467- if (!!binding.meta !== e.metaKey) return false;
468- return true;
469-}
470-
471-function getActiveBindings(): KeyBinding[] {
472- const bindings: KeyBinding[] = [...globalBindings];
473-
474- if (homepageState.commandPaletteOpen) {
475- bindings.push(...commandPaletteBindings);
476- } else {
477- bindings.push(...listBindings);
478-
479- switch (homepageState.activePanel) {
480- case 'history':
481- bindings.push(...historyBindings);
482- break;
483- case 'favorites':
484- // Add favorites-specific bindings here
485- break;
486- }
487- }
488-
489- return bindings;
490-}
491-
492-export function handleKeyDown(e: KeyboardEvent): void {
493- // Skip if focused on input/textarea (except for navigation keys)
494- const target = e.target as HTMLElement;
495- const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA';
496-
497- // Special handling for "/" - opens command palette from anywhere
498- // Ignore modifier keys since "/" may require Shift on some layouts (AZERTY)
499- if (e.key === '/') {
500- e.preventDefault();
501- e.stopPropagation();
502- if (isInput) {
503- (target as HTMLInputElement).blur();
504- }
505- homepageState.openCommandPalette();
506- return;
507- }
508-
509- if (isInput) {
510- // Allow specific keys when in input
511- const allowedInInput = ['Escape', 'ArrowUp', 'ArrowDown', 'Enter'];
512- const ctrlAllowed = ['j', 'k', 'n', 'p'];
513-
514- if (!allowedInInput.includes(e.key) && !(e.ctrlKey && ctrlAllowed.includes(e.key))) {
515- return;
516- }
517- }
518-
519- const bindings = getActiveBindings();
520-
521- for (const binding of bindings) {
522- if (matchBinding(e, binding)) {
523- // Check context
524- if (binding.context) {
525- switch (binding.context) {
526- case 'history':
527- if (homepageState.activePanel !== 'history') continue;
528- break;
529- case 'favorites':
530- if (homepageState.activePanel !== 'favorites') continue;
531- break;
532- case 'command-palette':
533- if (!homepageState.commandPaletteOpen) continue;
534- break;
535- }
536- }
537-
538- e.preventDefault();
539- e.stopPropagation();
540- binding.action();
541- return;
542- }
543- }
544-
545- // Handle 'g' prefix standalone
546- if (e.key === 'g' && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && !isInput) {
547- if (!pendingPrefix) {
548- e.preventDefault();
549- setPendingPrefix('g');
550- }
551- }
552-}
553-
554-// ═══════════════════════════════════════════════════════════════════════════════
555-// TIERED SEARCH (Client-side first, then FTS)
556-// ═══════════════════════════════════════════════════════════════════════════════
557-
558-let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
559-const SEARCH_DEBOUNCE_MS = 400;
560-const MIN_FTS_QUERY_LENGTH = 3;
561-
562-/**
563- * Fuzzy match score calculator.
564- * Supports words in any order (fzf-style).
565- * Returns score > 0 if match, 0 if no match. Higher = better.
566- */
567-function fuzzyScore(text: string, query: string): number {
568- const lowerText = text.toLowerCase();
569- const lowerQuery = query.toLowerCase();
570-
571- // Split query into words for "any order" matching
572- const queryWords = lowerQuery.split(/\s+/).filter(w => w.length > 0);
573- if (queryWords.length === 0) return 0;
574-
575- let totalScore = 0;
576-
577- for (const word of queryWords) {
578- // Exact substring match (highest score)
579- const exactIdx = lowerText.indexOf(word);
580- if (exactIdx !== -1) {
581- // Bonus for word boundary match (start of text or after space/punctuation)
582- const atBoundary = exactIdx === 0 || /[\s\-_./]/.test(lowerText[exactIdx - 1] || '');
583- totalScore += 100 + (atBoundary ? 50 : 0) + (word.length * 10);
584- continue;
585- }
586-
587- // Fuzzy: chars in sequence (lower score)
588- let qi = 0;
589- let lastMatchIdx = -1;
590- let consecutiveBonus = 0;
591-
592- for (let i = 0; i < lowerText.length && qi < word.length; i++) {
593- if (lowerText[i] === word[qi]) {
594- // Bonus for consecutive matches
595- if (lastMatchIdx === i - 1) consecutiveBonus += 5;
596- lastMatchIdx = i;
597- qi++;
598- }
599- }
600-
601- if (qi === word.length) {
602- // All chars found in order
603- totalScore += 30 + consecutiveBonus;
604- } else {
605- // This query word not found - no match
606- return 0;
607- }
608- }
609-
610- return totalScore;
611-}
612-
613-interface ScoredEntry {
614- entry: HistoryEntry;
615- score: number;
616-}
617-
618-/**
619- * Filter history entries client-side using fuzzy matching.
620- * Combines title + URL so words can match across both fields.
621- * E.g. "google search" matches title "Google" + URL containing "search"
622- */
623-function filterHistoryLocal(query: string): HistoryEntry[] {
624- const q = query.trim();
625- if (!q) return [];
626-
627- const scored: ScoredEntry[] = [];
628-
629- for (const entry of homepageState.history) {
630- const title = entry.title || '';
631- const url = entry.url;
632-
633- // Combine title and URL for cross-field word matching
634- // Title gets priority (appears first), URL stripped of protocol
635- const urlClean = url.replace(/^https?:\/\//, '').replace(/\/$/, '');
636- const combined = `${title} ${urlClean}`;
637-
638- const score = fuzzyScore(combined, q);
639-
640- // Boost score if title matches directly (more relevant)
641- const titleBonus = fuzzyScore(title, q) > 0 ? 50 : 0;
642-
643- if (score > 0) {
644- scored.push({ entry, score: score + titleBonus });
645- }
646- }
647-
648- // Sort by score descending, limit results
649- scored.sort((a, b) => b.score - a.score);
650- return scored.slice(0, 50).map(s => s.entry);
651-}
652-
653-export function handleSearchInput(query: string): void {
654- homepageState.setHistorySearchQuery(query);
655-
656- if (searchDebounceTimer) {
657- clearTimeout(searchDebounceTimer);
658- searchDebounceTimer = null;
659- }
660-
661- const trimmed = query.trim();
662- if (!trimmed) {
663- homepageState.setHistorySearchResults([]);
664- return;
665- }
666-
667- // Tier 1: Instant client-side fuzzy filter on cached history
668- const localResults = filterHistoryLocal(trimmed);
669- homepageState.setHistorySearchResults(localResults);
670-
671- // Tier 2: If local returns 0 results AND query is long enough, try FTS
672- // Also try FTS after debounce if query is substantial (deeper search)
673- if (trimmed.length >= MIN_FTS_QUERY_LENGTH) {
674- searchDebounceTimer = setTimeout(() => {
675- // Only call FTS if local results are sparse
676- if (localResults.length < 5) {
677- searchHistoryFTS(trimmed);
678- }
679- }, SEARCH_DEBOUNCE_MS);
680- }
681-}
682-
683-// ═══════════════════════════════════════════════════════════════════════════════
684-// KEYBOARD HINTS FOR UI
685-// ═══════════════════════════════════════════════════════════════════════════════
686-
687-export interface KeyboardHint {
688- keys: string[];
689- description: string;
690-}
691-
692-export function getContextualHints(): KeyboardHint[] {
693- const hints: KeyboardHint[] = [];
694-
695- if (homepageState.commandPaletteOpen) {
696- hints.push(
697- { keys: ['Ctrl+j', '↓'], description: 'Move down' },
698- { keys: ['Ctrl+k', '↑'], description: 'Move up' },
699- { keys: ['Enter'], description: 'Execute' },
700- { keys: ['Escape'], description: 'Close' }
701- );
702- } else {
703- hints.push(
704- { keys: ['j', '↓'], description: 'Move down' },
705- { keys: ['k', '↑'], description: 'Move up' },
706- { keys: ['Enter', 'o'], description: 'Open' },
707- { keys: ['/'], description: 'Search' }
708- );
709-
710- if (homepageState.activePanel === 'history') {
711- hints.push(
712- { keys: ['d', 'x'], description: 'Delete' },
713- { keys: ['D h'], description: 'Clear hour' },
714- { keys: ['D d'], description: 'Clear day' },
715- { keys: ['D D'], description: 'Clear all' }
716- );
717- }
718-
719- hints.push(
720- { keys: ['g h'], description: 'History' },
721- { keys: ['g f'], description: 'Favorites' },
722- { keys: ['g a'], description: 'Analytics' },
723- { keys: ['1-9'], description: 'Quick nav' }
724- );
725- }
726-
727- return hints;
728-}
729-
730-// ═══════════════════════════════════════════════════════════════════════════════
731-// INITIALIZATION
732-// ═══════════════════════════════════════════════════════════════════════════════
733-
734-export function initializeKeyboard(): () => void {
735- document.addEventListener('keydown', handleKeyDown);
736-
737- return () => {
738- document.removeEventListener('keydown', handleKeyDown);
739- clearPendingPrefix();
740- if (searchDebounceTimer) {
741- clearTimeout(searchDebounceTimer);
742- }
743- };
744-}
D webui/src/pages/homepage/messaging.ts +0 -546
@@ -1,546 +0,0 @@
1-// ═══════════════════════════════════════════════════════════════════════════════
2-// Homepage bridge
3-// Communication layer between frontend and Go backend via WebKit or CEF bridge.
4-// ═══════════════════════════════════════════════════════════════════════════════
5-
6-import type {
7- MessageType,
8- HistoryEntry,
9- Folder,
10- Tag,
11- Favorite,
12- DomainStat,
13- Analytics,
14- HistoryCleanupRange,
15- FolderCreateRequest,
16- FolderUpdateRequest,
17- TagCreateRequest,
18- TagUpdateRequest,
19-} from './types';
20-import { homepageState } from './state.svelte';
21-
22-// ═══════════════════════════════════════════════════════════════════════════════
23-// REQUEST TRACKING
24-// ═══════════════════════════════════════════════════════════════════════════════
25-
26-// Pending requests store resolvers for different response types.
27-// The resolve function accepts `unknown` since requests with different
28-// response types share the same map. Type safety is maintained at the
29-// sendMessage<T> API level.
30-interface PendingRequest {
31- resolve: (value: unknown) => void;
32- reject: (error: Error) => void;
33- timeout: ReturnType<typeof setTimeout>;
34-}
35-
36-const pendingRequests = new Map<string, PendingRequest>();
37-const REQUEST_TIMEOUT_MS = 10_000;
38-
39-let callbacksInitialized = false;
40-
41-// ═══════════════════════════════════════════════════════════════════════════════
42-// WEBKIT BRIDGE UTILITIES
43-// ═══════════════════════════════════════════════════════════════════════════════
44-
45-function getWebKitBridge(): DumberBridge | null {
46- const bridge = window.webkit?.messageHandlers?.dumber ?? window.dumber;
47- if (bridge && typeof bridge.postMessage === 'function') {
48- return bridge;
49- }
50- return null;
51-}
52-
53-function getWebViewId(): number {
54- return window.__dumber_webview_id ?? 0;
55-}
56-
57-function generateRequestId(type: string): string {
58- return `${type}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
59-}
60-
61-// ═══════════════════════════════════════════════════════════════════════════════
62-// CALLBACK INITIALIZATION
63-// ═══════════════════════════════════════════════════════════════════════════════
64-
65-function initializeCallbacks(): void {
66- if (callbacksInitialized) return;
67-
68- // Generic response handler - accepts a single Response object from Go backend
69- window.__dumber_homepage_response = (response) => {
70- const request = pendingRequests.get(response.requestId);
71- if (!request) return;
72-
73- clearTimeout(request.timeout);
74- pendingRequests.delete(response.requestId);
75-
76- if (response.success) {
77- request.resolve(response.data);
78- } else {
79- request.reject(new Error(response.error ?? 'Unknown error'));
80- }
81- };
82-
83- // Legacy callbacks for backward compatibility
84- // These can receive either raw data or a Response object
85- window.__dumber_history_timeline = (dataOrResponse) => {
86- handleLegacyOrResponseCallback(dataOrResponse);
87- };
88-
89- window.__dumber_folders = (dataOrResponse) => {
90- handleLegacyOrResponseCallback(dataOrResponse);
91- };
92-
93- window.__dumber_tags = (dataOrResponse) => {
94- handleLegacyOrResponseCallback(dataOrResponse);
95- };
96-
97- window.__dumber_favorites = (dataOrResponse) => {
98- handleLegacyOrResponseCallback(dataOrResponse);
99- };
100-
101- window.__dumber_analytics = (dataOrResponse) => {
102- handleLegacyOrResponseCallback(dataOrResponse);
103- };
104-
105- window.__dumber_domain_stats = (dataOrResponse) => {
106- handleLegacyOrResponseCallback(dataOrResponse);
107- };
108-
109- window.__dumber_history_search_results = (dataOrResponse) => {
110- handleLegacyOrResponseCallback(dataOrResponse);
111- };
112-
113- window.__dumber_history_deleted = (dataOrResponse) => {
114- handleLegacyOrResponseCallback(dataOrResponse);
115- };
116-
117- window.__dumber_history_cleared = (dataOrResponse) => {
118- handleLegacyOrResponseCallback(dataOrResponse);
119- };
120-
121- window.__dumber_error = (errorOrResponse) => {
122- // Handle both formats: direct error string or Response object with error
123- if (typeof errorOrResponse === 'string') {
124- // Legacy format: direct error string
125- console.error('[messaging] Legacy error:', errorOrResponse);
126- } else if (errorOrResponse && typeof errorOrResponse === 'object') {
127- // Response object format
128- const request = pendingRequests.get(errorOrResponse.requestId ?? 'default');
129- if (request) {
130- clearTimeout(request.timeout);
131- pendingRequests.delete(errorOrResponse.requestId ?? 'default');
132- request.reject(new Error(errorOrResponse.error ?? 'Unknown error'));
133- }
134- }
135- };
136-
137- // Listen for favorites changes from omnibox (e.g., when user toggles favorite)
138- document.addEventListener('dumber:favorites-changed', () => {
139- console.log('[homepage] Favorites changed event received, refreshing...');
140- fetchFavorites();
141- });
142-
143- callbacksInitialized = true;
144-}
145-
146-// Handle both legacy raw data and new Response object format
147-function handleLegacyOrResponseCallback(dataOrResponse: unknown): void {
148- // Check if it's a Response object (has requestId and success fields)
149- const response = dataOrResponse as { requestId?: string; success?: boolean; data?: unknown; error?: string };
150- if (response && typeof response === 'object' && 'requestId' in response && 'success' in response) {
151- // New Response object format
152- const request = pendingRequests.get(response.requestId || 'default');
153- if (request) {
154- clearTimeout(request.timeout);
155- pendingRequests.delete(response.requestId || 'default');
156- if (response.success) {
157- request.resolve(response.data);
158- } else {
159- request.reject(new Error(response.error || 'Unknown error'));
160- }
161- }
162- } else {
163- // Legacy format: raw data (for backward compatibility)
164- // Try to find any pending request (not ideal but maintains compatibility)
165- console.warn('[messaging] Received legacy callback format without requestId');
166- }
167-}
168-
169-// ═══════════════════════════════════════════════════════════════════════════════
170-// CORE SEND FUNCTION
171-// ═══════════════════════════════════════════════════════════════════════════════
172-
173-async function sendMessage<T = unknown>(
174- type: MessageType,
175- payload?: Record<string, unknown>
176-): Promise<T> {
177- initializeCallbacks();
178-
179- const bridge = getWebKitBridge();
180- if (!bridge) {
181- throw new Error('Dumber bridge not available');
182- }
183-
184- const requestId = generateRequestId(type);
185-
186- return new Promise<T>((resolve, reject) => {
187- const timeout = setTimeout(() => {
188- pendingRequests.delete(requestId);
189- reject(new Error(`Request timeout: ${type}`));
190- }, REQUEST_TIMEOUT_MS);
191-
192- // Cast resolve to accept unknown - type safety is maintained at API level
193- // since sendMessage<T> returns Promise<T> and callers expect typed responses
194- pendingRequests.set(requestId, {
195- resolve: resolve as (value: unknown) => void,
196- reject,
197- timeout,
198- });
199-
200- try {
201- bridge.postMessage({
202- type,
203- webviewId: getWebViewId(),
204- payload: { requestId, ...payload },
205- });
206- } catch (error) {
207- clearTimeout(timeout);
208- pendingRequests.delete(requestId);
209- reject(error);
210- }
211- });
212-}
213-
214-// ═══════════════════════════════════════════════════════════════════════════════
215-// HISTORY API
216-// ═══════════════════════════════════════════════════════════════════════════════
217-
218-export async function fetchHistoryTimeline(
219- limit: number = 50,
220- offset: number = 0
221-): Promise<HistoryEntry[]> {
222- homepageState.setHistoryLoading(true);
223- try {
224- const data = await sendMessage<HistoryEntry[]>('history_timeline', {
225- limit,
226- offset,
227- });
228- const entries = Array.isArray(data) ? data : [];
229-
230- if (offset === 0) {
231- homepageState.setHistory(entries);
232- } else {
233- homepageState.appendHistory(entries);
234- }
235-
236- homepageState.setHistoryOffset(offset + entries.length);
237- homepageState.setHasMoreHistory(entries.length === limit);
238-
239- return entries;
240- } catch (error) {
241- console.error('[messaging] fetchHistoryTimeline error:', error);
242- if (offset === 0) {
243- homepageState.setHistory([]);
244- }
245- homepageState.setHasMoreHistory(false);
246- return [];
247- } finally {
248- homepageState.setHistoryLoading(false);
249- }
250-}
251-
252-export async function searchHistoryFTS(query: string): Promise<HistoryEntry[]> {
253- if (!query.trim()) {
254- homepageState.setHistorySearchResults([]);
255- return [];
256- }
257-
258- homepageState.setHistorySearching(true);
259- try {
260- const data = await sendMessage<HistoryEntry[]>('history_search_fts', {
261- query,
262- limit: 100,
263- });
264- const results = Array.isArray(data) ? data : [];
265- homepageState.setHistorySearchResults(results);
266- return results;
267- } catch (error) {
268- console.error('[messaging] searchHistoryFTS error:', error);
269- homepageState.setHistorySearchResults([]);
270- return [];
271- } finally {
272- homepageState.setHistorySearching(false);
273- }
274-}
275-
276-export async function deleteHistoryEntry(id: number): Promise<void> {
277- try {
278- await sendMessage('history_delete_entry', { id });
279- homepageState.deleteHistoryEntry(id);
280- } catch (error) {
281- console.error('[messaging] deleteHistoryEntry error:', error);
282- throw error;
283- }
284-}
285-
286-export async function deleteHistoryByRange(range: HistoryCleanupRange): Promise<void> {
287- try {
288- await sendMessage('history_delete_range', { range });
289- // Refresh history after deletion
290- await fetchHistoryTimeline();
291- } catch (error) {
292- console.error('[messaging] deleteHistoryByRange error:', error);
293- throw error;
294- }
295-}
296-
297-export async function clearAllHistory(): Promise<void> {
298- try {
299- await sendMessage('history_clear_all');
300- homepageState.clearHistory();
301- } catch (error) {
302- console.error('[messaging] clearAllHistory error:', error);
303- throw error;
304- }
305-}
306-
307-export async function deleteHistoryByDomain(domain: string): Promise<void> {
308- try {
309- await sendMessage('history_delete_domain', { domain });
310- // Refresh history after deletion
311- await fetchHistoryTimeline();
312- } catch (error) {
313- console.error('[messaging] deleteHistoryByDomain error:', error);
314- throw error;
315- }
316-}
317-
318-// ═══════════════════════════════════════════════════════════════════════════════
319-// FOLDERS API
320-// ═══════════════════════════════════════════════════════════════════════════════
321-
322-export async function fetchFolders(): Promise<Folder[]> {
323- try {
324- const data = await sendMessage<Folder[]>('folder_list');
325- const folders = Array.isArray(data) ? data : [];
326- homepageState.setFolders(folders);
327- return folders;
328- } catch (error) {
329- console.error('[messaging] fetchFolders error:', error);
330- homepageState.setFolders([]);
331- return [];
332- }
333-}
334-
335-export async function createFolder(req: FolderCreateRequest): Promise<Folder> {
336- const folder = await sendMessage<Folder>('folder_create', { ...req });
337- homepageState.addFolder(folder);
338- return folder;
339-}
340-
341-export async function updateFolder(req: FolderUpdateRequest): Promise<void> {
342- await sendMessage('folder_update', { ...req });
343- homepageState.updateFolder({
344- ...homepageState.folders.find(f => f.id === req.id)!,
345- name: req.name,
346- icon: req.icon ?? null,
347- });
348-}
349-
350-export async function deleteFolder(id: number): Promise<void> {
351- await sendMessage('folder_delete', { id });
352- homepageState.deleteFolder(id);
353-}
354-
355-// ═══════════════════════════════════════════════════════════════════════════════
356-// TAGS API
357-// ═══════════════════════════════════════════════════════════════════════════════
358-
359-export async function fetchTags(): Promise<Tag[]> {
360- try {
361- const data = await sendMessage<Tag[]>('tag_list');
362- const tags = Array.isArray(data) ? data : [];
363- homepageState.setTags(tags);
364- return tags;
365- } catch (error) {
366- console.error('[messaging] fetchTags error:', error);
367- homepageState.setTags([]);
368- return [];
369- }
370-}
371-
372-export async function createTag(req: TagCreateRequest): Promise<Tag> {
373- const tag = await sendMessage<Tag>('tag_create', { ...req });
374- homepageState.addTag(tag);
375- return tag;
376-}
377-
378-export async function updateTag(req: TagUpdateRequest): Promise<void> {
379- await sendMessage('tag_update', { ...req });
380- const existing = homepageState.tags.find(t => t.id === req.id);
381- if (existing) {
382- homepageState.updateTag({
383- ...existing,
384- name: req.name ?? existing.name,
385- color: req.color ?? existing.color,
386- });
387- }
388-}
389-
390-export async function deleteTag(id: number): Promise<void> {
391- await sendMessage('tag_delete', { id });
392- homepageState.deleteTag(id);
393-}
394-
395-export async function assignTag(favoriteId: number, tagId: number): Promise<void> {
396- await sendMessage('tag_assign', { favorite_id: favoriteId, tag_id: tagId });
397- // Update local state
398- const favorite = homepageState.favorites.find(f => f.id === favoriteId);
399- const tag = homepageState.tags.find(t => t.id === tagId);
400- if (favorite && tag) {
401- homepageState.updateFavorite({
402- ...favorite,
403- tags: [...(favorite.tags || []), tag],
404- });
405- }
406-}
407-
408-export async function removeTag(favoriteId: number, tagId: number): Promise<void> {
409- await sendMessage('tag_remove', { favorite_id: favoriteId, tag_id: tagId });
410- // Update local state
411- const favorite = homepageState.favorites.find(f => f.id === favoriteId);
412- if (favorite) {
413- homepageState.updateFavorite({
414- ...favorite,
415- tags: favorite.tags?.filter(t => t.id !== tagId),
416- });
417- }
418-}
419-
420-// ═══════════════════════════════════════════════════════════════════════════════
421-// FAVORITES API
422-// ═══════════════════════════════════════════════════════════════════════════════
423-
424-export async function fetchFavorites(): Promise<Favorite[]> {
425- homepageState.setFavoritesLoading(true);
426- try {
427- const data = await sendMessage<Favorite[]>('favorite_list');
428- const favorites = Array.isArray(data) ? data : [];
429- homepageState.setFavorites(favorites);
430- return favorites;
431- } catch (error) {
432- console.error('[messaging] fetchFavorites error:', error);
433- homepageState.setFavorites([]);
434- return [];
435- } finally {
436- homepageState.setFavoritesLoading(false);
437- }
438-}
439-
440-export async function setFavoriteShortcut(
441- favoriteId: number,
442- shortcutKey: number | null
443-): Promise<void> {
444- await sendMessage('favorite_set_shortcut', {
445- favorite_id: favoriteId,
446- shortcut_key: shortcutKey,
447- });
448- // Update local state
449- const favorite = homepageState.favorites.find(f => f.id === favoriteId);
450- if (favorite) {
451- // Clear shortcut from any other favorite that had this key
452- if (shortcutKey !== null) {
453- for (const f of homepageState.favorites) {
454- if (f.shortcut_key === shortcutKey && f.id !== favoriteId) {
455- homepageState.updateFavorite({ ...f, shortcut_key: null });
456- }
457- }
458- }
459- homepageState.updateFavorite({ ...favorite, shortcut_key: shortcutKey });
460- }
461-}
462-
463-export async function getFavoriteByShortcut(shortcutKey: number): Promise<Favorite | null> {
464- try {
465- const data = await sendMessage<Favorite | null>('favorite_get_by_shortcut', {
466- shortcut_key: shortcutKey,
467- });
468- return data;
469- } catch (error) {
470- console.error('[messaging] getFavoriteByShortcut error:', error);
471- return null;
472- }
473-}
474-
475-export async function setFavoriteFolder(
476- favoriteId: number,
477- folderId: number | null
478-): Promise<void> {
479- await sendMessage('favorite_set_folder', {
480- favorite_id: favoriteId,
481- folder_id: folderId,
482- });
483- // Update local state
484- const favorite = homepageState.favorites.find(f => f.id === favoriteId);
485- if (favorite) {
486- homepageState.updateFavorite({ ...favorite, folder_id: folderId });
487- }
488-}
489-
490-// ═══════════════════════════════════════════════════════════════════════════════
491-// ANALYTICS API
492-// ═══════════════════════════════════════════════════════════════════════════════
493-
494-export async function fetchAnalytics(): Promise<Analytics | null> {
495- homepageState.setAnalyticsLoading(true);
496- try {
497- const data = await sendMessage<Analytics>('history_analytics');
498- homepageState.setAnalytics(data);
499- return data;
500- } catch (error) {
501- console.error('[messaging] fetchAnalytics error:', error);
502- homepageState.setAnalytics(null);
503- return null;
504- } finally {
505- homepageState.setAnalyticsLoading(false);
506- }
507-}
508-
509-export async function fetchDomainStats(limit: number = 20): Promise<DomainStat[]> {
510- try {
511- const data = await sendMessage<DomainStat[]>('history_domain_stats', { limit });
512- const stats = Array.isArray(data) ? data : [];
513- homepageState.setDomainStats(stats);
514- return stats;
515- } catch (error) {
516- console.error('[messaging] fetchDomainStats error:', error);
517- homepageState.setDomainStats([]);
518- return [];
519- }
520-}
521-
522-// ═══════════════════════════════════════════════════════════════════════════════
523-// INITIALIZATION
524-// ═══════════════════════════════════════════════════════════════════════════════
525-
526-export async function initializeHomepage(): Promise<void> {
527- // Initialize callbacks first
528- initializeCallbacks();
529-
530- // Fetch all initial data in parallel
531- await Promise.all([
532- fetchHistoryTimeline(),
533- fetchFolders(),
534- fetchTags(),
535- fetchFavorites(),
536- fetchAnalytics(),
537- ]);
538-}
539-
540-// ═══════════════════════════════════════════════════════════════════════════════
541-// NAVIGATION
542-// ═══════════════════════════════════════════════════════════════════════════════
543-
544-export function navigateTo(url: string): void {
545- window.location.href = url;
546-}
D webui/src/pages/homepage/panels/AnalyticsPanel.svelte +0 -246
@@ -1,246 +0,0 @@
1-<script lang="ts">
2- import { homepageState } from '../state.svelte';
3- import { fetchAnalytics } from '../messaging';
4- import { DailyVisitsChart, HourlyActivityChart } from '../analytics';
5-
6- // Fetch analytics on mount
7- $effect(() => {
8- if (!homepageState.analytics) {
9- fetchAnalytics();
10- }
11- });
12-
13- const formatNumber = (n: number): string => {
14- if (n >= 1000) {
15- return `${(n / 1000).toFixed(1)}k`;
16- }
17- return n.toString();
18- };
19-</script>
20-
21-<div class="analytics-panel">
22- {#if homepageState.analyticsLoading}
23- <div class="loading-state">
24- <span class="loading-spinner"></span>
25- <span class="loading-text">LOADING ANALYTICS...</span>
26- </div>
27- {:else if homepageState.analytics}
28- <div class="stats-grid">
29- <div class="stat-card">
30- <span class="stat-value">{formatNumber(homepageState.analytics.total_entries)}</span>
31- <span class="stat-label">TOTAL ENTRIES</span>
32- </div>
33- <div class="stat-card">
34- <span class="stat-value">{formatNumber(homepageState.analytics.total_visits)}</span>
35- <span class="stat-label">TOTAL VISITS</span>
36- </div>
37- <div class="stat-card">
38- <span class="stat-value">{homepageState.analytics.unique_days}</span>
39- <span class="stat-label">DAYS ACTIVE</span>
40- </div>
41- <div class="stat-card">
42- <span class="stat-value">{homepageState.domainStats.length}</span>
43- <span class="stat-label">UNIQUE DOMAINS</span>
44- </div>
45- </div>
46-
47- <div class="section">
48- <div class="section-header">
49- <span class="section-title">TOP DOMAINS</span>
50- </div>
51- <div class="domain-list">
52- {#each homepageState.domainStats.slice(0, 10) as domain, i (domain.domain)}
53- <div class="domain-item">
54- <span class="domain-rank">{i + 1}</span>
55- <span class="domain-name">{domain.domain}</span>
56- <span class="domain-stats">
57- <span class="domain-pages">{domain.page_count} pages</span>
58- <span class="domain-visits">{domain.total_visits} visits</span>
59- </span>
60- </div>
61- {/each}
62- </div>
63- </div>
64-
65- <!-- Daily Visits Chart (30 days) -->
66- {#if homepageState.dailyVisits.length > 0}
67- <div class="section">
68- <div class="section-header">
69- <span class="section-title">VISITS (LAST 30 DAYS)</span>
70- </div>
71- <div class="chart-container">
72- <DailyVisitsChart dailyVisits={homepageState.dailyVisits} />
73- </div>
74- </div>
75- {/if}
76-
77- <!-- Hourly Activity Chart -->
78- {#if homepageState.analytics.hourly_distribution.length > 0}
79- <div class="section">
80- <div class="section-header">
81- <span class="section-title">ACTIVITY BY HOUR</span>
82- </div>
83- <div class="chart-container">
84- <HourlyActivityChart hourlyDistribution={homepageState.analytics.hourly_distribution} />
85- </div>
86- </div>
87- {/if}
88- {:else}
89- <div class="empty-state">
90- <span class="empty-icon"></span>
91- <span class="empty-text">NO ANALYTICS DATA</span>
92- <span class="empty-hint">Browse some sites to generate analytics</span>
93- </div>
94- {/if}
95-</div>
96-
97-<style>
98- .analytics-panel {
99- display: flex;
100- flex-direction: column;
101- gap: 1rem;
102- padding: 0.5rem 1rem;
103- }
104-
105- .loading-state,
106- .empty-state {
107- flex: 1;
108- display: flex;
109- flex-direction: column;
110- align-items: center;
111- justify-content: center;
112- gap: 0.5rem;
113- padding: 2rem;
114- color: var(--muted-foreground);
115- text-align: center;
116- }
117-
118- .loading-spinner {
119- width: 24px;
120- height: 24px;
121- border-width: 2px;
122- border-style: solid;
123- border-color: var(--border);
124- border-top-color: var(--primary, #4ade80);
125- border-radius: 50%;
126- animation: spin 0.8s linear infinite;
127- }
128-
129- @keyframes spin {
130- to { transform: rotate(360deg); }
131- }
132-
133- .loading-text,
134- .empty-text {
135- font-size: 0.68rem;
136- font-weight: 600;
137- letter-spacing: 0.12em;
138- }
139-
140- .empty-icon {
141- font-size: 1.5rem;
142- opacity: 0.5;
143- }
144-
145- .empty-hint {
146- font-size: 0.65rem;
147- letter-spacing: 0.06em;
148- opacity: 0.7;
149- }
150-
151- .stats-grid {
152- display: grid;
153- grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
154- gap: 0.5rem;
155- }
156-
157- .stat-card {
158- display: flex;
159- flex-direction: column;
160- gap: 0.3rem;
161- padding: 0.85rem;
162- border-width: 1px;
163- border-style: solid;
164- border-color: var(--border);
165- background: color-mix(in srgb, var(--background) 92%, var(--card) 8%);
166- }
167-
168- .stat-value {
169- font-size: 1.5rem;
170- font-weight: 700;
171- color: var(--foreground);
172- letter-spacing: -0.02em;
173- }
174-
175- .stat-label {
176- font-size: 0.6rem;
177- font-weight: 600;
178- color: var(--muted-foreground);
179- letter-spacing: 0.12em;
180- }
181-
182- .section {
183- border-width: 1px;
184- border-style: solid;
185- border-color: var(--border);
186- background: color-mix(in srgb, var(--background) 92%, var(--card) 8%);
187- }
188-
189- .section-header {
190- padding: 0.5rem 0.75rem;
191- border-bottom: 1px solid var(--border);
192- background: color-mix(in srgb, var(--background) 80%, var(--card) 20%);
193- }
194-
195- .section-title {
196- font-size: 0.6rem;
197- font-weight: 600;
198- color: var(--muted-foreground);
199- letter-spacing: 0.12em;
200- }
201-
202- .domain-list {
203- display: flex;
204- flex-direction: column;
205- }
206-
207- .domain-item {
208- display: grid;
209- grid-template-columns: auto 1fr auto;
210- align-items: center;
211- gap: 0.75rem;
212- padding: 0.5rem 0.75rem;
213- border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
214- }
215-
216- .domain-item:last-child {
217- border-bottom: none;
218- }
219-
220- .domain-rank {
221- width: 1.5rem;
222- font-size: 0.65rem;
223- font-weight: 600;
224- color: var(--muted-foreground);
225- text-align: center;
226- }
227-
228- .domain-name {
229- font-size: 0.75rem;
230- color: var(--foreground);
231- white-space: nowrap;
232- overflow: hidden;
233- text-overflow: ellipsis;
234- }
235-
236- .domain-stats {
237- display: flex;
238- gap: 0.75rem;
239- font-size: 0.6rem;
240- color: var(--muted-foreground);
241- }
242-
243- .chart-container {
244- padding: 0.5rem;
245- }
246-</style>
D webui/src/pages/homepage/panels/FavoritesPanel.svelte +0 -287
@@ -1,287 +0,0 @@
1-<script lang="ts">
2- import { homepageState } from '../state.svelte';
3- import {
4- navigateTo,
5- setFavoriteShortcut,
6- setFavoriteFolder,
7- assignTag,
8- removeTag,
9- createFolder,
10- createTag
11- } from '../messaging';
12- import type { Favorite } from '../types';
13-
14- import { FolderList, TagCloud, FavoriteGrid, FavoriteEditor } from '../favorites';
15-
16- let editorOpen = $state(false);
17- let editingFavorite = $state<Favorite | null>(null);
18-
19- // Get filtered favorites based on folder and tag selection
20- const displayedFavorites = $derived(() => {
21- let result = homepageState.favorites;
22-
23- // Filter by folder
24- const folderId = homepageState.selectedFolderId;
25- if (folderId !== null) {
26- if (folderId === -1) {
27- // Show unfiled
28- result = result.filter(f => f.folder_id === null);
29- } else {
30- result = result.filter(f => f.folder_id === folderId);
31- }
32- }
33-
34- // Filter by tags
35- const tagIds = homepageState.selectedTagIds;
36- if (tagIds.length > 0) {
37- result = result.filter(f =>
38- f.tags?.some(t => tagIds.includes(t.id))
39- );
40- }
41-
42- return result;
43- });
44-
45- const handleSelectFavorite = (fav: Favorite) => {
46- navigateTo(fav.url);
47- };
48-
49- const handleEditFavorite = (fav: Favorite) => {
50- editingFavorite = fav;
51- editorOpen = true;
52- };
53-
54- const handleSaveFavorite = async (updates: Partial<Favorite>) => {
55- if (!updates.id) return;
56-
57- // Update shortcut key
58- if (updates.shortcut_key !== undefined) {
59- await setFavoriteShortcut(updates.id, updates.shortcut_key);
60- }
61-
62- // Update folder
63- if (updates.folder_id !== undefined) {
64- await setFavoriteFolder(updates.id, updates.folder_id);
65- }
66-
67- // Title update would need a separate handler - for now just update local state
68- if (updates.title !== undefined && editingFavorite) {
69- homepageState.updateFavorite({
70- ...editingFavorite,
71- ...updates
72- });
73- }
74- };
75-
76- const handleDeleteFavorite = (id: number) => {
77- // This would call a delete API - for now just update local state
78- homepageState.deleteFavorite(id);
79- };
80-
81- const handleAssignTag = async (favoriteId: number, tagId: number) => {
82- await assignTag(favoriteId, tagId);
83- };
84-
85- const handleRemoveTag = async (favoriteId: number, tagId: number) => {
86- await removeTag(favoriteId, tagId);
87- };
88-
89- const handleSelectFolder = (id: number | null) => {
90- homepageState.selectFolder(id);
91- };
92-
93- const handleToggleTag = (id: number) => {
94- homepageState.toggleTag(id);
95- };
96-
97- const handleClearTagSelection = () => {
98- homepageState.clearTagSelection();
99- };
100-
101- const handleCreateFolder = async (name: string) => {
102- await createFolder({ name });
103- };
104-
105- const handleCreateTag = async (name: string, color: string) => {
106- await createTag({ name, color });
107- };
108-
109- const closeEditor = () => {
110- editorOpen = false;
111- editingFavorite = null;
112- };
113-</script>
114-
115-<div class="favorites-panel">
116- <div class="panel-sidebar">
117- <FolderList
118- folders={homepageState.folders}
119- favorites={homepageState.favorites}
120- selectedFolderId={homepageState.selectedFolderId}
121- onSelectFolder={handleSelectFolder}
122- onCreateFolder={handleCreateFolder}
123- />
124-
125- <TagCloud
126- tags={homepageState.tags}
127- selectedTagIds={homepageState.selectedTagIds}
128- onToggleTag={handleToggleTag}
129- onClearSelection={handleClearTagSelection}
130- onCreateTag={handleCreateTag}
131- />
132- </div>
133-
134- <div class="panel-main">
135- <div class="panel-header">
136- <span class="header-title">
137- {#if homepageState.selectedFolderId === null}
138- ALL FAVORITES
139- {:else if homepageState.selectedFolderId === -1}
140- UNFILED
141- {:else}
142- {homepageState.folders.find(f => f.id === homepageState.selectedFolderId)?.name ?? 'FOLDER'}
143- {/if}
144- </span>
145- <span class="header-count">{displayedFavorites().length}</span>
146- </div>
147-
148- <div class="panel-content">
149- {#if homepageState.favoritesLoading}
150- <div class="loading-state">
151- <span class="loading-spinner"></span>
152- <span class="loading-text">LOADING FAVORITES...</span>
153- </div>
154- {:else}
155- <FavoriteGrid
156- favorites={displayedFavorites()}
157- onSelectFavorite={handleSelectFavorite}
158- onEditFavorite={handleEditFavorite}
159- />
160- {/if}
161- </div>
162- </div>
163-
164- {#if editorOpen && editingFavorite}
165- <FavoriteEditor
166- favorite={editingFavorite}
167- folders={homepageState.folders}
168- tags={homepageState.tags}
169- onSave={handleSaveFavorite}
170- onDelete={handleDeleteFavorite}
171- onClose={closeEditor}
172- onAssignTag={handleAssignTag}
173- onRemoveTag={handleRemoveTag}
174- />
175- {/if}
176-</div>
177-
178-<style>
179- .favorites-panel {
180- display: grid;
181- grid-template-columns: 200px 1fr;
182- gap: 0.75rem;
183- padding: 0.5rem 1rem;
184- }
185-
186- .panel-sidebar {
187- display: flex;
188- flex-direction: column;
189- gap: 0.5rem;
190- }
191-
192- .panel-main {
193- display: flex;
194- flex-direction: column;
195- min-height: 0;
196- border-width: 1px;
197- border-style: solid;
198- border-color: var(--border);
199- background: color-mix(in srgb, var(--background) 95%, var(--card) 5%);
200- }
201-
202- .panel-header {
203- display: flex;
204- align-items: center;
205- justify-content: space-between;
206- padding: 0.6rem 0.85rem;
207- border-bottom: 1px solid var(--border);
208- background: color-mix(in srgb, var(--background) 80%, var(--card) 20%);
209- }
210-
211- .header-title {
212- font-size: 0.68rem;
213- font-weight: 600;
214- letter-spacing: 0.12em;
215- color: var(--foreground);
216- }
217-
218- .header-count {
219- font-size: 0.6rem;
220- padding: 0.15rem 0.4rem;
221- border-width: 1px;
222- border-style: solid;
223- border-color: var(--border);
224- background: var(--background);
225- color: var(--muted-foreground);
226- }
227-
228- .panel-content {
229- padding: 0.75rem;
230- }
231-
232- .loading-state {
233- flex: 1;
234- display: flex;
235- flex-direction: column;
236- align-items: center;
237- justify-content: center;
238- gap: 0.75rem;
239- padding: 2rem;
240- color: var(--muted-foreground);
241- }
242-
243- .loading-spinner {
244- width: 24px;
245- height: 24px;
246- border-width: 2px;
247- border-style: solid;
248- border-color: var(--border);
249- border-top-color: var(--primary, #4ade80);
250- border-radius: 50%;
251- animation: spin 0.8s linear infinite;
252- }
253-
254- @keyframes spin {
255- to { transform: rotate(360deg); }
256- }
257-
258- .loading-text {
259- font-size: 0.68rem;
260- letter-spacing: 0.12em;
261- animation: pulse 1.5s ease-in-out infinite;
262- }
263-
264- @keyframes pulse {
265- 0%, 100% { opacity: 0.5; }
266- 50% { opacity: 1; }
267- }
268-
269- /* Responsive: stack on smaller screens */
270- @media (max-width: 768px) {
271- .favorites-panel {
272- grid-template-columns: 1fr;
273- grid-template-rows: auto 1fr;
274- }
275-
276- .panel-sidebar {
277- flex-direction: row;
278- gap: 0.5rem;
279- overflow-x: auto;
280- }
281-
282- .panel-sidebar > :global(*) {
283- flex-shrink: 0;
284- min-width: 180px;
285- }
286- }
287-</style>
D webui/src/pages/homepage/panels/HistoryPanel.svelte +0 -210
@@ -1,210 +0,0 @@
1-<script lang="ts">
2- import { homepageState } from '../state.svelte';
3- import {
4- fetchHistoryTimeline,
5- deleteHistoryEntry,
6- deleteHistoryByRange,
7- clearAllHistory,
8- fetchDomainStats,
9- navigateTo
10- } from '../messaging';
11- import type { HistoryEntry, HistoryCleanupRange } from '../types';
12-
13- import { HistoryTimeline, HistorySearch, HistoryFilters, HistoryCleanup } from '../history';
14-
15- let cleanupOpen = $state(false);
16- let selectedDomain = $state<string | null>(null);
17-
18- // Filter entries by domain if selected
19- const displayedEntries = $derived(() => {
20- if (!selectedDomain) {
21- return homepageState.historySearchQuery
22- ? homepageState.historySearchResults
23- : homepageState.history;
24- }
25-
26- const entries = homepageState.historySearchQuery
27- ? homepageState.historySearchResults
28- : homepageState.history;
29-
30- return entries.filter(entry => {
31- try {
32- return new URL(entry.url).hostname === selectedDomain;
33- } catch {
34- return false;
35- }
36- });
37- });
38-
39- // Recompute timeline from filtered entries
40- /* eslint-disable svelte/prefer-svelte-reactivity -- temporary Map/Date for data grouping, not reactive state */
41- const displayedTimeline = $derived(() => {
42- const entries = displayedEntries();
43- if (entries.length === 0) return [];
44-
45- const groups = new Map<string, HistoryEntry[]>();
46- const today = new Date();
47- today.setHours(0, 0, 0, 0);
48- const yesterday = new Date(today);
49- yesterday.setDate(yesterday.getDate() - 1);
50-
51- for (const entry of entries) {
52- const date = new Date(entry.last_visited);
53- const dateKey = date.toISOString().split('T')[0] ?? '';
54- if (!groups.has(dateKey)) {
55- groups.set(dateKey, []);
56- }
57- groups.get(dateKey)!.push(entry);
58- }
59-
60- const result: { date: string; label: string; entries: HistoryEntry[] }[] = [];
61- for (const [dateKey, items] of groups) {
62- const date = new Date(dateKey);
63- let label: string;
64-
65- if (date >= today) {
66- label = 'Today';
67- } else if (date >= yesterday) {
68- label = 'Yesterday';
69- } else {
70- label = date.toLocaleDateString(undefined, {
71- weekday: 'short',
72- month: 'short',
73- day: 'numeric',
74- });
75- }
76-
77- result.push({ date: dateKey, label, entries: items });
78- }
79-
80- return result.sort((a, b) => b.date.localeCompare(a.date));
81- });
82- /* eslint-enable svelte/prefer-svelte-reactivity */
83-
84- const handleSelectEntry = (entry: HistoryEntry) => {
85- navigateTo(entry.url);
86- };
87-
88- const handleDeleteEntry = async (entry: HistoryEntry) => {
89- await deleteHistoryEntry(entry.id);
90- };
91-
92- const handleLoadMore = async () => {
93- if (homepageState.hasMoreHistory && !homepageState.historyLoading) {
94- await fetchHistoryTimeline(50, homepageState.historyOffset);
95- }
96- };
97-
98- const handleCleanup = async (range: HistoryCleanupRange) => {
99- if (range === 'all') {
100- await clearAllHistory();
101- } else {
102- await deleteHistoryByRange(range);
103- }
104- cleanupOpen = false;
105- };
106-
107- const handleSelectDomain = (domain: string | null) => {
108- selectedDomain = domain;
109- homepageState.setFocusedIndex(0);
110- };
111-
112- // Fetch domain stats on mount
113- $effect(() => {
114- fetchDomainStats(10);
115- });
116-</script>
117-
118-<div class="history-panel">
119- <div class="panel-toolbar">
120- <HistorySearch placeholder="Search history (FTS)..." autofocus={true} />
121- </div>
122-
123- <HistoryFilters
124- domains={homepageState.domainStats}
125- selectedDomain={selectedDomain}
126- onSelectDomain={handleSelectDomain}
127- onOpenCleanup={() => cleanupOpen = true}
128- />
129-
130- <div class="panel-content">
131- {#if homepageState.historyLoading && homepageState.history.length === 0}
132- <div class="loading-state">
133- <span class="loading-spinner"></span>
134- <span class="loading-text">LOADING HISTORY...</span>
135- </div>
136- {:else}
137- <HistoryTimeline
138- groups={displayedTimeline()}
139- onSelectEntry={handleSelectEntry}
140- onDeleteEntry={handleDeleteEntry}
141- onLoadMore={handleLoadMore}
142- />
143- {/if}
144- </div>
145-
146- {#if cleanupOpen}
147- <HistoryCleanup
148- onCleanup={handleCleanup}
149- onClose={() => cleanupOpen = false}
150- />
151- {/if}
152-</div>
153-
154-<style>
155- .history-panel {
156- display: flex;
157- flex-direction: column;
158- gap: 0.5rem;
159- padding: 0.5rem 1rem;
160- }
161-
162- .panel-toolbar {
163- flex-shrink: 0;
164- }
165-
166- .panel-content {
167- display: flex;
168- flex-direction: column;
169- border-width: 1px;
170- border-style: solid;
171- border-color: var(--border);
172- background: color-mix(in srgb, var(--background) 95%, var(--card) 5%);
173- }
174-
175- .loading-state {
176- flex: 1;
177- display: flex;
178- flex-direction: column;
179- align-items: center;
180- justify-content: center;
181- gap: 0.75rem;
182- color: var(--muted-foreground);
183- }
184-
185- .loading-spinner {
186- width: 24px;
187- height: 24px;
188- border-width: 2px;
189- border-style: solid;
190- border-color: var(--border);
191- border-top-color: var(--primary, #4ade80);
192- border-radius: 50%;
193- animation: spin 0.8s linear infinite;
194- }
195-
196- @keyframes spin {
197- to { transform: rotate(360deg); }
198- }
199-
200- .loading-text {
201- font-size: 0.68rem;
202- letter-spacing: 0.12em;
203- animation: pulse 1.5s ease-in-out infinite;
204- }
205-
206- @keyframes pulse {
207- 0%, 100% { opacity: 0.5; }
208- 50% { opacity: 1; }
209- }
210-</style>
D webui/src/pages/homepage/panels/index.ts +0 -4
@@ -1,4 +0,0 @@
1-// Panel Components - Main content panels for homepage
2-export { default as HistoryPanel } from './HistoryPanel.svelte';
3-export { default as FavoritesPanel } from './FavoritesPanel.svelte';
4-export { default as AnalyticsPanel } from './AnalyticsPanel.svelte';
D webui/src/pages/homepage/shell/CommandPalette.svelte +0 -613
@@ -1,613 +0,0 @@
1-<script lang="ts">
2- import { onMount, tick } from 'svelte';
3- import { homepageState } from '../state.svelte';
4- import { navigateTo } from '../messaging';
5- import type { CommandPaletteItem } from '../types';
6- import { Search, SearchX, History, Star, BarChart3, Trash2, Globe } from '@lucide/svelte';
7-
8- // Input element reference
9- let inputRef: HTMLInputElement | null = $state(null);
10- let listRef: HTMLDivElement | null = $state(null);
11-
12- // Local state for command palette
13- let query = $state('');
14- let selectedIndex = $state(0);
15-
16- // Build command items from available actions
17- const staticCommands: CommandPaletteItem[] = [
18- {
19- id: 'nav:history',
20- label: 'Go to History',
21- description: 'View browsing history timeline',
22- icon: History,
23- shortcut: 'g h',
24- action: () => {
25- homepageState.setActivePanel('history');
26- homepageState.closeCommandPalette();
27- },
28- },
29- {
30- id: 'nav:favorites',
31- label: 'Go to Favorites',
32- description: 'View bookmarks and folders',
33- icon: Star,
34- shortcut: 'g f',
35- action: () => {
36- homepageState.setActivePanel('favorites');
37- homepageState.closeCommandPalette();
38- },
39- },
40- {
41- id: 'nav:analytics',
42- label: 'Go to Analytics',
43- description: 'View browsing statistics',
44- icon: BarChart3,
45- shortcut: 'g a',
46- action: () => {
47- homepageState.setActivePanel('analytics');
48- homepageState.closeCommandPalette();
49- },
50- },
51- {
52- id: 'action:cleanup',
53- label: 'Clean History',
54- description: 'Remove history entries by time range',
55- icon: Trash2,
56- action: () => {
57- homepageState.closeCommandPalette();
58- homepageState.openCleanupModal();
59- },
60- },
61- ];
62-
63- // Fuzzy match function
64- function fuzzyMatch(text: string, pattern: string): { match: boolean; score: number } {
65- if (!pattern) return { match: true, score: 0 };
66-
67- const lower = text.toLowerCase();
68- const pLower = pattern.toLowerCase();
69-
70- // Exact substring match scores highest
71- if (lower.includes(pLower)) {
72- return { match: true, score: 100 - lower.indexOf(pLower) };
73- }
74-
75- // Fuzzy character matching
76- let pIdx = 0;
77- let score = 0;
78- let consecutive = 0;
79-
80- for (let i = 0; i < lower.length && pIdx < pLower.length; i++) {
81- if (lower[i] === pLower[pIdx]) {
82- pIdx++;
83- consecutive++;
84- score += consecutive * 2;
85- } else {
86- consecutive = 0;
87- }
88- }
89-
90- return { match: pIdx === pLower.length, score };
91- }
92-
93- // Build filtered items based on query
94- const filteredItems = $derived.by(() => {
95- const results: Array<{ item: CommandPaletteItem; score: number }> = [];
96-
97- // Add static commands
98- for (const cmd of staticCommands) {
99- const labelMatch = fuzzyMatch(cmd.label, query);
100- const descMatch = cmd.description ? fuzzyMatch(cmd.description, query) : { match: false, score: 0 };
101-
102- if (labelMatch.match || descMatch.match) {
103- results.push({
104- item: cmd,
105- score: Math.max(labelMatch.score, descMatch.score * 0.8),
106- });
107- }
108- }
109-
110- // Add history items as navigable commands
111- for (const entry of homepageState.history.slice(0, 50)) {
112- const titleMatch = fuzzyMatch(entry.title || '', query);
113- const urlMatch = fuzzyMatch(entry.url, query);
114-
115- if (titleMatch.match || urlMatch.match) {
116- results.push({
117- item: {
118- id: `history:${entry.id}`,
119- label: entry.title || getDomain(entry.url),
120- description: entry.url,
121- icon: Globe,
122- action: () => {
123- navigateTo(entry.url);
124- },
125- },
126- score: Math.max(titleMatch.score, urlMatch.score * 0.7),
127- });
128- }
129- }
130-
131- // Add favorites as navigable commands
132- for (const fav of homepageState.favorites) {
133- const titleMatch = fuzzyMatch(fav.title || '', query);
134- const urlMatch = fuzzyMatch(fav.url, query);
135-
136- if (titleMatch.match || urlMatch.match) {
137- results.push({
138- item: {
139- id: `fav:${fav.id}`,
140- label: fav.title || getDomain(fav.url),
141- description: fav.url,
142- icon: Star,
143- shortcut: fav.shortcut_key ? `${fav.shortcut_key}` : undefined,
144- action: () => {
145- navigateTo(fav.url);
146- },
147- },
148- score: Math.max(titleMatch.score, urlMatch.score * 0.7) + 10, // Boost favorites
149- });
150- }
151- }
152-
153- // Sort by score and return items
154- results.sort((a, b) => b.score - a.score);
155- return results.map(r => r.item).slice(0, 20);
156- });
157-
158- function getDomain(url: string): string {
159- try {
160- return new URL(url).hostname;
161- } catch {
162- return url;
163- }
164- }
165-
166- // Reset selection when query changes
167- $effect(() => {
168- void query; // Track query changes
169- selectedIndex = 0;
170- });
171-
172- // Scroll selected item into view
173- $effect(() => {
174- if (listRef) {
175- const selected = listRef.querySelector(`[data-index="${selectedIndex}"]`);
176- selected?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
177- }
178- });
179-
180- function handleKeydown(e: KeyboardEvent) {
181- switch (e.key) {
182- case 'ArrowDown':
183- case 'j':
184- if (e.key === 'j' && !e.ctrlKey) break;
185- e.preventDefault();
186- selectedIndex = Math.min(selectedIndex + 1, filteredItems.length - 1);
187- break;
188-
189- case 'ArrowUp':
190- case 'k':
191- if (e.key === 'k' && !e.ctrlKey) break;
192- e.preventDefault();
193- selectedIndex = Math.max(selectedIndex - 1, 0);
194- break;
195-
196- case 'n':
197- if (e.ctrlKey) {
198- e.preventDefault();
199- selectedIndex = Math.min(selectedIndex + 1, filteredItems.length - 1);
200- }
201- break;
202-
203- case 'p':
204- if (e.ctrlKey) {
205- e.preventDefault();
206- selectedIndex = Math.max(selectedIndex - 1, 0);
207- }
208- break;
209-
210- case 'Enter':
211- e.preventDefault();
212- executeSelected();
213- break;
214-
215- case 'Escape':
216- e.preventDefault();
217- homepageState.closeCommandPalette();
218- break;
219- }
220- }
221-
222- function executeSelected() {
223- const item = filteredItems[selectedIndex];
224- if (item) {
225- item.action();
226- }
227- }
228-
229- function handleItemClick(index: number) {
230- selectedIndex = index;
231- executeSelected();
232- }
233-
234- function handleOverlayInteraction(event: MouseEvent | KeyboardEvent) {
235- if (event instanceof KeyboardEvent) {
236- if (event.key === 'Escape' || event.key === 'Enter' || event.key === ' ') {
237- event.preventDefault();
238- homepageState.closeCommandPalette();
239- }
240- return;
241- }
242-
243- if (event.currentTarget === event.target) {
244- homepageState.closeCommandPalette();
245- }
246- }
247-
248- onMount(async () => {
249- await tick();
250- inputRef?.focus();
251- });
252-</script>
253-
254-<div
255- class="palette-overlay"
256- role="button"
257- tabindex="0"
258- aria-label="Close command palette"
259- onclick={handleOverlayInteraction}
260- onkeydown={handleOverlayInteraction}
261->
262- <div class="palette-container" onclick={(e) => e.stopPropagation()} role="presentation">
263- <!-- Search Header -->
264- <div class="palette-header">
265- <div class="search-row">
266- <span class="search-icon">
267- <Search size={16} strokeWidth={2} />
268- </span>
269- <input
270- bind:this={inputRef}
271- bind:value={query}
272- type="text"
273- class="search-input"
274- placeholder="Search commands, history, favorites..."
275- spellcheck="false"
276- autocomplete="off"
277- onkeydown={handleKeydown}
278- />
279- <div class="search-meta">
280- <span class="result-count">{filteredItems.length}</span>
281- <kbd class="esc-hint">esc</kbd>
282- </div>
283- </div>
284- </div>
285-
286- <!-- Results List -->
287- <div class="palette-results" bind:this={listRef}>
288- {#if filteredItems.length === 0}
289- <div class="empty-results">
290- <span class="empty-icon">
291- <SearchX size={24} strokeWidth={1.5} />
292- </span>
293- <span class="empty-text">No matches found</span>
294- </div>
295- {:else}
296- {#each filteredItems as item, index (item.id)}
297- <button
298- class="result-item"
299- class:selected={index === selectedIndex}
300- data-index={index}
301- type="button"
302- onclick={() => handleItemClick(index)}
303- onmouseenter={() => selectedIndex = index}
304- >
305- <span class="item-icon">
306- {#if typeof item.icon === 'function'}
307- <item.icon size={16} strokeWidth={2} />
308- {:else if item.icon}
309- {item.icon}
310- {/if}
311- </span>
312- <div class="item-content">
313- <span class="item-label">{item.label}</span>
314- {#if item.description}
315- <span class="item-description">{item.description}</span>
316- {/if}
317- </div>
318- {#if item.shortcut}
319- <kbd class="item-shortcut">{item.shortcut}</kbd>
320- {/if}
321- </button>
322- {/each}
323- {/if}
324- </div>
325-
326- <!-- Footer Hints -->
327- <div class="palette-footer">
328- <div class="hint-group">
329- <kbd></kbd><kbd></kbd>
330- <span>navigate</span>
331- </div>
332- <div class="hint-group">
333- <kbd></kbd>
334- <span>select</span>
335- </div>
336- <div class="hint-group">
337- <kbd>esc</kbd>
338- <span>close</span>
339- </div>
340- </div>
341- </div>
342-</div>
343-
344-<style>
345- .palette-overlay {
346- position: fixed;
347- inset: 0;
348- z-index: 200;
349- display: flex;
350- align-items: flex-start;
351- justify-content: center;
352- padding-top: 12vh;
353- background: rgb(0 0 0 / 0.65);
354- backdrop-filter: blur(8px);
355- animation: overlay-in 150ms ease;
356- }
357-
358- @keyframes overlay-in {
359- from {
360- opacity: 0;
361- backdrop-filter: blur(0);
362- }
363- }
364-
365- .palette-container {
366- width: 100%;
367- max-width: 580px;
368- margin: 0 1rem;
369- display: flex;
370- flex-direction: column;
371- background: var(--card);
372- border-width: 1px;
373- border-style: solid;
374- border-color: var(--border);
375- box-shadow:
376- 0 0 0 1px color-mix(in srgb, var(--border) 30%, transparent),
377- 0 32px 64px -16px rgb(0 0 0 / 0.7);
378- animation: palette-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
379- }
380-
381- @keyframes palette-in {
382- from {
383- opacity: 0;
384- transform: scale(0.96) translateY(-12px);
385- }
386- }
387-
388- /* Header */
389- .palette-header {
390- padding: 0;
391- border-bottom: 1px solid var(--border);
392- background: color-mix(in srgb, var(--background) 70%, transparent);
393- }
394-
395- .search-row {
396- display: flex;
397- align-items: center;
398- gap: 0.75rem;
399- padding: 0.85rem 1rem;
400- }
401-
402- .search-icon {
403- font-size: 1rem;
404- color: var(--primary, #4ade80);
405- flex-shrink: 0;
406- }
407-
408- .search-input {
409- flex: 1;
410- background: transparent;
411- border: none;
412- outline: none;
413- font-size: 0.95rem;
414- font-family: inherit;
415- color: var(--foreground);
416- caret-color: var(--primary, #4ade80);
417- }
418-
419- .search-input::placeholder {
420- color: var(--muted-foreground);
421- opacity: 0.7;
422- }
423-
424- .search-meta {
425- display: flex;
426- align-items: center;
427- gap: 0.5rem;
428- flex-shrink: 0;
429- }
430-
431- .result-count {
432- font-size: 0.7rem;
433- color: var(--muted-foreground);
434- letter-spacing: 0.05em;
435- }
436-
437- .esc-hint {
438- padding: 0.2rem 0.4rem;
439- font-size: 0.65rem;
440- color: var(--muted-foreground);
441- background: var(--background);
442- border-width: 1px;
443- border-style: solid;
444- border-color: var(--border);
445- }
446-
447- /* Results */
448- .palette-results {
449- max-height: 360px;
450- overflow-y: auto;
451- overscroll-behavior: contain;
452- }
453-
454- .empty-results {
455- display: flex;
456- flex-direction: column;
457- align-items: center;
458- justify-content: center;
459- gap: 0.5rem;
460- padding: 2.5rem 1rem;
461- color: var(--muted-foreground);
462- }
463-
464- .empty-icon {
465- font-size: 1.5rem;
466- opacity: 0.5;
467- }
468-
469- .empty-text {
470- font-size: 0.8rem;
471- letter-spacing: 0.05em;
472- }
473-
474- .result-item {
475- width: 100%;
476- display: flex;
477- align-items: center;
478- gap: 0.75rem;
479- padding: 0.7rem 1rem;
480- background: transparent;
481- border: none;
482- border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
483- cursor: pointer;
484- text-align: left;
485- transition: background 80ms ease;
486- }
487-
488- .result-item:last-child {
489- border-bottom: none;
490- }
491-
492- .result-item:hover {
493- background: color-mix(in srgb, var(--card) 80%, var(--background) 20%);
494- }
495-
496- .result-item.selected {
497- background: color-mix(in srgb, var(--primary, #4ade80) 12%, transparent);
498- box-shadow: inset 2px 0 0 var(--primary, #4ade80);
499- }
500-
501- .item-icon {
502- font-size: 1rem;
503- color: var(--muted-foreground);
504- flex-shrink: 0;
505- width: 1.5rem;
506- text-align: center;
507- }
508-
509- .result-item.selected .item-icon {
510- color: var(--primary, #4ade80);
511- }
512-
513- .item-content {
514- flex: 1;
515- min-width: 0;
516- display: flex;
517- flex-direction: column;
518- gap: 0.15rem;
519- }
520-
521- .item-label {
522- font-size: 0.85rem;
523- color: var(--foreground);
524- white-space: nowrap;
525- overflow: hidden;
526- text-overflow: ellipsis;
527- }
528-
529- .item-description {
530- font-size: 0.72rem;
531- color: var(--muted-foreground);
532- white-space: nowrap;
533- overflow: hidden;
534- text-overflow: ellipsis;
535- }
536-
537- .item-shortcut {
538- padding: 0.2rem 0.45rem;
539- font-size: 0.65rem;
540- color: var(--muted-foreground);
541- background: var(--background);
542- border-width: 1px;
543- border-style: solid;
544- border-color: var(--border);
545- flex-shrink: 0;
546- }
547-
548- .result-item.selected .item-shortcut {
549- border-color: color-mix(in srgb, var(--primary, #4ade80) 40%, var(--border) 60%);
550- color: var(--foreground);
551- }
552-
553- /* Footer */
554- .palette-footer {
555- display: flex;
556- align-items: center;
557- justify-content: center;
558- gap: 1.5rem;
559- padding: 0.65rem 1rem;
560- border-top: 1px solid var(--border);
561- background: color-mix(in srgb, var(--background) 70%, transparent);
562- }
563-
564- .hint-group {
565- display: flex;
566- align-items: center;
567- gap: 0.35rem;
568- font-size: 0.68rem;
569- color: var(--muted-foreground);
570- }
571-
572- .hint-group kbd {
573- padding: 0.15rem 0.35rem;
574- font-size: 0.62rem;
575- background: var(--background);
576- border-width: 1px;
577- border-style: solid;
578- border-color: var(--border);
579- }
580-
581- /* Scrollbar */
582- .palette-results::-webkit-scrollbar {
583- width: 6px;
584- }
585-
586- .palette-results::-webkit-scrollbar-track {
587- background: transparent;
588- }
589-
590- .palette-results::-webkit-scrollbar-thumb {
591- background: var(--border);
592- }
593-
594- .palette-results::-webkit-scrollbar-thumb:hover {
595- background: var(--muted-foreground);
596- }
597-
598- @media (max-width: 480px) {
599- .palette-overlay {
600- padding-top: 2rem;
601- align-items: flex-start;
602- }
603-
604- .palette-container {
605- max-height: calc(100vh - 4rem);
606- }
607-
608- .palette-footer {
609- flex-wrap: wrap;
610- gap: 0.75rem;
611- }
612- }
613-</style>
D webui/src/pages/homepage/shell/HomepageShell.svelte +0 -634
@@ -1,634 +0,0 @@
1-<script lang="ts">
2- import { onMount, onDestroy } from 'svelte';
3- import { homepageState } from '../state.svelte';
4- import { initializeHomepage } from '../messaging';
5- import { initializeKeyboard } from '../keyboard';
6- import CommandPalette from './CommandPalette.svelte';
7- import StatusBar from './StatusBar.svelte';
8- import KeyboardHints from './KeyboardHints.svelte';
9- import { History, Star, BarChart3, Search, Moon, Sun, Settings } from '@lucide/svelte';
10-
11- // Props for slot content
12- interface Props {
13- children?: import('svelte').Snippet;
14- }
15- let { children }: Props = $props();
16-
17- // Theme state
18- type ThemeMode = 'light' | 'dark';
19- let themeMode = $state<ThemeMode>('dark');
20- let themeObserver: MutationObserver | null = null;
21-
22- // Keyboard cleanup
23- let cleanupKeyboard: (() => void) | null = null;
24-
25- // Panel tabs config
26- const panelTabs = [
27- { id: 'history' as const, label: 'HIST', icon: History },
28- { id: 'favorites' as const, label: 'FAV', icon: Star },
29- { id: 'analytics' as const, label: 'STAT', icon: BarChart3 },
30- ];
31-
32- // Theme management
33- const syncThemeState = () => {
34- themeMode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
35- };
36-
37- function navigateWithViewTransition(url: string) {
38- if (typeof document.startViewTransition !== 'function') {
39- window.location.href = url;
40- return;
41- }
42-
43- const transition = document.startViewTransition(() => {
44- document.documentElement.dataset.vt = 'leaving';
45- });
46-
47- transition.finished.finally(() => {
48- window.location.href = url;
49- });
50- }
51-
52- const toggleTheme = () => {
53- const nextMode = themeMode === 'dark' ? 'light' : 'dark';
54- const manager = window.__dumber_color_scheme_manager;
55-
56- if (manager?.setUserPreference) {
57- manager.setUserPreference(nextMode);
58- return;
59- }
60-
61- if (window.__dumber_setTheme) {
62- window.__dumber_setTheme(nextMode);
63- localStorage.setItem('dumber.theme', nextMode);
64- return;
65- }
66-
67- switch (nextMode) {
68- case 'light':
69- document.documentElement.classList.add('light');
70- document.documentElement.classList.remove('dark');
71- break;
72- case 'dark':
73- document.documentElement.classList.remove('light');
74- document.documentElement.classList.add('dark');
75- break;
76- }
77- localStorage.setItem('dumber.theme', nextMode);
78- themeMode = nextMode;
79- };
80-
81- onMount(async () => {
82- // Initialize keyboard navigation
83- cleanupKeyboard = initializeKeyboard();
84-
85- // Initialize data fetching
86- await initializeHomepage();
87-
88- // Theme synchronization
89- syncThemeState();
90- themeObserver = new MutationObserver(syncThemeState);
91- themeObserver.observe(document.documentElement, {
92- attributes: true,
93- attributeFilter: ['class'],
94- });
95- });
96-
97- onDestroy(() => {
98- cleanupKeyboard?.();
99- themeObserver?.disconnect();
100- themeObserver = null;
101- homepageState.reset();
102- });
103-</script>
104-
105-<svelte:head>
106- <title>dumb://home</title>
107- <meta name="description" content="Dumber Browser - Homepage" />
108- <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static styles for shell initialization -->
109- {@html `<style>
110- html, body { margin: 0; padding: 0; }
111- html { background: var(--background, #0a0a0a); }
112- body { background: var(--background, #0a0a0a); color: var(--foreground, #e5e5e5); }
113- /* Disable GTK/WebKit default focus rings - we handle focus styling ourselves */
114- *:focus { outline: none !important; }
115- *:focus-visible { outline: none !important; }
116- button:focus, input:focus, a:focus { outline: none !important; box-shadow: none !important; }
117- /* Enable touch scrolling for WebKit */
118- * { -webkit-overflow-scrolling: touch; }
119- </style>`}
120-</svelte:head>
121-
122-<div class="homepage-shell">
123- <!-- Terminal Frame -->
124- <div class="terminal-frame">
125- <!-- Header -->
126- <header class="terminal-header">
127- <div class="header-left">
128- <span class="terminal-path">dumb://home</span>
129- <div class="header-tabs" role="tablist" aria-label="Homepage panels">
130- {#each panelTabs as tab, index (tab.id)}
131- <button
132- class="tab-button"
133- class:active={homepageState.activePanel === tab.id}
134- onclick={() => homepageState.setActivePanel(tab.id)}
135- onkeydown={(e) => {
136- switch (e.key) {
137- case 'Enter':
138- case ' ':
139- e.preventDefault();
140- homepageState.setActivePanel(tab.id);
141- break;
142- case 'ArrowLeft':
143- case 'ArrowUp':
144- e.preventDefault();
145- {
146- const prevIndex = index === 0 ? panelTabs.length - 1 : index - 1;
147- const prevTab = panelTabs[prevIndex];
148- if (prevTab) {
149- homepageState.setActivePanel(prevTab.id);
150- (e.currentTarget.parentElement?.children[prevIndex] as HTMLElement)?.focus();
151- }
152- }
153- break;
154- case 'ArrowRight':
155- case 'ArrowDown':
156- e.preventDefault();
157- {
158- const nextIndex = index === panelTabs.length - 1 ? 0 : index + 1;
159- const nextTab = panelTabs[nextIndex];
160- if (nextTab) {
161- homepageState.setActivePanel(nextTab.id);
162- (e.currentTarget.parentElement?.children[nextIndex] as HTMLElement)?.focus();
163- }
164- }
165- break;
166- }
167- }}
168- type="button"
169- role="tab"
170- aria-selected={homepageState.activePanel === tab.id}
171- tabindex={homepageState.activePanel === tab.id ? 0 : -1}
172- >
173- <tab.icon size={14} strokeWidth={2} class="tab-icon" />
174- <span class="tab-label">{tab.label}</span>
175- </button>
176- {/each}
177- </div>
178- </div>
179- <div class="header-right">
180- <button
181- class="action-button"
182- type="button"
183- onclick={() => homepageState.openCommandPalette()}
184- title="Command Palette (Ctrl+P or /)"
185- >
186- <Search size={14} strokeWidth={2} />
187- <span class="button-text">CMD</span>
188- <kbd class="kbd-hint">/</kbd>
189- </button>
190- <button
191- class="action-button"
192- type="button"
193- onclick={() => navigateWithViewTransition('dumb://config')}
194- title="Settings"
195- >
196- <Settings size={14} strokeWidth={2} />
197- <span class="button-text">CFG</span>
198- </button>
199- <button
200- class="theme-toggle"
201- type="button"
202- onclick={toggleTheme}
203- title={themeMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
204- aria-pressed={themeMode === 'dark'}
205- >
206- {#if themeMode === 'dark'}
207- <Moon size={18} strokeWidth={2} />
208- {:else}
209- <Sun size={18} strokeWidth={2} />
210- {/if}
211- </button>
212- </div>
213- </header>
214-
215- <!-- Main Content Area -->
216- <main class="terminal-body scrollable">
217- {#if children}
218- {@render children()}
219- {:else}
220- <div class="empty-shell">
221- <span class="shell-prompt">$</span>
222- <span class="shell-cursor">_</span>
223- </div>
224- {/if}
225- </main>
226-
227- <!-- Status Bar -->
228- <StatusBar />
229- </div>
230-
231- <!-- Keyboard Hints Overlay -->
232- <KeyboardHints />
233-
234- <!-- Command Palette Modal -->
235- {#if homepageState.commandPaletteOpen}
236- <CommandPalette />
237- {/if}
238-
239- <!-- Confirmation Modal -->
240- {#if homepageState.confirmModalOpen}
241- <div
242- class="modal-overlay"
243- onclick={() => homepageState.cancelConfirm()}
244- onkeydown={(e) => { if (e.key === 'Escape') homepageState.cancelConfirm(); }}
245- role="button"
246- tabindex="0"
247- aria-label="Close confirmation modal"
248- >
249- <div class="confirm-modal" onclick={(e) => e.stopPropagation()} role="presentation">
250- <div class="modal-header">
251- <span class="modal-icon"></span>
252- <span class="modal-title">CONFIRM</span>
253- </div>
254- <p class="modal-message">{homepageState.confirmModalMessage}</p>
255- <div class="modal-actions">
256- <button
257- class="modal-btn modal-btn-cancel"
258- type="button"
259- onclick={() => homepageState.cancelConfirm()}
260- >
261- CANCEL
262- <kbd>Esc</kbd>
263- </button>
264- <button
265- class="modal-btn modal-btn-confirm"
266- type="button"
267- onclick={() => homepageState.confirmAction()}
268- >
269- CONFIRM
270- <kbd>Enter</kbd>
271- </button>
272- </div>
273- </div>
274- </div>
275- {/if}
276-</div>
277-
278-<style>
279- :global(html),
280- :global(body) {
281- height: 100%;
282- }
283-
284- :global(body) {
285- overflow: hidden;
286- overscroll-behavior: contain;
287- }
288-
289- .homepage-shell {
290- height: 100vh;
291- min-height: 100vh;
292- display: flex;
293- flex-direction: column;
294- font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
295- line-height: 1.5;
296- color: var(--foreground);
297- background: var(--background);
298- overflow: hidden;
299- }
300-
301- .terminal-frame {
302- flex: 1;
303- display: grid;
304- grid-template-rows: auto 1fr auto;
305- width: 100%;
306- height: 100%;
307- background: color-mix(in srgb, var(--card) 60%, var(--background) 40%);
308- }
309-
310- /* Dark mode: subtle shadow */
311- :global(.dark) .terminal-frame {
312- box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.5);
313- }
314-
315- /* Header */
316- .terminal-header {
317- display: flex;
318- align-items: center;
319- justify-content: space-between;
320- gap: 1rem;
321- padding: 0.75rem 1rem;
322- border-bottom: 1px solid var(--border);
323- background: color-mix(in srgb, var(--background) 90%, var(--card) 10%);
324- position: sticky;
325- top: 0;
326- z-index: 5;
327- }
328-
329- .header-left {
330- display: flex;
331- align-items: center;
332- gap: 1.5rem;
333- }
334-
335- .terminal-path {
336- font-size: 0.8rem;
337- font-weight: 600;
338- color: var(--primary, #4ade80);
339- letter-spacing: 0.05em;
340- }
341-
342- .header-tabs {
343- display: flex;
344- gap: 0.25rem;
345- }
346-
347- .tab-button {
348- display: flex;
349- align-items: center;
350- gap: 0.4rem;
351- padding: 0.4rem 0.75rem;
352- font-size: 0.7rem;
353- font-weight: 500;
354- letter-spacing: 0.1em;
355- color: var(--muted-foreground);
356- background: transparent;
357- border: 1px solid transparent;
358- cursor: pointer;
359- transition: all 120ms ease;
360- }
361-
362- .tab-button:hover {
363- color: var(--foreground);
364- background: color-mix(in srgb, var(--card) 50%, transparent);
365- }
366-
367- .tab-button:focus-visible {
368- color: var(--foreground);
369- border-color: var(--primary, #4ade80);
370- background: color-mix(in srgb, var(--primary, #4ade80) 15%, transparent);
371- }
372-
373- .tab-button.active {
374- color: var(--foreground);
375- background: var(--card);
376- border-color: var(--border);
377- }
378-
379- .header-right {
380- display: flex;
381- align-items: center;
382- gap: 0.5rem;
383- }
384-
385- .action-button {
386- display: flex;
387- align-items: center;
388- gap: 0.5rem;
389- padding: 0.4rem 0.75rem;
390- font-size: 0.7rem;
391- font-weight: 500;
392- letter-spacing: 0.08em;
393- color: var(--muted-foreground);
394- background: transparent;
395- border-width: 1px;
396- border-style: solid;
397- border-color: var(--border);
398- cursor: pointer;
399- transition: all 120ms ease;
400- }
401-
402- .action-button:hover,
403- .action-button:focus-visible {
404- color: var(--foreground);
405- border-color: color-mix(in srgb, var(--border) 50%, var(--foreground) 50%);
406- background: color-mix(in srgb, var(--card) 30%, transparent);
407- outline: none;
408- }
409-
410- .kbd-hint {
411- padding: 0.15rem 0.35rem;
412- font-size: 0.65rem;
413- color: var(--muted-foreground);
414- background: var(--background);
415- border-width: 1px;
416- border-style: solid;
417- border-color: var(--border);
418- margin-left: 0.25rem;
419- }
420-
421- .theme-toggle {
422- display: flex;
423- align-items: center;
424- justify-content: center;
425- width: 2.25rem;
426- height: 2.25rem;
427- color: var(--muted-foreground);
428- background: transparent;
429- border-width: 1px;
430- border-style: solid;
431- border-color: var(--border);
432- cursor: pointer;
433- transition: all 120ms ease;
434- }
435-
436- .theme-toggle:hover,
437- .theme-toggle:focus-visible {
438- color: var(--foreground);
439- border-color: color-mix(in srgb, var(--border) 50%, var(--foreground) 50%);
440- outline: none;
441- }
442-
443- .theme-toggle :global(svg) {
444- flex-shrink: 0;
445- }
446-
447- /* Main Content */
448- .terminal-body {
449- display: flex;
450- flex-direction: column;
451- min-height: 0;
452- overflow: hidden;
453- background: var(--background);
454- }
455-
456- .terminal-body.scrollable {
457- overflow-y: auto;
458- -webkit-overflow-scrolling: touch;
459- touch-action: pan-y;
460- }
461-
462- .empty-shell {
463- flex: 1;
464- display: flex;
465- align-items: center;
466- justify-content: center;
467- gap: 0.5rem;
468- color: var(--muted-foreground);
469- font-size: 1.25rem;
470- }
471-
472- .shell-prompt {
473- color: var(--primary, #4ade80);
474- }
475-
476- .shell-cursor {
477- animation: blink 1s step-end infinite;
478- }
479-
480- @keyframes blink {
481- 50% { opacity: 0; }
482- }
483-
484- /* Modal Overlay */
485- .modal-overlay {
486- position: fixed;
487- inset: 0;
488- z-index: 100;
489- display: flex;
490- align-items: center;
491- justify-content: center;
492- background: rgb(0 0 0 / 0.7);
493- backdrop-filter: blur(4px);
494- animation: fade-in 150ms ease;
495- }
496-
497- @keyframes fade-in {
498- from { opacity: 0; }
499- }
500-
501- .confirm-modal {
502- width: 100%;
503- max-width: 420px;
504- margin: 1rem;
505- background: var(--card);
506- border-width: 1px;
507- border-style: solid;
508- border-color: var(--border);
509- box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.6);
510- animation: modal-in 200ms ease;
511- }
512-
513- @keyframes modal-in {
514- from {
515- opacity: 0;
516- transform: scale(0.95) translateY(-8px);
517- }
518- }
519-
520- .modal-header {
521- display: flex;
522- align-items: center;
523- gap: 0.6rem;
524- padding: 0.85rem 1rem;
525- border-bottom-width: 1px;
526- border-bottom-style: solid;
527- border-bottom-color: var(--border);
528- background: color-mix(in srgb, var(--background) 80%, transparent);
529- }
530-
531- .modal-icon {
532- font-size: 1rem;
533- color: var(--warning);
534- }
535-
536- .modal-title {
537- font-size: 0.75rem;
538- font-weight: 600;
539- letter-spacing: 0.1em;
540- color: var(--foreground);
541- }
542-
543- .modal-message {
544- margin: 0;
545- padding: 1.25rem 1rem;
546- font-size: 0.85rem;
547- color: var(--foreground);
548- line-height: 1.6;
549- }
550-
551- .modal-actions {
552- display: flex;
553- gap: 0.5rem;
554- padding: 0.85rem 1rem;
555- border-top-width: 1px;
556- border-top-style: solid;
557- border-top-color: var(--border);
558- background: color-mix(in srgb, var(--background) 50%, transparent);
559- }
560-
561- .modal-btn {
562- flex: 1;
563- display: flex;
564- align-items: center;
565- justify-content: center;
566- gap: 0.6rem;
567- padding: 0.6rem 1rem;
568- font-size: 0.72rem;
569- font-weight: 500;
570- letter-spacing: 0.08em;
571- border-width: 1px;
572- border-style: solid;
573- border-color: var(--border);
574- cursor: pointer;
575- transition: all 120ms ease;
576- }
577-
578- .modal-btn kbd {
579- padding: 0.1rem 0.3rem;
580- font-size: 0.6rem;
581- background: var(--background);
582- border-width: 1px;
583- border-style: solid;
584- border-color: var(--border);
585- opacity: 0.7;
586- }
587-
588- .modal-btn-cancel {
589- color: var(--muted-foreground);
590- background: transparent;
591- }
592-
593- .modal-btn-cancel:hover {
594- color: var(--foreground);
595- background: color-mix(in srgb, var(--card) 40%, transparent);
596- }
597-
598- .modal-btn-confirm {
599- color: var(--destructive-foreground);
600- background: var(--destructive);
601- border-color: color-mix(in srgb, var(--destructive) 80%, black 20%);
602- }
603-
604- .modal-btn-confirm:hover {
605- background: color-mix(in srgb, var(--destructive) 85%, white 15%);
606- }
607-
608- /* Responsive */
609- @media (max-width: 768px) {
610- .terminal-header {
611- flex-wrap: wrap;
612- gap: 0.75rem;
613- }
614-
615- .header-left {
616- flex-wrap: wrap;
617- gap: 0.75rem;
618- }
619-
620- .header-tabs {
621- order: 10;
622- width: 100%;
623- }
624-
625- .tab-button {
626- flex: 1;
627- justify-content: center;
628- }
629-
630- .action-button .button-text {
631- display: none;
632- }
633- }
634-</style>
D webui/src/pages/homepage/shell/KeyboardHints.svelte +0 -447
@@ -1,447 +0,0 @@
1-<script lang="ts">
2- import { homepageState } from '../state.svelte';
3- import type { KeyboardHint } from '../keyboard';
4-
5- // Toggle visibility with ?
6- let showHints = $state(false);
7- let hintTimeout: ReturnType<typeof setTimeout> | null = null;
8-
9- // Panel-specific hints
10- const panelHints = $derived.by((): KeyboardHint[] => {
11- switch (homepageState.activePanel) {
12- case 'history':
13- return [
14- { keys: ['d', 'x'], description: 'Delete entry' },
15- { keys: ['D h'], description: 'Clear last hour' },
16- { keys: ['D d'], description: 'Clear last day' },
17- { keys: ['D w'], description: 'Clear last week' },
18- { keys: ['D D'], description: 'Clear all history' },
19- { keys: ['D @'], description: 'Clear domain' },
20- { keys: ['p'], description: 'Pin to favorites' },
21- ];
22- case 'favorites':
23- return [
24- { keys: ['e'], description: 'Edit favorite' },
25- { keys: ['t'], description: 'Manage tags' },
26- { keys: ['m'], description: 'Move to folder' },
27- { keys: ['1-9'], description: 'Set shortcut key' },
28- { keys: ['d'], description: 'Delete favorite' },
29- ];
30- case 'analytics':
31- return [
32- { keys: ['r'], description: 'Refresh stats' },
33- { keys: ['D @'], description: 'Clear domain' },
34- ];
35- default:
36- return [];
37- }
38- });
39-
40- // Navigation hints (always shown)
41- const navHints: KeyboardHint[] = [
42- { keys: ['j', '↓'], description: 'Move down' },
43- { keys: ['k', '↑'], description: 'Move up' },
44- { keys: ['g g'], description: 'Go to first' },
45- { keys: ['G'], description: 'Go to last' },
46- { keys: ['Enter', 'o'], description: 'Open item' },
47- { keys: ['/'], description: 'Search / Command' },
48- { keys: ['Esc'], description: 'Close / Cancel' },
49- ];
50-
51- // Panel switch hints
52- const panelSwitchHints: KeyboardHint[] = [
53- { keys: ['g h'], description: 'History panel' },
54- { keys: ['g f'], description: 'Favorites panel' },
55- { keys: ['g a'], description: 'Analytics panel' },
56- ];
57-
58- // Quick access hints
59- const quickAccessHints: KeyboardHint[] = [
60- { keys: ['1-9'], description: 'Quick navigate to shortcut' },
61- ];
62-
63- // Handle keyboard toggle
64- function handleKeydown(e: KeyboardEvent) {
65- if (e.key === '?' && !e.ctrlKey && !e.altKey && !e.metaKey) {
66- // Don't trigger if in input
67- if ((e.target as HTMLElement).tagName === 'INPUT') return;
68- // Don't trigger if command palette is open
69- if (homepageState.commandPaletteOpen) return;
70-
71- e.preventDefault();
72- toggleHints();
73- }
74- }
75-
76- function toggleHints() {
77- showHints = !showHints;
78-
79- // Auto-hide after 10 seconds
80- if (showHints) {
81- if (hintTimeout) clearTimeout(hintTimeout);
82- hintTimeout = setTimeout(() => {
83- showHints = false;
84- }, 10000);
85- } else {
86- if (hintTimeout) {
87- clearTimeout(hintTimeout);
88- hintTimeout = null;
89- }
90- }
91- }
92-
93- function closeHints() {
94- showHints = false;
95- if (hintTimeout) {
96- clearTimeout(hintTimeout);
97- hintTimeout = null;
98- }
99- }
100-
101- // Cleanup on destroy
102- import { onDestroy } from 'svelte';
103- onDestroy(() => {
104- if (hintTimeout) clearTimeout(hintTimeout);
105- });
106-</script>
107-
108-<svelte:document onkeydown={handleKeydown} />
109-
110-{#if showHints}
111- <div
112- class="hints-overlay"
113- onclick={closeHints}
114- onkeydown={(e) => { if (e.key === 'Escape' || e.key === 'Enter') closeHints(); }}
115- role="button"
116- tabindex="0"
117- aria-label="Close keyboard hints"
118- >
119- <div class="hints-panel" onclick={(e) => e.stopPropagation()} role="presentation">
120- <div class="hints-header">
121- <span class="hints-icon"></span>
122- <span class="hints-title">KEYBOARD SHORTCUTS</span>
123- <button class="hints-close" onclick={closeHints} type="button">
124- <span>×</span>
125- </button>
126- </div>
127-
128- <div class="hints-body">
129- <!-- Navigation Section -->
130- <section class="hints-section">
131- <h3 class="section-title">
132- <span class="section-icon"></span>
133- Navigation
134- </h3>
135- <div class="hints-grid">
136- {#each navHints as hint (hint.description)}
137- <div class="hint-row">
138- <div class="hint-keys">
139- {#each hint.keys as key, i (key)}
140- {#if i > 0}<span class="key-sep">/</span>{/if}
141- <kbd>{key}</kbd>
142- {/each}
143- </div>
144- <span class="hint-desc">{hint.description}</span>
145- </div>
146- {/each}
147- </div>
148- </section>
149-
150- <!-- Panel Switch Section -->
151- <section class="hints-section">
152- <h3 class="section-title">
153- <span class="section-icon"></span>
154- Panels
155- </h3>
156- <div class="hints-grid">
157- {#each panelSwitchHints as hint (hint.description)}
158- <div class="hint-row">
159- <div class="hint-keys">
160- {#each hint.keys as key, i (key)}
161- {#if i > 0}<span class="key-sep">/</span>{/if}
162- <kbd>{key}</kbd>
163- {/each}
164- </div>
165- <span class="hint-desc">{hint.description}</span>
166- </div>
167- {/each}
168- </div>
169- </section>
170-
171- <!-- Current Panel Section -->
172- {#if panelHints.length > 0}
173- <section class="hints-section panel-specific">
174- <h3 class="section-title">
175- <span class="section-icon"></span>
176- {homepageState.activePanel.charAt(0).toUpperCase() + homepageState.activePanel.slice(1)} Actions
177- </h3>
178- <div class="hints-grid">
179- {#each panelHints as hint (hint.description)}
180- <div class="hint-row">
181- <div class="hint-keys">
182- {#each hint.keys as key, i (key)}
183- {#if i > 0}<span class="key-sep">/</span>{/if}
184- <kbd>{key}</kbd>
185- {/each}
186- </div>
187- <span class="hint-desc">{hint.description}</span>
188- </div>
189- {/each}
190- </div>
191- </section>
192- {/if}
193-
194- <!-- Quick Access Section -->
195- <section class="hints-section">
196- <h3 class="section-title">
197- <span class="section-icon"></span>
198- Quick Access
199- </h3>
200- <div class="hints-grid">
201- {#each quickAccessHints as hint (hint.description)}
202- <div class="hint-row">
203- <div class="hint-keys">
204- {#each hint.keys as key, i (key)}
205- {#if i > 0}<span class="key-sep">/</span>{/if}
206- <kbd>{key}</kbd>
207- {/each}
208- </div>
209- <span class="hint-desc">{hint.description}</span>
210- </div>
211- {/each}
212- </div>
213- </section>
214- </div>
215-
216- <div class="hints-footer">
217- <span class="footer-hint">Press <kbd>?</kbd> to toggle hints</span>
218- </div>
219- </div>
220- </div>
221-{/if}
222-
223-<style>
224- .hints-overlay {
225- position: fixed;
226- inset: 0;
227- z-index: 150;
228- display: flex;
229- align-items: center;
230- justify-content: center;
231- background: rgb(0 0 0 / 0.6);
232- backdrop-filter: blur(4px);
233- animation: fade-in 150ms ease;
234- }
235-
236- @keyframes fade-in {
237- from { opacity: 0; }
238- }
239-
240- .hints-panel {
241- width: 100%;
242- max-width: 540px;
243- max-height: 85vh;
244- margin: 1rem;
245- display: flex;
246- flex-direction: column;
247- background: var(--card);
248- border-width: 1px;
249- border-style: solid;
250- border-color: var(--border);
251- box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.6);
252- animation: panel-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
253- overflow: hidden;
254- }
255-
256- @keyframes panel-in {
257- from {
258- opacity: 0;
259- transform: scale(0.95);
260- }
261- }
262-
263- /* Header */
264- .hints-header {
265- display: flex;
266- align-items: center;
267- gap: 0.6rem;
268- padding: 0.85rem 1rem;
269- border-bottom: 1px solid var(--border);
270- background: color-mix(in srgb, var(--background) 80%, transparent);
271- }
272-
273- .hints-icon {
274- font-size: 1rem;
275- color: var(--primary, #4ade80);
276- }
277-
278- .hints-title {
279- flex: 1;
280- font-size: 0.75rem;
281- font-weight: 600;
282- letter-spacing: 0.1em;
283- color: var(--foreground);
284- }
285-
286- .hints-close {
287- display: flex;
288- align-items: center;
289- justify-content: center;
290- width: 1.75rem;
291- height: 1.75rem;
292- font-size: 1.25rem;
293- color: var(--muted-foreground);
294- background: transparent;
295- border-width: 1px;
296- border-style: solid;
297- border-color: var(--border);
298- cursor: pointer;
299- transition: all 120ms ease;
300- }
301-
302- .hints-close:hover {
303- color: var(--foreground);
304- border-color: color-mix(in srgb, var(--border) 50%, var(--foreground) 50%);
305- }
306-
307- /* Body */
308- .hints-body {
309- flex: 1;
310- overflow-y: auto;
311- padding: 1rem;
312- display: flex;
313- flex-direction: column;
314- gap: 1.25rem;
315- }
316-
317- .hints-section {
318- display: flex;
319- flex-direction: column;
320- gap: 0.6rem;
321- }
322-
323- .hints-section.panel-specific {
324- padding: 0.75rem;
325- background: color-mix(in srgb, var(--primary, #4ade80) 8%, transparent);
326- border: 1px solid color-mix(in srgb, var(--primary, #4ade80) 25%, var(--border) 75%);
327- }
328-
329- .section-title {
330- display: flex;
331- align-items: center;
332- gap: 0.5rem;
333- margin: 0;
334- font-size: 0.72rem;
335- font-weight: 600;
336- text-transform: uppercase;
337- letter-spacing: 0.1em;
338- color: var(--foreground);
339- }
340-
341- .section-icon {
342- font-size: 0.85rem;
343- color: var(--muted-foreground);
344- }
345-
346- .panel-specific .section-icon {
347- color: var(--primary, #4ade80);
348- }
349-
350- .hints-grid {
351- display: grid;
352- grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
353- gap: 0.4rem;
354- }
355-
356- .hint-row {
357- display: flex;
358- align-items: center;
359- gap: 0.6rem;
360- padding: 0.4rem 0.6rem;
361- background: color-mix(in srgb, var(--background) 60%, transparent);
362- border: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
363- }
364-
365- .hint-keys {
366- display: flex;
367- align-items: center;
368- gap: 0.2rem;
369- flex-shrink: 0;
370- }
371-
372- .hint-keys kbd {
373- padding: 0.2rem 0.4rem;
374- font-size: 0.68rem;
375- font-family: inherit;
376- color: var(--foreground);
377- background: var(--background);
378- border-width: 1px;
379- border-style: solid;
380- border-color: var(--border);
381- min-width: 1.4rem;
382- text-align: center;
383- }
384-
385- .key-sep {
386- font-size: 0.6rem;
387- color: var(--muted-foreground);
388- opacity: 0.6;
389- }
390-
391- .hint-desc {
392- font-size: 0.72rem;
393- color: var(--muted-foreground);
394- white-space: nowrap;
395- overflow: hidden;
396- text-overflow: ellipsis;
397- }
398-
399- /* Footer */
400- .hints-footer {
401- display: flex;
402- justify-content: center;
403- padding: 0.65rem 1rem;
404- border-top: 1px solid var(--border);
405- background: color-mix(in srgb, var(--background) 70%, transparent);
406- }
407-
408- .footer-hint {
409- font-size: 0.68rem;
410- color: var(--muted-foreground);
411- letter-spacing: 0.05em;
412- }
413-
414- .footer-hint kbd {
415- padding: 0.15rem 0.35rem;
416- font-size: 0.62rem;
417- background: var(--background);
418- border-width: 1px;
419- border-style: solid;
420- border-color: var(--border);
421- margin: 0 0.25rem;
422- }
423-
424- /* Scrollbar */
425- .hints-body::-webkit-scrollbar {
426- width: 6px;
427- }
428-
429- .hints-body::-webkit-scrollbar-track {
430- background: transparent;
431- }
432-
433- .hints-body::-webkit-scrollbar-thumb {
434- background: var(--border);
435- }
436-
437- /* Responsive */
438- @media (max-width: 480px) {
439- .hints-grid {
440- grid-template-columns: 1fr;
441- }
442-
443- .hints-panel {
444- max-height: 90vh;
445- }
446- }
447-</style>
D webui/src/pages/homepage/shell/StatusBar.svelte +0 -416
@@ -1,416 +0,0 @@
1-<script lang="ts">
2- import { homepageState } from '../state.svelte';
3- import { History, Star, BarChart3, Terminal, Search, Folder, Tags, Command } from '@lucide/svelte';
4-
5- // Derived status indicators
6- const panelLabel = $derived({
7- history: 'HISTORY',
8- favorites: 'FAVORITES',
9- analytics: 'ANALYTICS',
10- }[homepageState.activePanel]);
11-
12- const panelIcon = $derived({
13- history: History,
14- favorites: Star,
15- analytics: BarChart3,
16- }[homepageState.activePanel]);
17-
18- // Loading states
19- const isLoading = $derived(
20- homepageState.historyLoading ||
21- homepageState.favoritesLoading ||
22- homepageState.analyticsLoading
23- );
24-
25- // Current list stats
26- const listStats = $derived.by(() => {
27- switch (homepageState.activePanel) {
28- case 'history':
29- if (homepageState.historySearchQuery) {
30- return {
31- count: homepageState.historySearchResults.length,
32- label: 'results',
33- filtered: true,
34- };
35- }
36- return {
37- count: homepageState.history.length,
38- label: 'entries',
39- filtered: false,
40- };
41- case 'favorites': {
42- const filtered = homepageState.filteredFavorites;
43- const total = homepageState.favorites.length;
44- return {
45- count: filtered.length,
46- label: filtered.length === total ? 'items' : `of ${total}`,
47- filtered: filtered.length !== total,
48- };
49- }
50- case 'analytics':
51- return {
52- count: homepageState.domainStats.length,
53- label: 'domains',
54- filtered: false,
55- };
56- default:
57- return { count: 0, label: 'items', filtered: false };
58- }
59- });
60-
61- // Position in list
62- const positionInfo = $derived.by(() => {
63- const length = homepageState.currentListLength;
64- if (length === 0) return null;
65-
66- const pos = homepageState.focusedIndex + 1;
67- return { current: pos, total: length };
68- });
69-
70- // Mode indicator
71- const modeIndicator = $derived.by(() => {
72- if (homepageState.commandPaletteOpen) {
73- return { icon: Terminal, label: 'COMMAND', color: 'accent' };
74- }
75- if (homepageState.historySearchQuery) {
76- return { icon: Search, label: 'SEARCH', color: 'warning' };
77- }
78- if (homepageState.selectedFolderId !== null) {
79- const folder = homepageState.folders.find(f => f.id === homepageState.selectedFolderId);
80- return { icon: Folder, label: folder?.name || 'FOLDER', color: 'info' };
81- }
82- if (homepageState.selectedTagIds.length > 0) {
83- return { icon: Tags, label: `${homepageState.selectedTagIds.length} TAGS`, color: 'info' };
84- }
85- return null;
86- });
87-</script>
88-
89-<footer class="status-bar">
90- <!-- Left Section: Mode & Panel -->
91- <div class="status-left">
92- <!-- Pending Key Prefix Indicator -->
93- {#if homepageState.pendingKeyPrefix}
94- <div class="status-chip prefix-chip" data-color="warning">
95- <span class="chip-icon">
96- <Command size={12} strokeWidth={2} />
97- </span>
98- <kbd class="prefix-key">{homepageState.pendingKeyPrefix}</kbd>
99- <span class="prefix-waiting">...</span>
100- </div>
101- {/if}
102-
103- <!-- Mode Indicator -->
104- {#if modeIndicator}
105- {@const ModeIcon = modeIndicator.icon}
106- <div class="status-chip mode-chip" data-color={modeIndicator.color}>
107- <span class="chip-icon">
108- <ModeIcon size={12} strokeWidth={2} />
109- </span>
110- <span class="chip-text">{modeIndicator.label}</span>
111- </div>
112- {/if}
113-
114- <!-- Panel Indicator -->
115- <div class="status-chip panel-chip">
116- <span class="chip-icon">
117- {#if panelIcon}
118- {@const PanelIcon = panelIcon}
119- <PanelIcon size={12} strokeWidth={2} />
120- {/if}
121- </span>
122- <span class="chip-text">{panelLabel}</span>
123- </div>
124-
125- <!-- Loading Indicator -->
126- {#if isLoading}
127- <div class="status-chip loading-chip">
128- <span class="loading-spinner"></span>
129- <span class="chip-text">SYNC</span>
130- </div>
131- {/if}
132- </div>
133-
134- <!-- Center Section: Stats -->
135- <div class="status-center">
136- <div class="stats-group">
137- <span class="stats-count" class:filtered={listStats.filtered}>
138- {listStats.count}
139- </span>
140- <span class="stats-label">{listStats.label}</span>
141- </div>
142-
143- {#if positionInfo}
144- <div class="position-group">
145- <span class="position-current">{positionInfo.current}</span>
146- <span class="position-sep">/</span>
147- <span class="position-total">{positionInfo.total}</span>
148- </div>
149- {/if}
150- </div>
151-
152- <!-- Right Section: Quick Hints -->
153- <div class="status-right">
154- <div class="hint-item">
155- <kbd>/</kbd>
156- <span>cmd</span>
157- </div>
158- <div class="hint-item">
159- <kbd>j</kbd><kbd>k</kbd>
160- <span>nav</span>
161- </div>
162- <div class="hint-item">
163- <kbd>?</kbd>
164- <span>help</span>
165- </div>
166- </div>
167-</footer>
168-
169-<style>
170- .status-bar {
171- display: flex;
172- align-items: center;
173- justify-content: space-between;
174- gap: 1rem;
175- padding: 0.5rem 0.85rem;
176- font-size: 0.68rem;
177- font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
178- letter-spacing: 0.05em;
179- border-top: 1px solid var(--border);
180- background: color-mix(in srgb, var(--background) 92%, var(--card) 8%);
181- color: var(--muted-foreground);
182- }
183-
184- /* Sections */
185- .status-left,
186- .status-center,
187- .status-right {
188- display: flex;
189- align-items: center;
190- gap: 0.6rem;
191- }
192-
193- .status-left {
194- flex: 1;
195- justify-content: flex-start;
196- }
197-
198- .status-center {
199- flex: 0 0 auto;
200- gap: 1rem;
201- }
202-
203- .status-right {
204- flex: 1;
205- justify-content: flex-end;
206- gap: 0.85rem;
207- }
208-
209- /* Status Chips */
210- .status-chip {
211- display: inline-flex;
212- align-items: center;
213- gap: 0.35rem;
214- padding: 0.25rem 0.5rem;
215- background: color-mix(in srgb, var(--card) 60%, transparent);
216- border-width: 1px;
217- border-style: solid;
218- border-color: var(--border);
219- }
220-
221- .chip-icon {
222- display: flex;
223- align-items: center;
224- }
225-
226- .chip-text {
227- font-weight: 500;
228- text-transform: uppercase;
229- }
230-
231- /* Mode chip colors */
232- .mode-chip[data-color="accent"] {
233- background: color-mix(in srgb, var(--primary, #4ade80) 15%, transparent);
234- border-color: color-mix(in srgb, var(--primary, #4ade80) 40%, var(--border) 60%);
235- color: var(--primary, #4ade80);
236- }
237-
238- .mode-chip[data-color="warning"] {
239- background: color-mix(in srgb, var(--warning) 15%, transparent);
240- border-color: color-mix(in srgb, var(--warning) 40%, var(--border) 60%);
241- color: var(--warning);
242- }
243-
244- .mode-chip[data-color="info"] {
245- background: color-mix(in srgb, var(--info) 15%, transparent);
246- border-color: color-mix(in srgb, var(--info) 40%, var(--border) 60%);
247- color: var(--info);
248- }
249-
250- /* Pending prefix indicator */
251- .prefix-chip {
252- animation: pulse-border 1s ease-in-out infinite;
253- }
254-
255- .prefix-chip[data-color="warning"] {
256- background: color-mix(in srgb, var(--warning) 15%, transparent);
257- border-color: color-mix(in srgb, var(--warning) 50%, var(--border) 50%);
258- color: var(--warning);
259- }
260-
261- .prefix-key {
262- font-size: 0.7rem;
263- font-weight: 700;
264- padding: 0.1rem 0.25rem;
265- background: color-mix(in srgb, var(--warning) 25%, transparent);
266- border: 1px solid color-mix(in srgb, var(--warning) 40%, var(--border) 60%);
267- font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
268- }
269-
270- .prefix-waiting {
271- font-size: 0.65rem;
272- opacity: 0.8;
273- animation: blink 0.6s ease-in-out infinite;
274- }
275-
276- @keyframes pulse-border {
277- 0%, 100% { border-color: color-mix(in srgb, var(--warning) 50%, var(--border) 50%); }
278- 50% { border-color: var(--warning); }
279- }
280-
281- @keyframes blink {
282- 0%, 100% { opacity: 0.4; }
283- 50% { opacity: 1; }
284- }
285-
286- .panel-chip {
287- color: var(--foreground);
288- }
289-
290- .panel-chip .chip-icon {
291- color: var(--primary, #4ade80);
292- }
293-
294- /* Loading */
295- .loading-chip {
296- color: var(--muted-foreground);
297- }
298-
299- .loading-spinner {
300- width: 0.7rem;
301- height: 0.7rem;
302- border: 1.5px solid var(--border);
303- border-top-color: var(--primary, #4ade80);
304- border-radius: 50%;
305- animation: spin 0.8s linear infinite;
306- }
307-
308- @keyframes spin {
309- to { transform: rotate(360deg); }
310- }
311-
312- /* Stats */
313- .stats-group {
314- display: flex;
315- align-items: baseline;
316- gap: 0.35rem;
317- }
318-
319- .stats-count {
320- font-weight: 600;
321- color: var(--foreground);
322- font-variant-numeric: tabular-nums;
323- }
324-
325- .stats-count.filtered {
326- color: var(--primary, #4ade80);
327- }
328-
329- .stats-label {
330- color: var(--muted-foreground);
331- }
332-
333- .position-group {
334- display: flex;
335- align-items: center;
336- gap: 0.15rem;
337- padding: 0.2rem 0.45rem;
338- background: var(--background);
339- border-width: 1px;
340- border-style: solid;
341- border-color: var(--border);
342- font-variant-numeric: tabular-nums;
343- }
344-
345- .position-current {
346- font-weight: 600;
347- color: var(--foreground);
348- }
349-
350- .position-sep {
351- color: var(--border);
352- }
353-
354- .position-total {
355- color: var(--muted-foreground);
356- }
357-
358- /* Hints */
359- .hint-item {
360- display: flex;
361- align-items: center;
362- gap: 0.25rem;
363- color: var(--muted-foreground);
364- }
365-
366- .hint-item kbd {
367- padding: 0.1rem 0.3rem;
368- font-size: 0.6rem;
369- background: var(--background);
370- border-width: 1px;
371- border-style: solid;
372- border-color: var(--border);
373- color: var(--foreground);
374- }
375-
376- .hint-item span {
377- font-size: 0.62rem;
378- text-transform: uppercase;
379- letter-spacing: 0.08em;
380- }
381-
382- /* Responsive */
383- @media (max-width: 640px) {
384- .status-bar {
385- flex-wrap: wrap;
386- gap: 0.5rem;
387- }
388-
389- .status-left,
390- .status-right {
391- flex: 0 0 auto;
392- }
393-
394- .status-center {
395- order: -1;
396- width: 100%;
397- justify-content: center;
398- padding-bottom: 0.35rem;
399- border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
400- }
401-
402- .hint-item span {
403- display: none;
404- }
405- }
406-
407- @media (max-width: 480px) {
408- .status-right {
409- display: none;
410- }
411-
412- .status-left {
413- flex: 1;
414- }
415- }
416-</style>
D webui/src/pages/homepage/shell/index.ts +0 -5
@@ -1,5 +0,0 @@
1-// Shell Components - Industrial Terminal Layout
2-export { default as HomepageShell } from './HomepageShell.svelte';
3-export { default as CommandPalette } from './CommandPalette.svelte';
4-export { default as StatusBar } from './StatusBar.svelte';
5-export { default as KeyboardHints } from './KeyboardHints.svelte';
D webui/src/pages/homepage/state.svelte.ts +0 -504
@@ -1,504 +0,0 @@
1-// ═══════════════════════════════════════════════════════════════════════════════
2-// Homepage Global State (Svelte 5 Runes)
3-// Single module-level $state with exported store object
4-// ═══════════════════════════════════════════════════════════════════════════════
5-
6-import type {
7- HistoryEntry,
8- TimelineGroup,
9- DomainStat,
10- DailyVisitCount,
11- HourlyDistribution,
12- Favorite,
13- Folder,
14- Tag,
15- Analytics,
16- PanelType,
17- HistoryCleanupRange,
18-} from './types';
19-
20-// ═══════════════════════════════════════════════════════════════════════════════
21-// HISTORY STATE
22-// ═══════════════════════════════════════════════════════════════════════════════
23-
24-let history = $state<HistoryEntry[]>([]);
25-let timeline = $state<TimelineGroup[]>([]);
26-let historyLoading = $state(true);
27-let historyOffset = $state(0);
28-let hasMoreHistory = $state(true);
29-let historySearchQuery = $state('');
30-let historySearchResults = $state<HistoryEntry[]>([]);
31-let historySearching = $state(false);
32-
33-// ═══════════════════════════════════════════════════════════════════════════════
34-// FAVORITES STATE
35-// ═══════════════════════════════════════════════════════════════════════════════
36-
37-let favorites = $state<Favorite[]>([]);
38-let folders = $state<Folder[]>([]);
39-let tags = $state<Tag[]>([]);
40-let favoritesLoading = $state(true);
41-let selectedFolderId = $state<number | null>(null);
42-let selectedTagIds = $state<number[]>([]);
43-let editingFavorite = $state<Favorite | null>(null);
44-
45-// ═══════════════════════════════════════════════════════════════════════════════
46-// ANALYTICS STATE
47-// ═══════════════════════════════════════════════════════════════════════════════
48-
49-let analytics = $state<Analytics | null>(null);
50-let analyticsLoading = $state(true);
51-let domainStats = $state<DomainStat[]>([]);
52-let dailyVisits = $state<DailyVisitCount[]>([]);
53-let hourlyDistribution = $state<HourlyDistribution[]>([]);
54-
55-// ═══════════════════════════════════════════════════════════════════════════════
56-// UI STATE
57-// ═══════════════════════════════════════════════════════════════════════════════
58-
59-let activePanel = $state<PanelType>('history');
60-let commandPaletteOpen = $state(false);
61-let commandPaletteQuery = $state('');
62-let focusedIndex = $state(-1); // -1 means no item focused (focus on search input)
63-let confirmModalOpen = $state(false);
64-let confirmModalMessage = $state('');
65-let confirmModalAction = $state<(() => void) | null>(null);
66-let cleanupModalOpen = $state(false);
67-let cleanupRange = $state<HistoryCleanupRange>('hour');
68-let pendingKeyPrefix = $state<string | null>(null);
69-
70-// ═══════════════════════════════════════════════════════════════════════════════
71-// HELPER FUNCTIONS
72-// ═══════════════════════════════════════════════════════════════════════════════
73-
74-/* eslint-disable svelte/prefer-svelte-reactivity -- helper function uses temporary Map/Date, not reactive state */
75-function groupHistoryByDate(entries: HistoryEntry[]): TimelineGroup[] {
76- const groups = new Map<string, HistoryEntry[]>();
77- const today = new Date();
78- today.setHours(0, 0, 0, 0);
79- const yesterday = new Date(today);
80- yesterday.setDate(yesterday.getDate() - 1);
81-
82- for (const entry of entries) {
83- const date = new Date(entry.last_visited);
84- const dateKey = date.toISOString().split('T')[0] ?? '';
85- if (!groups.has(dateKey)) {
86- groups.set(dateKey, []);
87- }
88- groups.get(dateKey)!.push(entry);
89- }
90-
91- const result: TimelineGroup[] = [];
92- for (const [dateKey, items] of groups) {
93- const date = new Date(dateKey);
94- let label: string;
95-
96- if (date >= today) {
97- label = 'Today';
98- } else if (date >= yesterday) {
99- label = 'Yesterday';
100- } else {
101- label = date.toLocaleDateString(undefined, {
102- weekday: 'short',
103- month: 'short',
104- day: 'numeric',
105- });
106- }
107-
108- result.push({ date: dateKey, label, entries: items });
109- }
110-
111- return result.sort((a, b) => b.date.localeCompare(a.date));
112-}
113-/* eslint-enable svelte/prefer-svelte-reactivity */
114-
115-// ═══════════════════════════════════════════════════════════════════════════════
116-// EXPORTED STORE
117-// ═══════════════════════════════════════════════════════════════════════════════
118-
119-export const homepageState = {
120- // ─────────────────────────────────────────────────────────────────────────────
121- // History Getters
122- // ─────────────────────────────────────────────────────────────────────────────
123- get history() { return history; },
124- get timeline() { return timeline; },
125- get historyLoading() { return historyLoading; },
126- get historyOffset() { return historyOffset; },
127- get hasMoreHistory() { return hasMoreHistory; },
128- get historySearchQuery() { return historySearchQuery; },
129- get historySearchResults() { return historySearchResults; },
130- get historySearching() { return historySearching; },
131-
132- // ─────────────────────────────────────────────────────────────────────────────
133- // Favorites Getters
134- // ─────────────────────────────────────────────────────────────────────────────
135- get favorites() { return favorites; },
136- get folders() { return folders; },
137- get tags() { return tags; },
138- get favoritesLoading() { return favoritesLoading; },
139- get selectedFolderId() { return selectedFolderId; },
140- get selectedTagIds() { return selectedTagIds; },
141- get editingFavorite() { return editingFavorite; },
142-
143- // ─────────────────────────────────────────────────────────────────────────────
144- // Analytics Getters
145- // ─────────────────────────────────────────────────────────────────────────────
146- get analytics() { return analytics; },
147- get analyticsLoading() { return analyticsLoading; },
148- get domainStats() { return domainStats; },
149- get dailyVisits() { return dailyVisits; },
150- get hourlyDistribution() { return hourlyDistribution; },
151-
152- // ─────────────────────────────────────────────────────────────────────────────
153- // UI Getters
154- // ─────────────────────────────────────────────────────────────────────────────
155- get activePanel() { return activePanel; },
156- get commandPaletteOpen() { return commandPaletteOpen; },
157- get commandPaletteQuery() { return commandPaletteQuery; },
158- get focusedIndex() { return focusedIndex; },
159- get confirmModalOpen() { return confirmModalOpen; },
160- get confirmModalMessage() { return confirmModalMessage; },
161- get cleanupModalOpen() { return cleanupModalOpen; },
162- get cleanupRange() { return cleanupRange; },
163- get pendingKeyPrefix() { return pendingKeyPrefix; },
164-
165- // ─────────────────────────────────────────────────────────────────────────────
166- // Computed Properties
167- // ─────────────────────────────────────────────────────────────────────────────
168- get filteredFavorites(): Favorite[] {
169- let result = favorites;
170-
171- // Filter by folder
172- if (selectedFolderId !== null) {
173- result = result.filter(f => f.folder_id === selectedFolderId);
174- }
175-
176- // Filter by tags
177- if (selectedTagIds.length > 0) {
178- result = result.filter(f =>
179- f.tags?.some(t => selectedTagIds.includes(t.id))
180- );
181- }
182-
183- return result;
184- },
185-
186- get currentList(): HistoryEntry[] | Favorite[] {
187- switch (activePanel) {
188- case 'history':
189- return historySearchQuery ? historySearchResults : history;
190- case 'favorites':
191- return this.filteredFavorites;
192- default:
193- return [];
194- }
195- },
196-
197- get currentListLength(): number {
198- return this.currentList.length;
199- },
200-
201- // ─────────────────────────────────────────────────────────────────────────────
202- // History Actions
203- // ─────────────────────────────────────────────────────────────────────────────
204- setHistory(items: HistoryEntry[]) {
205- history = items;
206- timeline = groupHistoryByDate(items);
207- },
208-
209- appendHistory(items: HistoryEntry[]) {
210- history = [...history, ...items];
211- timeline = groupHistoryByDate(history);
212- },
213-
214- setHistoryLoading(loading: boolean) {
215- historyLoading = loading;
216- },
217-
218- setHistoryOffset(offset: number) {
219- historyOffset = offset;
220- },
221-
222- setHasMoreHistory(hasMore: boolean) {
223- hasMoreHistory = hasMore;
224- },
225-
226- deleteHistoryEntry(id: number) {
227- history = history.filter(h => h.id !== id);
228- timeline = groupHistoryByDate(history);
229- historySearchResults = historySearchResults.filter(h => h.id !== id);
230- },
231-
232- clearHistory() {
233- history = [];
234- timeline = [];
235- historySearchResults = [];
236- historyOffset = 0;
237- hasMoreHistory = true;
238- },
239-
240- setHistorySearchQuery(q: string) {
241- historySearchQuery = q;
242- if (!q) {
243- historySearchResults = [];
244- }
245- },
246-
247- setHistorySearchResults(results: HistoryEntry[]) {
248- historySearchResults = results;
249- },
250-
251- setHistorySearching(searching: boolean) {
252- historySearching = searching;
253- },
254-
255- // ─────────────────────────────────────────────────────────────────────────────
256- // Favorites Actions
257- // ─────────────────────────────────────────────────────────────────────────────
258- setFavorites(items: Favorite[]) {
259- favorites = items;
260- },
261-
262- setFolders(items: Folder[]) {
263- folders = items;
264- },
265-
266- setTags(items: Tag[]) {
267- tags = items;
268- },
269-
270- setFavoritesLoading(loading: boolean) {
271- favoritesLoading = loading;
272- },
273-
274- selectFolder(id: number | null) {
275- selectedFolderId = id;
276- focusedIndex = 0;
277- },
278-
279- toggleTag(id: number) {
280- if (selectedTagIds.includes(id)) {
281- selectedTagIds = selectedTagIds.filter(t => t !== id);
282- } else {
283- selectedTagIds = [...selectedTagIds, id];
284- }
285- focusedIndex = 0;
286- },
287-
288- clearTagSelection() {
289- selectedTagIds = [];
290- focusedIndex = 0;
291- },
292-
293- setEditingFavorite(fav: Favorite | null) {
294- editingFavorite = fav;
295- },
296-
297- updateFavorite(updated: Favorite) {
298- favorites = favorites.map(f => f.id === updated.id ? updated : f);
299- },
300-
301- deleteFavorite(id: number) {
302- favorites = favorites.filter(f => f.id !== id);
303- },
304-
305- addFolder(folder: Folder) {
306- folders = [...folders, folder];
307- },
308-
309- updateFolder(updated: Folder) {
310- folders = folders.map(f => f.id === updated.id ? updated : f);
311- },
312-
313- deleteFolder(id: number) {
314- folders = folders.filter(f => f.id !== id);
315- // Clear folder selection if deleted folder was selected
316- if (selectedFolderId === id) {
317- selectedFolderId = null;
318- }
319- // Update favorites that were in this folder
320- favorites = favorites.map(f =>
321- f.folder_id === id ? { ...f, folder_id: null } : f
322- );
323- },
324-
325- addTag(tag: Tag) {
326- tags = [...tags, tag];
327- },
328-
329- updateTag(updated: Tag) {
330- tags = tags.map(t => t.id === updated.id ? updated : t);
331- },
332-
333- deleteTag(id: number) {
334- tags = tags.filter(t => t.id !== id);
335- selectedTagIds = selectedTagIds.filter(t => t !== id);
336- // Remove tag from all favorites
337- favorites = favorites.map(f => ({
338- ...f,
339- tags: f.tags?.filter(t => t.id !== id),
340- }));
341- },
342-
343- // ─────────────────────────────────────────────────────────────────────────────
344- // Analytics Actions
345- // ─────────────────────────────────────────────────────────────────────────────
346- setAnalytics(data: Analytics | null) {
347- analytics = data;
348- if (data) {
349- domainStats = data.top_domains ?? [];
350- dailyVisits = data.daily_visits ?? [];
351- hourlyDistribution = data.hourly_distribution ?? [];
352- }
353- },
354-
355- setAnalyticsLoading(loading: boolean) {
356- analyticsLoading = loading;
357- },
358-
359- setDomainStats(stats: DomainStat[]) {
360- domainStats = stats;
361- },
362-
363- // ─────────────────────────────────────────────────────────────────────────────
364- // UI Actions
365- // ─────────────────────────────────────────────────────────────────────────────
366- setActivePanel(panel: PanelType) {
367- activePanel = panel;
368- focusedIndex = -1; // Clear focus, let search input be focused
369- // Clear search when switching panels
370- historySearchQuery = '';
371- historySearchResults = [];
372- },
373-
374- openCommandPalette() {
375- commandPaletteOpen = true;
376- commandPaletteQuery = '';
377- },
378-
379- closeCommandPalette() {
380- commandPaletteOpen = false;
381- commandPaletteQuery = '';
382- },
383-
384- setCommandPaletteQuery(q: string) {
385- commandPaletteQuery = q;
386- },
387-
388- setFocusedIndex(i: number) {
389- focusedIndex = Math.max(-1, i);
390- },
391-
392- focusNext() {
393- const maxIndex = this.currentListLength - 1;
394- if (maxIndex < 0) return;
395- // If unfocused (-1), go to first item
396- if (focusedIndex === -1) {
397- focusedIndex = 0;
398- } else {
399- focusedIndex = Math.min(focusedIndex + 1, maxIndex);
400- }
401- },
402-
403- focusPrev() {
404- // If at first item or unfocused, stay at -1 (return to search)
405- if (focusedIndex <= 0) {
406- focusedIndex = -1;
407- } else {
408- focusedIndex = focusedIndex - 1;
409- }
410- },
411-
412- focusFirst() {
413- focusedIndex = 0;
414- },
415-
416- focusLast() {
417- focusedIndex = Math.max(0, this.currentListLength - 1);
418- },
419-
420- clearFocus() {
421- focusedIndex = -1;
422- },
423-
424- // Confirmation modal
425- showConfirm(message: string, action: () => void) {
426- confirmModalMessage = message;
427- confirmModalAction = action;
428- confirmModalOpen = true;
429- },
430-
431- confirmAction() {
432- confirmModalAction?.();
433- confirmModalOpen = false;
434- confirmModalAction = null;
435- confirmModalMessage = '';
436- },
437-
438- cancelConfirm() {
439- confirmModalOpen = false;
440- confirmModalAction = null;
441- confirmModalMessage = '';
442- },
443-
444- // Cleanup modal
445- openCleanupModal() {
446- cleanupModalOpen = true;
447- cleanupRange = 'hour';
448- },
449-
450- closeCleanupModal() {
451- cleanupModalOpen = false;
452- },
453-
454- setCleanupRange(range: HistoryCleanupRange) {
455- cleanupRange = range;
456- },
457-
458- // Keyboard prefix state (for 'g' and 'D' prefix indicators)
459- setPendingKeyPrefix(prefix: string | null) {
460- pendingKeyPrefix = prefix;
461- },
462-
463- // ─────────────────────────────────────────────────────────────────────────────
464- // Reset
465- // ─────────────────────────────────────────────────────────────────────────────
466- reset() {
467- // History
468- history = [];
469- timeline = [];
470- historyLoading = true;
471- historyOffset = 0;
472- hasMoreHistory = true;
473- historySearchQuery = '';
474- historySearchResults = [];
475- historySearching = false;
476-
477- // Favorites
478- favorites = [];
479- folders = [];
480- tags = [];
481- favoritesLoading = true;
482- selectedFolderId = null;
483- selectedTagIds = [];
484- editingFavorite = null;
485-
486- // Analytics
487- analytics = null;
488- analyticsLoading = true;
489- domainStats = [];
490- dailyVisits = [];
491- hourlyDistribution = [];
492-
493- // UI
494- activePanel = 'history';
495- commandPaletteOpen = false;
496- commandPaletteQuery = '';
497- focusedIndex = -1;
498- confirmModalOpen = false;
499- confirmModalMessage = '';
500- confirmModalAction = null;
501- cleanupModalOpen = false;
502- cleanupRange = 'hour';
503- },
504-};
D webui/src/pages/homepage/types.ts +0 -232
@@ -1,232 +0,0 @@
1-// ═══════════════════════════════════════════════════════════════════════════════
2-// Homepage TypeScript Types
3-// Mirrors Go models from internal/db/models.go and related query files
4-// ═══════════════════════════════════════════════════════════════════════════════
5-
6-import type { Component } from 'svelte';
7-
8-// ─────────────────────────────────────────────────────────────────────────────
9-// History Types
10-// ─────────────────────────────────────────────────────────────────────────────
11-
12-export interface HistoryEntry {
13- id: number;
14- url: string;
15- title: string | null;
16- favicon_url: string | null;
17- visit_count: number;
18- last_visited: string;
19- created_at: string;
20-}
21-
22-export interface TimelineGroup {
23- date: string; // YYYY-MM-DD
24- label: string; // "Today", "Yesterday", "Dec 5", etc.
25- entries: HistoryEntry[];
26-}
27-
28-export interface HistoryStats {
29- total_entries: number;
30- total_visits: number;
31- unique_days: number;
32-}
33-
34-export interface DomainStat {
35- domain: string;
36- page_count: number;
37- total_visits: number;
38- last_visit: string;
39-}
40-
41-export interface DailyVisitCount {
42- day: string;
43- entries: number;
44- visits: number;
45-}
46-
47-export interface HourlyDistribution {
48- hour: number;
49- visit_count: number;
50-}
51-
52-// ─────────────────────────────────────────────────────────────────────────────
53-// Favorites Types
54-// ─────────────────────────────────────────────────────────────────────────────
55-
56-export interface Favorite {
57- id: number;
58- url: string;
59- title: string | null;
60- favicon_url: string | null;
61- position: number;
62- created_at: string;
63- updated_at: string;
64- folder_id: number | null;
65- shortcut_key: number | null; // 1-9
66- tags?: Tag[]; // Populated via join
67-}
68-
69-export interface Folder {
70- id: number;
71- name: string;
72- icon: string | null;
73- position: number;
74- created_at: string;
75-}
76-
77-export interface Tag {
78- id: number;
79- name: string;
80- color: string; // Hex color like #6b7280
81- created_at: string;
82-}
83-
84-export interface TagAssignment {
85- favorite_id: number;
86- tag_id: number;
87-}
88-
89-// ─────────────────────────────────────────────────────────────────────────────
90-// Analytics Types
91-// ─────────────────────────────────────────────────────────────────────────────
92-
93-// Matches Go HistoryAnalytics struct (flat structure)
94-export interface Analytics {
95- total_entries: number;
96- total_visits: number;
97- unique_days: number;
98- top_domains: DomainStat[];
99- daily_visits: DailyVisitCount[];
100- hourly_distribution: HourlyDistribution[];
101-}
102-
103-// ─────────────────────────────────────────────────────────────────────────────
104-// UI State Types
105-// ─────────────────────────────────────────────────────────────────────────────
106-
107-export type PanelType = 'history' | 'favorites' | 'analytics';
108-
109-export type HistoryCleanupRange = 'hour' | 'day' | 'week' | 'month' | 'all';
110-
111-export interface CommandPaletteItem {
112- id: string;
113- label: string;
114- description?: string;
115- icon?: Component | string;
116- action: () => void;
117- shortcut?: string;
118-}
119-
120-export interface KeyboardShortcut {
121- key: string;
122- description: string;
123- action: () => void;
124- context?: 'global' | 'history' | 'favorites' | 'command-palette';
125-}
126-
127-// ─────────────────────────────────────────────────────────────────────────────
128-// Message Types (WebKit Bridge)
129-// ─────────────────────────────────────────────────────────────────────────────
130-
131-export type MessageType =
132- // Folders
133- | 'folder_list'
134- | 'folder_create'
135- | 'folder_update'
136- | 'folder_delete'
137- // Tags
138- | 'tag_list'
139- | 'tag_create'
140- | 'tag_update'
141- | 'tag_delete'
142- | 'tag_assign'
143- | 'tag_remove'
144- // History
145- | 'history_timeline'
146- | 'history_search_fts'
147- | 'history_analytics'
148- | 'history_domain_stats'
149- | 'history_delete_entry'
150- | 'history_delete_range'
151- | 'history_clear_all'
152- | 'history_delete_domain'
153- // Favorites
154- | 'favorite_list'
155- | 'favorite_set_shortcut'
156- | 'favorite_get_by_shortcut'
157- | 'favorite_set_folder';
158-
159-export interface Message<T = unknown> {
160- type: MessageType;
161- requestId?: string;
162- payload?: T;
163-}
164-
165-export interface MessageResponse<T = unknown> {
166- success: boolean;
167- data?: T;
168- error?: string;
169-}
170-
171-// ─────────────────────────────────────────────────────────────────────────────
172-// Request/Response Payloads
173-// ─────────────────────────────────────────────────────────────────────────────
174-
175-// History
176-export interface HistoryTimelineRequest {
177- limit?: number;
178- offset?: number;
179-}
180-
181-export interface HistorySearchRequest {
182- query: string;
183- limit?: number;
184-}
185-
186-export interface HistoryDeleteRangeRequest {
187- range: HistoryCleanupRange;
188-}
189-
190-export interface HistoryDeleteDomainRequest {
191- domain: string;
192-}
193-
194-// Folders
195-export interface FolderCreateRequest {
196- name: string;
197- icon?: string;
198-}
199-
200-export interface FolderUpdateRequest {
201- id: number;
202- name: string;
203- icon?: string;
204-}
205-
206-// Tags
207-export interface TagCreateRequest {
208- name: string;
209- color?: string;
210-}
211-
212-export interface TagUpdateRequest {
213- id: number;
214- name?: string;
215- color?: string;
216-}
217-
218-export interface TagAssignRequest {
219- favorite_id: number;
220- tag_id: number;
221-}
222-
223-// Favorites
224-export interface FavoriteShortcutRequest {
225- favorite_id: number;
226- shortcut_key: number; // 1-9
227-}
228-
229-export interface FavoriteFolderRequest {
230- favorite_id: number;
231- folder_id: number | null;
232-}
D webui/src/pages/webrtc.ts +0 -165
@@ -1,165 +0,0 @@
1-import "../styles/app.css";
2-
3-const activeStreams: MediaStream[] = [];
4-
5-function now(): string {
6- return new Date().toLocaleTimeString();
7-}
8-
9-function appendLog(output: HTMLPreElement, message: string): void {
10- output.textContent = `${output.textContent}[${now()}] ${message}\n`;
11- output.scrollTop = output.scrollHeight;
12-}
13-
14-function setStatus(status: HTMLElement, value: string): void {
15- status.textContent = value;
16-}
17-
18-function stopAllStreams(video: HTMLVideoElement, output: HTMLPreElement, status: HTMLElement): void {
19- for (const stream of activeStreams.splice(0)) {
20- for (const track of stream.getTracks()) {
21- track.stop();
22- }
23- }
24-
25- video.pause();
26- video.srcObject = null;
27- setStatus(status, "idle");
28- appendLog(output, "Stopped all active tracks");
29-}
30-
31-async function probeDevices(output: HTMLPreElement): Promise<void> {
32- if (!("mediaDevices" in navigator) || typeof navigator.mediaDevices.enumerateDevices !== "function") {
33- appendLog(output, "enumerateDevices is unavailable");
34- return;
35- }
36-
37- try {
38- const devices = await navigator.mediaDevices.enumerateDevices();
39- const summary = devices
40- .map((device) => `${device.kind} ${device.label || "(label hidden until permission)"}`)
41- .join(" | ");
42-
43- appendLog(output, `Devices: ${summary || "none"}`);
44- } catch (err) {
45- appendLog(output, `enumerateDevices failed: ${String(err)}`);
46- appendLog(output, "Devices: none");
47- }
48-}
49-
50-async function requestUserMedia(video: HTMLVideoElement, output: HTMLPreElement, status: HTMLElement): Promise<void> {
51- if (!("mediaDevices" in navigator) || typeof navigator.mediaDevices.getUserMedia !== "function") {
52- appendLog(output, "getUserMedia is unavailable");
53- return;
54- }
55-
56- try {
57- setStatus(status, "requesting camera + microphone");
58- const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
59- activeStreams.push(stream);
60- video.srcObject = stream;
61- await video.play();
62- setStatus(status, "camera + microphone granted");
63- appendLog(output, "camera + microphone permission granted");
64- await probeDevices(output);
65- } catch (err) {
66- setStatus(status, "camera + microphone blocked");
67- appendLog(output, `camera + microphone request failed: ${String(err)}`);
68- }
69-}
70-
71-async function requestDisplayMedia(video: HTMLVideoElement, output: HTMLPreElement, status: HTMLElement): Promise<void> {
72- if (!("mediaDevices" in navigator) || typeof navigator.mediaDevices.getDisplayMedia !== "function") {
73- appendLog(output, "getDisplayMedia is unavailable");
74- return;
75- }
76-
77- try {
78- setStatus(status, "requesting screen capture");
79- const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
80- activeStreams.push(stream);
81- video.srcObject = stream;
82- await video.play();
83- setStatus(status, "screen capture granted");
84- appendLog(output, "screen capture permission granted");
85- } catch (err) {
86- setStatus(status, "screen capture blocked");
87- appendLog(output, `screen capture request failed: ${String(err)}`);
88- }
89-}
90-
91-function mount(): void {
92- document.title = "WebRTC Diagnostics";
93- document.body.innerHTML = `
94- <main class="page-shell" style="padding: 24px; max-width: 920px; margin: 0 auto; display: grid; gap: 16px;">
95- <header style="display: grid; gap: 8px;">
96- <h1 style="margin: 0; font-size: 1.8rem;">WebRTC Diagnostics</h1>
97- <p style="margin: 0; opacity: 0.85;">Use this page to validate camera, microphone, and screen permission behavior.</p>
98- </header>
99-
100- <section style="display: flex; flex-wrap: wrap; gap: 10px;">
101- <button id="btn-av" type="button">Request camera + microphone</button>
102- <button id="btn-screen" type="button">Request screen capture</button>
103- <button id="btn-stop" type="button">Stop all streams</button>
104- </section>
105-
106- <section style="display: grid; gap: 8px;">
107- <strong>Status: <span id="status">idle</span></strong>
108- <video id="preview" autoplay playsinline muted style="width: 100%; max-height: 320px; border-radius: 10px; background: #0c1522;"></video>
109- </section>
110-
111- <section style="display: grid; gap: 8px;">
112- <strong>Event Log</strong>
113- <pre id="output" style="margin: 0; padding: 12px; border-radius: 10px; background: #0d1727; color: #dbe8f4; min-height: 180px; max-height: 280px; overflow: auto;">[${now()}] Ready\n</pre>
114- </section>
115- </main>
116- `;
117-
118- const status = document.getElementById("status");
119- const preview = document.getElementById("preview");
120- const output = document.getElementById("output");
121- const btnAV = document.getElementById("btn-av");
122- const btnScreen = document.getElementById("btn-screen");
123- const btnStop = document.getElementById("btn-stop");
124-
125- if (!(status instanceof HTMLElement)) {
126- return;
127- }
128- if (!(preview instanceof HTMLVideoElement)) {
129- return;
130- }
131- if (!(output instanceof HTMLPreElement)) {
132- return;
133- }
134- if (!(btnAV instanceof HTMLButtonElement)) {
135- return;
136- }
137- if (!(btnScreen instanceof HTMLButtonElement)) {
138- return;
139- }
140- if (!(btnStop instanceof HTMLButtonElement)) {
141- return;
142- }
143-
144- btnAV.addEventListener("click", () => {
145- requestUserMedia(preview, output, status).catch((err: unknown) => {
146- appendLog(output, `unexpected user media error: ${String(err)}`);
147- });
148- });
149-
150- btnScreen.addEventListener("click", () => {
151- requestDisplayMedia(preview, output, status).catch((err: unknown) => {
152- appendLog(output, `unexpected display media error: ${String(err)}`);
153- });
154- });
155-
156- btnStop.addEventListener("click", () => {
157- stopAllStreams(preview, output, status);
158- });
159-
160- window.addEventListener("beforeunload", () => {
161- stopAllStreams(preview, output, status);
162- });
163-}
164-
165-mount();
D webui/src/styles/app.css +0 -332
@@ -1,332 +0,0 @@
1-@import "tailwindcss";
2-@import "tw-animate-css";
3-
4-/*
5- * Dumber Browser - Unified Theme CSS
6- * Following shadcn-svelte conventions
7- *
8- * Aesthetic: Industrial Terminal / Brutalist Minimal
9- * - Sharp corners (--radius: 0)
10- * - High contrast
11- * - Vibrant green primary accent
12- *
13- * Theme Flow:
14- * 1. TOML config defines appearance.light_palette / dark_palette
15- * 2. Go ThemeManager converts to CSS variables via palette.ToWebCSSVars()
16- * 3. ContentInjector injects <style data-dumber-theme-vars> into webviews
17- * 4. These CSS values below serve as fallbacks when running outside the browser
18- */
19-
20-@custom-variant dark (&:is(.dark *));
21-
22-/* ═══════════════════════════════════════════════════════════════
23- * LIGHT THEME (fallback values - Go backend overrides at runtime)
24- * ═══════════════════════════════════════════════════════════════ */
25-:root {
26- --radius: 0;
27-
28- /* Core surfaces */
29- --background: oklch(0.985 0.002 260);
30- --foreground: oklch(0.145 0.02 260);
31-
32- /* Card/Panel surfaces */
33- --card: oklch(0.965 0.004 260);
34- --card-foreground: oklch(0.145 0.02 260);
35-
36- /* Popover/Dropdown */
37- --popover: oklch(0.985 0.002 260);
38- --popover-foreground: oklch(0.145 0.02 260);
39-
40- /* Primary - Vibrant Green */
41- --primary: oklch(0.55 0.20 145);
42- --primary-foreground: oklch(0.985 0 0);
43-
44- /* Secondary - Muted surfaces */
45- --secondary: oklch(0.94 0.008 260);
46- --secondary-foreground: oklch(0.20 0.02 260);
47-
48- /* Muted - Subdued content */
49- --muted: oklch(0.94 0.008 260);
50- --muted-foreground: oklch(0.48 0.02 260);
51-
52- /* Accent - Cyan for info/highlights */
53- --accent: oklch(0.58 0.14 200);
54- --accent-foreground: oklch(0.145 0.02 260);
55-
56- /* Destructive - Red for danger */
57- --destructive: oklch(0.55 0.22 25);
58- --destructive-foreground: oklch(0.985 0 0);
59-
60- /* Borders & Inputs - solid colors for WebKitGTK compatibility */
61- --border: oklch(0.88 0.008 260);
62- --input: oklch(0.88 0.008 260);
63- --ring: var(--primary);
64-
65- /* Semantic colors - for status indicators */
66- --success: oklch(0.65 0.20 145);
67- --success-foreground: oklch(0.985 0 0);
68- --warning: oklch(0.75 0.18 85);
69- --warning-foreground: oklch(0.145 0.02 260);
70- --info: oklch(0.65 0.15 230);
71- --info-foreground: oklch(0.985 0 0);
72-
73- /* Chart colors */
74- --chart-1: oklch(0.58 0.18 145);
75- --chart-2: oklch(0.56 0.14 200);
76- --chart-3: oklch(0.66 0.15 70);
77- --chart-4: oklch(0.58 0.18 300);
78- --chart-5: oklch(0.60 0.18 25);
79-
80- /* Sidebar */
81- --sidebar: oklch(0.975 0.002 260);
82- --sidebar-foreground: oklch(0.145 0.02 260);
83- --sidebar-primary: oklch(0.55 0.20 145);
84- --sidebar-primary-foreground: oklch(0.985 0 0);
85- --sidebar-accent: oklch(0.94 0.008 260);
86- --sidebar-accent-foreground: oklch(0.20 0.02 260);
87- --sidebar-border: oklch(0.88 0.008 260);
88- --sidebar-ring: oklch(0.55 0.20 145);
89-}
90-
91-/* ═══════════════════════════════════════════════════════════════
92- * DARK THEME (fallback values - Go backend overrides at runtime)
93- * ═══════════════════════════════════════════════════════════════ */
94-.dark {
95- /* Core surfaces - true black for maximum contrast */
96- --background: oklch(0.09 0.005 260);
97- --foreground: oklch(0.97 0 0);
98-
99- /* Card/Panel surfaces - slightly elevated */
100- --card: oklch(0.14 0.008 260);
101- --card-foreground: oklch(0.97 0 0);
102-
103- /* Popover/Dropdown */
104- --popover: oklch(0.16 0.008 260);
105- --popover-foreground: oklch(0.97 0 0);
106-
107- /* Primary - Bright Green */
108- --primary: oklch(0.75 0.19 145);
109- --primary-foreground: oklch(0.12 0.02 145);
110-
111- /* Secondary - Elevated surfaces */
112- --secondary: oklch(0.20 0.008 260);
113- --secondary-foreground: oklch(0.97 0 0);
114-
115- /* Muted - More visible subdued content */
116- --muted: oklch(0.22 0.008 260);
117- --muted-foreground: oklch(0.70 0.01 260);
118-
119- /* Accent - Cyan for info/highlights */
120- --accent: oklch(0.68 0.14 200);
121- --accent-foreground: oklch(0.97 0 0);
122-
123- /* Destructive - Red for danger */
124- --destructive: oklch(0.65 0.20 25);
125- --destructive-foreground: oklch(0.97 0 0);
126-
127- /* Borders & Inputs - solid colors for WebKitGTK compatibility */
128- --border: oklch(0.35 0.01 260);
129- --input: oklch(0.35 0.01 260);
130- --ring: var(--primary);
131-
132- /* Semantic colors - brighter for dark bg */
133- --success: oklch(0.72 0.18 145);
134- --success-foreground: oklch(0.12 0.02 145);
135- --warning: oklch(0.80 0.16 85);
136- --warning-foreground: oklch(0.12 0.02 85);
137- --info: oklch(0.70 0.14 230);
138- --info-foreground: oklch(0.12 0.02 230);
139-
140- /* Chart colors - brighter for dark bg */
141- --chart-1: oklch(0.72 0.18 145);
142- --chart-2: oklch(0.68 0.14 200);
143- --chart-3: oklch(0.76 0.16 70);
144- --chart-4: oklch(0.66 0.18 300);
145- --chart-5: oklch(0.68 0.18 25);
146-
147- /* Sidebar */
148- --sidebar: oklch(0.12 0.008 260);
149- --sidebar-foreground: oklch(0.97 0 0);
150- --sidebar-primary: oklch(0.75 0.19 145);
151- --sidebar-primary-foreground: oklch(0.12 0.02 145);
152- --sidebar-accent: oklch(0.20 0.008 260);
153- --sidebar-accent-foreground: oklch(0.97 0 0);
154- --sidebar-border: oklch(0.35 0.01 260);
155- --sidebar-ring: oklch(0.75 0.19 145);
156-}
157-
158-/* ═══════════════════════════════════════════════════════════════
159- * TAILWIND THEME BRIDGE
160- * Maps design tokens to Tailwind's color system
161- * ═══════════════════════════════════════════════════════════════ */
162-@theme inline {
163- --radius-sm: calc(var(--radius) - 4px);
164- --radius-md: calc(var(--radius) - 2px);
165- --radius-lg: var(--radius);
166- --radius-xl: calc(var(--radius) + 4px);
167-
168- --color-background: var(--background);
169- --color-foreground: var(--foreground);
170- --color-card: var(--card);
171- --color-card-foreground: var(--card-foreground);
172- --color-popover: var(--popover);
173- --color-popover-foreground: var(--popover-foreground);
174- --color-primary: var(--primary);
175- --color-primary-foreground: var(--primary-foreground);
176- --color-secondary: var(--secondary);
177- --color-secondary-foreground: var(--secondary-foreground);
178- --color-muted: var(--muted);
179- --color-muted-foreground: var(--muted-foreground);
180- --color-accent: var(--accent);
181- --color-accent-foreground: var(--accent-foreground);
182- --color-destructive: var(--destructive);
183- --color-destructive-foreground: var(--destructive-foreground);
184- --color-success: var(--success);
185- --color-success-foreground: var(--success-foreground);
186- --color-warning: var(--warning);
187- --color-warning-foreground: var(--warning-foreground);
188- --color-info: var(--info);
189- --color-info-foreground: var(--info-foreground);
190- --color-border: var(--border);
191- --color-input: var(--input);
192- --color-ring: var(--ring);
193- --color-chart-1: var(--chart-1);
194- --color-chart-2: var(--chart-2);
195- --color-chart-3: var(--chart-3);
196- --color-chart-4: var(--chart-4);
197- --color-chart-5: var(--chart-5);
198- --color-sidebar: var(--sidebar);
199- --color-sidebar-foreground: var(--sidebar-foreground);
200- --color-sidebar-primary: var(--sidebar-primary);
201- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
202- --color-sidebar-accent: var(--sidebar-accent);
203- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
204- --color-sidebar-border: var(--sidebar-border);
205- --color-sidebar-ring: var(--sidebar-ring);
206-}
207-
208-/* ═══════════════════════════════════════════════════════════════
209- * BASE LAYER
210- * Default styles applied to all elements
211- * ═══════════════════════════════════════════════════════════════ */
212-@layer base {
213- * {
214- @apply border-border outline-ring/50;
215- }
216- body {
217- @apply bg-background text-foreground;
218- transition:
219- background-color 200ms ease,
220- color 200ms ease;
221- }
222-}
223-
224-/* ═══════════════════════════════════════════════════════════════
225- * BROWSER UI BASE
226- * ═══════════════════════════════════════════════════════════════ */
227-
228-.browser-ui {
229- background-color: var(--background);
230- color: var(--foreground);
231- border-color: var(--border);
232- transition: colors 250ms;
233-}
234-
235-/* ═══════════════════════════════════════════════════════════════
236- * ANIMATIONS
237- * ═══════════════════════════════════════════════════════════════ */
238-
239-.slide-in-top {
240- animation: slide-in-top 0.25s ease-out;
241-}
242-
243-.slide-out-top {
244- animation: slide-out-top 0.25s ease-in;
245-}
246-
247-.fade-in {
248- animation: fade-in 0.15s ease-out;
249-}
250-
251-.fade-out {
252- animation: fade-out 0.15s ease-in;
253-}
254-
255-@keyframes slide-in-top {
256- from {
257- transform: translateY(-100%);
258- opacity: 0;
259- }
260- to {
261- transform: translateY(0);
262- opacity: 1;
263- }
264-}
265-
266-@keyframes slide-out-top {
267- from {
268- transform: translateY(0);
269- opacity: 1;
270- }
271- to {
272- transform: translateY(-100%);
273- opacity: 0;
274- }
275-}
276-
277-@keyframes fade-in {
278- from {
279- opacity: 0;
280- }
281- to {
282- opacity: 1;
283- }
284-}
285-
286-@keyframes fade-out {
287- from {
288- opacity: 1;
289- }
290- to {
291- opacity: 0;
292- }
293-}
294-
295-/* ═══════════════════════════════════════════════════════════════
296- * FIND HIGHLIGHTING
297- * Styles for in-page search highlights
298- * ═══════════════════════════════════════════════════════════════ */
299-
300-.find-highlight {
301- background-color: rgb(255 235 59); /* yellow-400 */
302- color: rgb(0 0 0); /* black */
303- padding: 0 0.125rem; /* px-0.5 */
304- border-radius: 0.125rem; /* rounded-sm */
305- box-shadow: 0 0 0 1px rgb(200 185 0) inset;
306-}
307-
308-.find-active {
309- background-color: rgb(255 152 0) !important; /* orange-500 */
310- color: rgb(0 0 0); /* black */
311- box-shadow: 0 0 0 1px rgb(179 107 0) inset !important;
312-}
313-
314-/* ═══════════════════════════════════════════════════════════════
315- * COMPONENT ISOLATION
316- * For injecting browser UI into external pages
317- * ═══════════════════════════════════════════════════════════════ */
318-
319-.browser-component-root {
320- /* Reset any inherited styles from the website */
321- all: initial;
322- /* Apply our design system */
323- font-family:
324- Fira Sans,
325- system-ui,
326- -apple-system,
327- sans-serif;
328- box-sizing: border-box;
329- * {
330- box-sizing: border-box;
331- }
332-}
D webui/src/types/dumber.d.ts +0 -156
@@ -1,156 +0,0 @@
1-/**
2- * Global type declarations for Dumber browser WebKit bridge
3- */
4-
5-// ═══════════════════════════════════════════════════════════════════════════════
6-// WEBKIT BRIDGE TYPES
7-// ═══════════════════════════════════════════════════════════════════════════════
8-
9-interface DumberBridge {
10- postMessage: (message: unknown) => void;
11-}
12-
13-interface DumberWebKit {
14- messageHandlers?: {
15- dumber?: DumberBridge;
16- };
17-}
18-
19-// ═══════════════════════════════════════════════════════════════════════════════
20-// VIEW TRANSITION API (not yet in TypeScript DOM lib)
21-// ═══════════════════════════════════════════════════════════════════════════════
22-
23-interface ViewTransition {
24- finished: Promise<void>;
25- ready: Promise<void>;
26- updateCallbackDone: Promise<void>;
27-}
28-
29-// ═══════════════════════════════════════════════════════════════════════════════
30-// THEME TYPES
31-// ═══════════════════════════════════════════════════════════════════════════════
32-
33-type ThemeMode = 'light' | 'dark';
34-
35-interface ColorSchemeManager {
36- setUserPreference?: (theme: ThemeMode) => void;
37-}
38-
39-// ═══════════════════════════════════════════════════════════════════════════════
40-// HOMEPAGE MESSAGING TYPES
41-// ═══════════════════════════════════════════════════════════════════════════════
42-
43-/** Response from Go backend for homepage requests */
44-interface HomepageResponse {
45- requestId: string;
46- success: boolean;
47- data?: unknown;
48- error?: string;
49-}
50-
51-/** Error response format (can be string for legacy or object) */
52-type HomepageErrorResponse = string | { requestId?: string; error?: string };
53-
54-/** Callback type for homepage responses */
55-type HomepageResponseCallback = (response: HomepageResponse) => void;
56-
57-/** Callback type for legacy data responses (can receive raw data or Response object) */
58-type HomepageLegacyCallback = (dataOrResponse: unknown) => void;
59-
60-/** Callback type for error responses */
61-type HomepageErrorCallback = (errorOrResponse: HomepageErrorResponse) => void;
62-
63-// ═══════════════════════════════════════════════════════════════════════════════
64-// GLOBAL DECLARATIONS
65-// ═══════════════════════════════════════════════════════════════════════════════
66-
67-declare global {
68- // Re-export bridge type for use in other modules
69- type DumberBridge = {
70- postMessage: (message: unknown) => void;
71- };
72- interface Window {
73- // ─────────────────────────────────────────────────────────────────────────
74- // Core WebKit Bridge
75- // ─────────────────────────────────────────────────────────────────────────
76- /** WebView ID assigned by the browser backend */
77- __dumber_webview_id?: number;
78- /** WebKit message handlers for native bridge */
79- webkit?: DumberWebKit;
80- /** CEF bridge shim injected on internal pages */
81- dumber?: DumberBridge;
82-
83- // ─────────────────────────────────────────────────────────────────────────
84- // Config Page Callbacks
85- // ─────────────────────────────────────────────────────────────────────────
86- /** Callback invoked when config save succeeds */
87- __dumber_config_saved?: (resp?: unknown) => void;
88- /** Callback invoked when config save fails */
89- __dumber_config_error?: (msg: unknown) => void;
90-
91- // ─────────────────────────────────────────────────────────────────────────
92- // Keybindings Callbacks
93- // ─────────────────────────────────────────────────────────────────────────
94- /** Callback invoked when keybindings are loaded */
95- __dumber_keybindings_loaded?: (resp?: unknown) => void;
96- /** Callback invoked when keybindings load fails */
97- __dumber_keybindings_error?: (msg: unknown) => void;
98- /** Callback invoked when a keybinding is set */
99- __dumber_keybinding_set?: (resp?: unknown) => void;
100- /** Callback invoked when keybinding set fails */
101- __dumber_keybinding_set_error?: (msg: unknown) => void;
102- /** Callback invoked when a keybinding is reset */
103- __dumber_keybinding_reset?: (resp?: unknown) => void;
104- /** Callback invoked when keybinding reset fails */
105- __dumber_keybinding_reset_error?: (msg: unknown) => void;
106- /** Callback invoked when all keybindings are reset */
107- __dumber_keybindings_reset_all?: (resp?: unknown) => void;
108- /** Callback invoked when reset all keybindings fails */
109- __dumber_keybindings_reset_all_error?: (msg: unknown) => void;
110-
111- // ─────────────────────────────────────────────────────────────────────────
112- // Homepage Callbacks
113- // ─────────────────────────────────────────────────────────────────────────
114- /** Generic response handler for homepage requests */
115- __dumber_homepage_response?: HomepageResponseCallback;
116- /** History timeline data callback */
117- __dumber_history_timeline?: HomepageLegacyCallback;
118- /** Folders data callback */
119- __dumber_folders?: HomepageLegacyCallback;
120- /** Tags data callback */
121- __dumber_tags?: HomepageLegacyCallback;
122- /** Favorites data callback */
123- __dumber_favorites?: HomepageLegacyCallback;
124- /** Analytics data callback */
125- __dumber_analytics?: HomepageLegacyCallback;
126- /** Domain stats data callback */
127- __dumber_domain_stats?: HomepageLegacyCallback;
128- /** History search results callback */
129- __dumber_history_search_results?: HomepageLegacyCallback;
130- /** History deleted callback */
131- __dumber_history_deleted?: HomepageLegacyCallback;
132- /** History cleared callback */
133- __dumber_history_cleared?: HomepageLegacyCallback;
134- /** Error callback for homepage requests */
135- __dumber_error?: HomepageErrorCallback;
136-
137- // ─────────────────────────────────────────────────────────────────────────
138- // Theme Management
139- // ─────────────────────────────────────────────────────────────────────────
140- /** Color scheme manager for theme switching */
141- __dumber_color_scheme_manager?: ColorSchemeManager;
142- /** Legacy theme setter */
143- __dumber_setTheme?: (theme: ThemeMode) => void;
144- /** Resolved prefers-dark state injected by the backend */
145- __dumber_cef_prefers_dark?: boolean;
146- /** Resolved prefers-dark state injected by the backend */
147- __dumber_gtk_prefers_dark?: boolean;
148- }
149-
150- interface Document {
151- /** View Transition API */
152- startViewTransition?: (callback: () => void | Promise<void>) => ViewTransition;
153- }
154-}
155-
156-export {};
D webui/src/types/frappe-charts.d.ts +0 -47
@@ -1,47 +0,0 @@
1-declare module 'frappe-charts' {
2- export interface ChartData {
3- labels: string[];
4- datasets: Array<{
5- name?: string;
6- values: number[];
7- chartType?: string;
8- }>;
9- }
10-
11- export interface ChartOptions {
12- data: ChartData;
13- type: 'line' | 'bar' | 'axis-mixed' | 'pie' | 'percentage' | 'heatmap';
14- height?: number;
15- colors?: string[];
16- axisOptions?: {
17- xAxisMode?: 'tick' | 'span';
18- xIsSeries?: boolean;
19- yAxisMode?: 'tick' | 'span';
20- };
21- barOptions?: {
22- spaceRatio?: number;
23- stacked?: boolean;
24- };
25- lineOptions?: {
26- regionFill?: number;
27- hideDots?: number;
28- hideLine?: number;
29- heatline?: number;
30- dotSize?: number;
31- };
32- tooltipOptions?: {
33- formatTooltipX?: (d: string) => string;
34- formatTooltipY?: (d: number) => string;
35- };
36- isNavigable?: boolean;
37- valuesOverPoints?: boolean;
38- maxSlices?: number;
39- }
40-
41- export class Chart {
42- constructor(container: HTMLElement, options: ChartOptions);
43- update(data: ChartData): void;
44- destroy(): void;
45- export(): void;
46- }
47-}
D webui/src/types/generated.ts +0 -21
@@ -1,21 +0,0 @@
1-export interface HistoryEntry {
2- id: number;
3- url: string;
4- title: string | null;
5- favicon_url: string | null;
6- visit_count: number;
7- last_visited: string;
8- created_at: string;
9-}
10-
11-export type History = HistoryEntry;
12-
13-export interface SearchShortcut {
14- url: string;
15- description: string;
16-}
17-
18-export interface Shortcut {
19- url_template: string;
20- description?: string;
21-}
D webui/src/utils/dom.ts +0 -203
@@ -1,203 +0,0 @@
1-import type { History, Shortcut } from "../types/generated.js";
2-
3-export class DOMRenderer {
4- private historyListElement: HTMLElement | null = null;
5- private shortcutsElement: HTMLElement | null = null;
6-
7- constructor() {
8- this.historyListElement = document.getElementById("historyList");
9- this.shortcutsElement = document.getElementById("shortcuts");
10- }
11-
12- displayHistory(historyEntries: History[]): void {
13- if (!this.historyListElement) return;
14-
15- if (!historyEntries || historyEntries.length === 0) {
16- this.historyListElement.innerHTML = `
17- <div class="empty-state">
18- <h3>No history yet</h3>
19- <p>Use CLI commands to browse websites</p>
20- </div>
21- `;
22- return;
23- }
24-
25- this.historyListElement.innerHTML = "";
26- historyEntries.forEach((item) => {
27- const historyItem = this.createHistoryItem(item);
28- this.historyListElement!.appendChild(historyItem);
29- });
30- }
31-
32- displayShortcuts(shortcutsData: Record<string, Shortcut>): void {
33- if (!this.shortcutsElement) return;
34-
35- if (!shortcutsData || Object.keys(shortcutsData).length === 0) {
36- this.shortcutsElement.innerHTML = `
37- <div class="empty-state">
38- <h3>No shortcuts configured</h3>
39- <p>Add shortcuts to your config file</p>
40- </div>
41- `;
42- return;
43- }
44-
45- this.shortcutsElement.innerHTML = "";
46- Object.entries(shortcutsData).forEach(([key, shortcut]) => {
47- const shortcutEl = this.createShortcutElement(key, shortcut);
48- this.shortcutsElement!.appendChild(shortcutEl);
49- });
50- }
51-
52- showLoading(container: "history" | "shortcuts"): void {
53- const element =
54- container === "history" ? this.historyListElement : this.shortcutsElement;
55- if (element) {
56- element.innerHTML = '<div class="loading">Loading...</div>';
57- }
58- }
59-
60- showError(container: "history" | "shortcuts", message: string): void {
61- const element =
62- container === "history" ? this.historyListElement : this.shortcutsElement;
63- if (element) {
64- element.innerHTML = `
65- <div class="empty-state">
66- <h3>Error</h3>
67- <p>${message}</p>
68- </div>
69- `;
70- }
71- }
72-
73- private createHistoryItem(item: History): HTMLElement {
74- const historyItem = document.createElement("div");
75- historyItem.className = "history-item";
76- const parsed = this.safeParseURL(item.url);
77- const title =
78- item.title && item.title.trim() !== ""
79- ? item.title
80- : parsed.host || "Untitled";
81-
82- const line = document.createElement("div");
83- line.className = "history-line";
84-
85- // Favicon chip (wrapper ensures perfect circle sizing)
86- const iconWrap = document.createElement("div");
87- iconWrap.className = "history-favicon-chip";
88- const icon = document.createElement("img");
89- icon.className = "history-favicon-img";
90- icon.width = 16;
91- icon.height = 16;
92- icon.loading = "lazy";
93- icon.referrerPolicy = "no-referrer";
94- icon.src = this.buildFaviconURL(item.url);
95- icon.addEventListener("error", () => {
96- iconWrap.style.display = "none";
97- });
98- iconWrap.appendChild(icon);
99-
100- const titleEl = document.createElement("span");
101- titleEl.className = "history-title";
102- titleEl.textContent = title;
103-
104- const sep1 = document.createElement("span");
105- sep1.className = "history-sep";
106- sep1.textContent = " - ";
107-
108- const domainEl = document.createElement("span");
109- domainEl.className = "history-domain";
110- domainEl.textContent = parsed.host || "";
111-
112- const sep2 = document.createElement("span");
113- sep2.className = "history-sep";
114- sep2.textContent = " - ";
115-
116- const urlEl = document.createElement("span");
117- urlEl.className = "history-url";
118- urlEl.textContent = item.url;
119-
120- line.appendChild(iconWrap);
121- line.appendChild(titleEl);
122- line.appendChild(sep1);
123- line.appendChild(domainEl);
124- line.appendChild(sep2);
125- line.appendChild(urlEl);
126-
127- historyItem.appendChild(line);
128-
129- historyItem.addEventListener("click", () => {
130- this.navigateToUrl(item.url);
131- });
132-
133- return historyItem;
134- }
135-
136- private createShortcutElement(key: string, shortcut: Shortcut): HTMLElement {
137- const shortcutEl = document.createElement("div");
138- shortcutEl.className = "shortcut";
139- shortcutEl.innerHTML = `
140- <div class="shortcut-key">${this.escapeHtml(key)}:</div>
141- <div class="shortcut-desc">${this.escapeHtml(shortcut.description || "No description")}</div>
142- `;
143-
144- shortcutEl.addEventListener("click", () => {
145- // Navigate to the base URL of the shortcut (remove %s template parameter)
146- const baseUrl = this.extractBaseUrl(shortcut.url_template);
147- this.navigateToUrl(baseUrl);
148- });
149-
150- return shortcutEl;
151- }
152-
153- private async navigateToUrl(url: string): Promise<void> {
154- console.log("Navigating to:", url);
155- window.location.href = url;
156- }
157-
158- private extractBaseUrl(templateUrl: string): string {
159- try {
160- // Support both %s and {query} placeholders
161- const candidate = templateUrl.replace(/%s|\{query\}/g, "");
162- const url = new URL(candidate);
163- // Remove empty query params left after placeholder removal
164- const params = new URLSearchParams(url.search);
165- for (const key of Array.from(params.keys())) {
166- const v = params.get(key);
167- if (v === null || v === "") {
168- params.delete(key);
169- }
170- }
171- url.search = params.toString();
172- return url.toString();
173- } catch (error) {
174- console.error("Failed to parse shortcut URL:", templateUrl, error);
175- throw error;
176- }
177- }
178-
179- private escapeHtml(text: string): string {
180- const div = document.createElement("div");
181- div.textContent = text;
182- return div.innerHTML;
183- }
184-
185- private safeParseURL(raw: string): { host: string } {
186- try {
187- const u = new URL(raw);
188- return { host: u.host };
189- } catch {
190- return { host: "" };
191- }
192- }
193-
194- private buildFaviconURL(raw: string): string {
195- try {
196- const u = new URL(raw);
197- const scheme = u.protocol && u.protocol !== ":" ? u.protocol : "https:";
198- return `${scheme}//${u.host}/favicon.ico`;
199- } catch {
200- return "";
201- }
202- }
203-}
D webui/svelte.config.js +0 -10
@@ -1,10 +0,0 @@
1-import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
2-
3-export default {
4- // Enable rune mode for Svelte 5
5- compilerOptions: {
6- runes: true,
7- },
8- // Use Vite's preprocessor for TypeScript and PostCSS
9- preprocess: vitePreprocess(),
10-};
D webui/tailwind.config.js +0 -66
@@ -1,66 +0,0 @@
1-/** @type {import('tailwindcss').Config} */
2-export default {
3- content: ["./src/**/*.{html,js,ts,svelte}"],
4- theme: {
5- extend: {
6- // Font families for Industrial Terminal aesthetic
7- fontFamily: {
8- mono: ['"JetBrains Mono NF"', "ui-monospace", "SFMono-Regular", "monospace"],
9- sans: ["Inter", "system-ui", "-apple-system", "sans-serif"],
10- },
11- // Custom design system colors
12- colors: {
13- border: "var(--border)",
14- input: "var(--input)",
15- ring: "var(--ring)",
16- background: "var(--background)",
17- foreground: "var(--foreground)",
18- primary: "var(--primary)",
19- "primary-foreground": "var(--primary-foreground)",
20- secondary: "var(--secondary)",
21- "secondary-foreground": "var(--secondary-foreground)",
22- destructive: "var(--destructive)",
23- "destructive-foreground": "var(--destructive-foreground)",
24- muted: "var(--muted)",
25- "muted-foreground": "var(--muted-foreground)",
26- accent: "var(--accent)",
27- "accent-foreground": "var(--accent-foreground)",
28- popover: "var(--popover)",
29- "popover-foreground": "var(--popover-foreground)",
30- card: "var(--card)",
31- "card-foreground": "var(--card-foreground)",
32- // Browser-specific colors that work well in both themes
33- browser: {
34- bg: "rgb(var(--browser-bg) / <alpha-value>)",
35- surface: "rgb(var(--browser-surface) / <alpha-value>)",
36- text: "rgb(var(--browser-text) / <alpha-value>)",
37- muted: "rgb(var(--browser-muted) / <alpha-value>)",
38- accent: "rgb(var(--browser-accent) / <alpha-value>)",
39- border: "rgb(var(--browser-border) / <alpha-value>)",
40- },
41- },
42- // Animation speeds optimized for browser UI
43- transitionDuration: {
44- 150: "150ms", // Fast interactions
45- 250: "250ms", // UI feedback
46- 400: "400ms", // Smooth transitions
47- },
48- // Typography scale for browser UI
49- fontSize: {
50- xs: ["0.75rem", "1rem"],
51- sm: ["0.875rem", "1.25rem"],
52- base: ["1rem", "1.5rem"],
53- lg: ["1.125rem", "1.75rem"],
54- },
55- // Spacing for compact UI elements
56- spacing: {
57- 18: "4.5rem",
58- 22: "5.5rem",
59- },
60- },
61- },
62- plugins: [],
63- // Manual dark mode control (not system preference)
64- // This allows GTK to control theme via CSS class
65- darkMode: "class",
66-};
D webui/tsconfig.json +0 -27
@@ -1,27 +0,0 @@
1-{
2- "compilerOptions": {
3- "target": "ES2022",
4- "module": "ESNext",
5- "moduleResolution": "bundler",
6- "lib": ["ES2022", "DOM", "DOM.Iterable"],
7- "allowImportingTsExtensions": true,
8- "moduleDetection": "force",
9- "noEmit": true,
10- "strict": true,
11- "noUnusedLocals": true,
12- "noUnusedParameters": true,
13- "noFallthroughCasesInSwitch": true,
14- "noUncheckedIndexedAccess": true,
15- "skipLibCheck": true,
16- "esModuleInterop": true,
17- "allowSyntheticDefaultImports": true,
18- "forceConsistentCasingInFileNames": true,
19- "baseUrl": ".",
20- "paths": {
21- "$lib/*": ["src/lib/*"],
22- "$components/*": ["src/components/*"]
23- }
24- },
25- "include": ["src/**/*", "vite.config.ts"],
26- "exclude": ["node_modules", "dist"]
27-}
D webui/vite-plugin-pages.ts +0 -153
@@ -1,153 +0,0 @@
1-import type { Plugin } from "vite";
2-import { writeFileSync, readFileSync, existsSync, copyFileSync } from "fs";
3-import { resolve } from "path";
4-
5-interface PageConfig {
6- /** Page name (used for filename and script name) */
7- name: string;
8- /** Page title */
9- title: string;
10- /** Script filename (e.g., 'homepage.min.js') */
11- script: string;
12- /** Optional CSS filename */
13- css?: string;
14- /** Target filename (defaults to name + '.html') */
15- filename?: string;
16-}
17-
18-// Bootstrap a unified window.dumber bridge before the page bundle runs.
19-// On WebKit internal pages we forward to the native message handler.
20-// On CEF internal pages we fall back to the fetch-based /api/message bridge.
21-function getBridgeBootstrap(): string {
22- return `<script>
23- (function() {
24- if (window.dumber && typeof window.dumber.postMessage === "function") {
25- return;
26- }
27-
28- window.dumber = {
29- postMessage: async function(msg) {
30- const nativeBridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber;
31- if (nativeBridge && typeof nativeBridge.postMessage === "function") {
32- return nativeBridge.postMessage(msg);
33- }
34-
35- try {
36- const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(msg))));
37- const response = await fetch("/api/message", {
38- method: "POST",
39- headers: {
40- "Content-Type": "application/json",
41- "X-Dumber-Body": encoded
42- }
43- });
44- const payload = await response.json();
45- if (payload && payload._callback && typeof window[payload._callback] === "function") {
46- window[payload._callback](payload.data);
47- }
48- return payload;
49- } catch (err) {
50- console.error("[dumber:bridge] fetch bridge error:", err);
51- return { error: err instanceof Error ? err.message : String(err) };
52- }
53- }
54- };
55-
56- window.dispatchEvent(new CustomEvent("dumber:bridge-ready"));
57- })();
58- </script>`;
59-}
60-
61-// Load the favicon SVG from assets
62-function getFaviconSVG(): string {
63- const logoPath = resolve(__dirname, "..", "assets", "logo.svg");
64- return readFileSync(logoPath, "utf-8");
65-}
66-
67-// Generate HTML content for a page
68-function generatePageHTML(config: PageConfig): string {
69- const cssLink = config.css
70- ? `<link rel="stylesheet" href="./${config.css}">`
71- : "";
72- const bridgeBootstrap = getBridgeBootstrap();
73-
74- return `<!DOCTYPE html>
75-<html lang="en">
76-<head>
77- <meta charset="UTF-8">
78- <meta name="viewport" content="width=device-width, initial-scale=1.0">
79- <title>${config.title}</title>
80- <link rel="icon" type="image/png" href="/favicon.png">
81- ${bridgeBootstrap}
82- ${cssLink}
83-</head>
84-<body>
85- <script src="./${config.script}"></script>
86-</body>
87-</html>`;
88-}
89-
90-/**
91- * Vite plugin to generate dumb:// protocol pages
92- * Supports multiple pages like homepage, config, about, etc.
93- */
94-export function pageGenerator(pages: PageConfig[]): Plugin {
95- return {
96- name: "dumb-page-generator",
97- writeBundle() {
98- const assetsDir = resolve(__dirname, "..", "assets", "webui");
99-
100- try {
101- // Generate HTML files for each page
102- for (const page of pages) {
103- const htmlContent = generatePageHTML(page);
104- const filename = page.filename || `${page.name}.html`;
105- writeFileSync(resolve(assetsDir, filename), htmlContent);
106- console.log(`✓ Generated ${filename} for dumb://${page.name}`);
107- }
108-
109- // Generate favicon files for scheme handler lookups
110- const faviconContent = getFaviconSVG();
111- writeFileSync(resolve(assetsDir, "favicon.svg"), faviconContent);
112- console.log("✓ Generated favicon.svg");
113-
114- // Copy pre-generated PNG and ICO favicons if they exist
115- const sourceDir = resolve(__dirname, "..", "assets", "webui");
116- try {
117- const pngPath = resolve(sourceDir, "favicon.png");
118- const icoPath = resolve(sourceDir, "favicon.ico");
119-
120- if (existsSync(pngPath)) {
121- copyFileSync(pngPath, resolve(assetsDir, "favicon.png"));
122- console.log("✓ Copied favicon.png");
123- }
124-
125- if (existsSync(icoPath)) {
126- copyFileSync(icoPath, resolve(assetsDir, "favicon.ico"));
127- console.log("✓ Copied favicon.ico");
128- }
129- } catch (err) {
130- console.warn("⚠ Could not copy favicon PNG/ICO files:", err);
131- }
132- } catch (error) {
133- console.error("✗ Failed to generate page files:", error);
134- throw error;
135- }
136- },
137- };
138-}
139-
140-/**
141- * Convenience function for homepage generation
142- * @deprecated Use pageGenerator with homepage config instead
143- */
144-export function homepageGenerator(): Plugin {
145- return pageGenerator([
146- {
147- name: "homepage",
148- title: "Dumber Browser",
149- script: "homepage.min.js",
150- filename: "index.html", // Special case: homepage uses index.html
151- },
152- ]);
153-}
D webui/vite.config.ts +0 -123
@@ -1,123 +0,0 @@
1-import { defineConfig } from "vite";
2-import { svelte } from "@sveltejs/vite-plugin-svelte";
3-import { resolve } from "path";
4-import { pageGenerator } from "./vite-plugin-pages";
5-import { copyFileSync, existsSync, mkdirSync } from "fs";
6-
7-// Page configurations for dumb:// protocol pages
8-const pages = {
9- homepage: {
10- entry: "src/pages/homepage.ts",
11- output: "homepage.min.js",
12- global: "DumberHomepage",
13- html: {
14- name: "homepage",
15- title: "Dumber Browser",
16- script: "homepage.min.js",
17- css: "style.css",
18- filename: "index.html",
19- },
20- },
21- error: {
22- entry: "src/pages/error.ts",
23- output: "error.min.js",
24- global: "DumberError",
25- html: {
26- name: "error",
27- title: "Error",
28- script: "error.min.js",
29- css: "style.css",
30- filename: "error.html",
31- },
32- },
33- config: {
34- entry: "src/pages/config.ts",
35- output: "config.min.js",
36- global: "DumberConfig",
37- html: {
38- name: "config",
39- title: "Settings",
40- script: "config.min.js",
41- css: "style.css",
42- filename: "config.html",
43- },
44- },
45- webrtc: {
46- entry: "src/pages/webrtc.ts",
47- output: "webrtc.min.js",
48- global: "DumberWebRTC",
49- html: {
50- name: "webrtc",
51- title: "WebRTC Diagnostics",
52- script: "webrtc.min.js",
53- css: "style.css",
54- filename: "webrtc.html",
55- },
56- },
57-} as const;
58-
59-// Get page from VITE_PAGE env var, default to building all
60-const targetPage = process.env.VITE_PAGE as keyof typeof pages | undefined;
61-
62-// Copy public assets to output directory
63-function copyPublicAssets() {
64- const outDir = resolve(__dirname, "../assets/webui");
65- const publicDir = resolve(__dirname, "public");
66- if (!existsSync(outDir)) {
67- mkdirSync(outDir, { recursive: true });
68- }
69- // Copy favicon.png for dumb:// URL favicons
70- const faviconSrc = resolve(publicDir, "favicon.png");
71- if (existsSync(faviconSrc)) {
72- copyFileSync(faviconSrc, resolve(outDir, "favicon.png"));
73- }
74-}
75-
76-// Build single page config
77-function buildPageConfig(pageName: keyof typeof pages) {
78- const page = pages[pageName];
79- return defineConfig({
80- plugins: [
81- svelte({
82- emitCss: false, // Inline component CSS for shadow DOM compatibility
83- }),
84- pageGenerator([page.html]),
85- {
86- name: "copy-public-assets",
87- closeBundle() {
88- copyPublicAssets();
89- },
90- },
91- ],
92- build: {
93- rollupOptions: {
94- input: resolve(__dirname, page.entry),
95- output: {
96- dir: "../assets/webui",
97- entryFileNames: page.output,
98- chunkFileNames: "[name].js",
99- assetFileNames: "[name].[ext]",
100- manualChunks: undefined,
101- format: "iife",
102- name: page.global,
103- },
104- },
105- emptyOutDir: false,
106- target: ["es2020", "chrome91", "firefox90"],
107- minify: true,
108- sourcemap: false,
109- cssCodeSplit: false,
110- assetsInlineLimit: 4096,
111- },
112- resolve: {
113- alias: {
114- $lib: resolve(__dirname, "src/lib"),
115- $components: resolve(__dirname, "src/components"),
116- },
117- },
118- });
119-}
120-
121-// Export config for specified page or default to homepage
122-// Use VITE_PAGE=homepage or VITE_PAGE=blocked to select
123-export default buildPageConfig(targetPage || "homepage");

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close