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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
129 files · +93 -16767
M .github/dependabot.yml +0 -9
| @@ -9,15 +9,6 @@ updates: | |||
| 9 | 9 | patterns: | |
| 10 | 10 | - "*" | |
| 11 | 11 | ||
| 12 | - | - package-ecosystem: npm | |
| 13 | - | directory: /webui | |
| 14 | - | schedule: | |
| 15 | - | interval: weekly | |
| 16 | - | groups: | |
| 17 | - | npm-dependencies: | |
| 18 | - | patterns: | |
| 19 | - | - "*" | |
| 20 | - | ||
| 21 | 12 | - package-ecosystem: github-actions | |
| 22 | 13 | directory: / | |
| 23 | 14 | schedule: | |
M .github/workflows/aur.yml +1 -1
| @@ -51,7 +51,7 @@ jobs: | |||
| 51 | 51 | "arch=('x86_64')" \ | |
| 52 | 52 | 'url="https://github.com/bnema/dumber"' \ | |
| 53 | 53 | "license=('MIT')" \ | |
| 54 | - | "makedepends=('go>=1.25' 'npm' 'git')" \ | |
| 54 | + | "makedepends=('go>=1.25' 'git')" \ | |
| 55 | 55 | "depends=('gtk4' 'webkitgtk-6.0')" \ | |
| 56 | 56 | 'optdepends=(' \ | |
| 57 | 57 | " 'gst-plugins-base: Base media codecs'" \ | |
M .github/workflows/ci.yml +6 -22
| @@ -7,7 +7,6 @@ on: | |||
| 7 | 7 | - '**.go' | |
| 8 | 8 | - 'go.mod' | |
| 9 | 9 | - 'go.sum' | |
| 10 | - | - 'webui/**' | |
| 11 | 10 | - '.golangci.yml' | |
| 12 | 11 | - 'Makefile' | |
| 13 | 12 | - 'sqlc.yaml' | |
| @@ -21,7 +20,6 @@ on: | |||
| 21 | 20 | - '**.go' | |
| 22 | 21 | - 'go.mod' | |
| 23 | 22 | - 'go.sum' | |
| 24 | - | - 'webui/**' | |
| 25 | 23 | - '.golangci.yml' | |
| 26 | 24 | - 'Makefile' | |
| 27 | 25 | - 'sqlc.yaml' | |
| @@ -40,7 +38,7 @@ jobs: | |||
| 40 | 38 | - name: Install dependencies | |
| 41 | 39 | run: | | |
| 42 | 40 | 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 | |
| 44 | 42 | - uses: actions/checkout@v6 | |
| 45 | 43 | - name: Cache Go modules | |
| 46 | 44 | uses: actions/cache@v5 | |
| @@ -56,15 +54,8 @@ jobs: | |||
| 56 | 54 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} | |
| 57 | 55 | restore-keys: | | |
| 58 | 56 | ${{ 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 | |
| 68 | 59 | - name: Install golangci-lint | |
| 69 | 60 | run: | | |
| 70 | 61 | 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: | |||
| 81 | 72 | - name: Install dependencies | |
| 82 | 73 | run: | | |
| 83 | 74 | 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 | |
| 85 | 76 | - uses: actions/checkout@v6 | |
| 86 | 77 | - name: Cache Go modules | |
| 87 | 78 | uses: actions/cache@v5 | |
| @@ -97,15 +88,8 @@ jobs: | |||
| 97 | 88 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} | |
| 98 | 89 | restore-keys: | | |
| 99 | 90 | ${{ 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 | |
| 109 | 93 | - name: Run tests | |
| 110 | 94 | env: | |
| 111 | 95 | 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 | |||
| 4 | 4 | ||
| 5 | 5 | before: | |
| 6 | 6 | hooks: | |
| 7 | - | # Build frontend assets before building binaries | |
| 8 | - | - make build-frontend | |
| 7 | + | # Build systemviews assets before building binaries | |
| 8 | + | - make build-systemviews | |
| 9 | 9 | ||
| 10 | 10 | builds: | |
| 11 | 11 | # Pure Go build (no CGO required) | |
M CONTRIBUTING.md +2 -4
| @@ -34,10 +34,8 @@ git push origin my-fix | |||
| 34 | 34 | 2. Run `make test` and ensure tests pass | |
| 35 | 35 | 3. Run `go fmt` on any Go code changes | |
| 36 | 36 | ||
| 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 | |
| 41 | 39 | ||
| 42 | 40 | Keep commits focused and use single-line commit messages. | |
| 43 | 41 | ||
M Makefile +5 -12
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Makefile for dumber (Clean Architecture - puregotk) | |
| 2 | 2 | ||
| 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 | |
| 4 | 4 | ||
| 5 | 5 | # Load local overrides from .env.local if present (Makefile syntax) | |
| 6 | 6 | ifneq (,$(wildcard .env.local)) | |
| @@ -38,18 +38,13 @@ help: ## Show this help message | |||
| 38 | 38 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | |
| 39 | 39 | ||
| 40 | 40 | # 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) | |
| 42 | 42 | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | |
| 43 | 43 | @mkdir -p $(DIST_DIR) | |
| 44 | 44 | CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 45 | 45 | CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper | |
| 46 | 46 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 47 | 47 | ||
| 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 | - | ||
| 53 | 48 | build-systemviews: ## Build the WASM systemviews runtime | |
| 54 | 49 | @echo "Building systemviews wasm assets..." | |
| 55 | 50 | @mkdir -p assets/systemviews | |
| @@ -57,8 +52,8 @@ build-systemviews: ## Build the WASM systemviews runtime | |||
| 57 | 52 | GOOS=js GOARCH=wasm go build -o assets/systemviews/systemviews.wasm ./cmd/systemviews | |
| 58 | 53 | @echo "Systemviews build complete" | |
| 59 | 54 | ||
| 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)..." | |
| 62 | 57 | @mkdir -p $(DIST_DIR) | |
| 63 | 58 | CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 64 | 59 | 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 | |||
| 130 | 125 | @echo "Cleaning build artifacts..." | |
| 131 | 126 | rm -rf $(DIST_DIR) | |
| 132 | 127 | 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 | |
| 135 | 128 | rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm | |
| 136 | 129 | rm -f coverage.out coverage.html | |
| 137 | 130 | go clean -cache | |
| @@ -168,7 +161,7 @@ man: build-quick ## Install man pages to ~/.local/share/man/man1/ | |||
| 168 | 161 | # Native release targets | |
| 169 | 162 | .PHONY: release-snapshot release | |
| 170 | 163 | ||
| 171 | - | release-snapshot: build-frontend ## Build snapshot using goreleaser | |
| 164 | + | release-snapshot: build-systemviews ## Build snapshot using goreleaser | |
| 172 | 165 | @echo "Building snapshot with goreleaser..." | |
| 173 | 166 | goreleaser release --snapshot --clean | |
| 174 | 167 | ||
M assets/webui_embed.go +4 -4
| @@ -4,11 +4,11 @@ import ( | |||
| 4 | 4 | "embed" | |
| 5 | 5 | ) | |
| 6 | 6 | ||
| 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. | |
| 10 | 10 | // | |
| 11 | - | //go:embed webui/* systemviews/* | |
| 11 | + | //go:embed systemviews/* | |
| 12 | 12 | var WebUIAssets embed.FS | |
| 13 | 13 | ||
| 14 | 14 | // 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 | |||
| 6 | 6 | arch = x86_64 | |
| 7 | 7 | license = MIT | |
| 8 | 8 | makedepends = go>=1.25 | |
| 9 | - | makedepends = npm | |
| 10 | 9 | makedepends = git | |
| 11 | 10 | depends = gtk4 | |
| 12 | 11 | 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)" | |||
| 6 | 6 | arch=('x86_64') | |
| 7 | 7 | url="https://github.com/bnema/dumber" | |
| 8 | 8 | license=('MIT') | |
| 9 | - | makedepends=('go>=1.25' 'npm' 'git') | |
| 9 | + | makedepends=('go>=1.25' 'git') | |
| 10 | 10 | depends=('gtk4' 'webkitgtk-6.0') | |
| 11 | 11 | optdepends=( | |
| 12 | 12 | 'gst-plugins-base: Base media codecs' | |
M docs/config/index.md +1 -1
| @@ -201,7 +201,7 @@ border = "#363636" | |||
| 201 | 201 | **Example:** | |
| 202 | 202 | ```toml | |
| 203 | 203 | [workspace] | |
| 204 | - | new_pane_url = "dumb://home" | |
| 204 | + | new_pane_url = "dumb://history" | |
| 205 | 205 | ``` | |
| 206 | 206 | ||
| 207 | 207 | ### Pane Mode | |
M internal/domain/url/normalize_test.go +4 -4
| @@ -34,8 +34,8 @@ func TestNormalize(t *testing.T) { | |||
| 34 | 34 | }, | |
| 35 | 35 | { | |
| 36 | 36 | name: "dumb scheme unchanged", | |
| 37 | - | input: "dumb://home", | |
| 38 | - | want: "dumb://home", | |
| 37 | + | input: "dumb://history", | |
| 38 | + | want: "dumb://history", | |
| 39 | 39 | }, | |
| 40 | 40 | { | |
| 41 | 41 | name: "about scheme unchanged", | |
| @@ -347,7 +347,7 @@ func TestLooksLikeURL(t *testing.T) { | |||
| 347 | 347 | }, | |
| 348 | 348 | { | |
| 349 | 349 | name: "dumb URL", | |
| 350 | - | input: "dumb://home", | |
| 350 | + | input: "dumb://history", | |
| 351 | 351 | want: true, | |
| 352 | 352 | }, | |
| 353 | 353 | { | |
| @@ -506,7 +506,7 @@ func TestIsExternalScheme(t *testing.T) { | |||
| 506 | 506 | }, | |
| 507 | 507 | { | |
| 508 | 508 | name: "dumb scheme", | |
| 509 | - | input: "dumb://home", | |
| 509 | + | input: "dumb://history", | |
| 510 | 510 | want: false, | |
| 511 | 511 | }, | |
| 512 | 512 | { | |
M internal/infrastructure/cef/internal_urls.go +4 -4
| @@ -39,7 +39,7 @@ func toActualInternalURL(raw string) string { | |||
| 39 | 39 | if page == "" { | |
| 40 | 40 | page = parsed.Opaque | |
| 41 | 41 | } | |
| 42 | - | if page == "" { | |
| 42 | + | if page == "" || !isInternalPageHost(page) { | |
| 43 | 43 | return raw | |
| 44 | 44 | } | |
| 45 | 45 | ||
| @@ -47,8 +47,8 @@ func toActualInternalURL(raw string) string { | |||
| 47 | 47 | trimmedPath := strings.Trim(parsed.Path, "/") | |
| 48 | 48 | switch { | |
| 49 | 49 | 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. | |
| 52 | 52 | case strings.HasPrefix(trimmedPath, "api/"): | |
| 53 | 53 | actualPath = "/" + trimmedPath | |
| 54 | 54 | case path.Ext(trimmedPath) != "": | |
| @@ -102,7 +102,7 @@ func toConceptualInternalURL(raw string) string { | |||
| 102 | 102 | ||
| 103 | 103 | func isInternalPageHost(host string) bool { | |
| 104 | 104 | switch host { | |
| 105 | - | case homePath, historyPath, favoritesPath, configPath, webrtcPath, errorPath: | |
| 105 | + | case historyPath, favoritesPath, configPath, errorPath: | |
| 106 | 106 | return true | |
| 107 | 107 | default: | |
| 108 | 108 | return false | |
M internal/infrastructure/cef/internal_urls_test.go +20 -10
| @@ -12,14 +12,19 @@ func TestToActualInternalURL(t *testing.T) { | |||
| 12 | 12 | }{ | |
| 13 | 13 | { | |
| 14 | 14 | name: "page root", | |
| 15 | - | in: "dumb://home", | |
| 16 | - | want: "https://dumber.invalid/home", | |
| 15 | + | in: "dumb://history", | |
| 16 | + | want: "https://dumber.invalid/history", | |
| 17 | 17 | }, | |
| 18 | 18 | { | |
| 19 | 19 | name: "history page root", | |
| 20 | 20 | in: "dumb://history", | |
| 21 | 21 | want: "https://dumber.invalid/history", | |
| 22 | 22 | }, | |
| 23 | + | { | |
| 24 | + | name: "error page root", | |
| 25 | + | in: "dumb://error", | |
| 26 | + | want: "https://dumber.invalid/error", | |
| 27 | + | }, | |
| 23 | 28 | { | |
| 24 | 29 | name: "favorites page root", | |
| 25 | 30 | in: "dumb://favorites", | |
| @@ -32,18 +37,18 @@ func TestToActualInternalURL(t *testing.T) { | |||
| 32 | 37 | }, | |
| 33 | 38 | { | |
| 34 | 39 | name: "api path stays at origin root", | |
| 35 | - | in: "dumb://home/api/message", | |
| 40 | + | in: "dumb://history/api/message", | |
| 36 | 41 | want: "https://dumber.invalid/api/message", | |
| 37 | 42 | }, | |
| 38 | 43 | { | |
| 39 | 44 | name: "root asset stays at origin root", | |
| 40 | - | in: "dumb://home/favicon.ico", | |
| 45 | + | in: "dumb://history/favicon.ico", | |
| 41 | 46 | want: "https://dumber.invalid/favicon.ico", | |
| 42 | 47 | }, | |
| 43 | 48 | { | |
| 44 | 49 | 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", | |
| 47 | 52 | }, | |
| 48 | 53 | { | |
| 49 | 54 | name: "actual internal URL unchanged", | |
| @@ -72,14 +77,19 @@ func TestToConceptualInternalURL(t *testing.T) { | |||
| 72 | 77 | }{ | |
| 73 | 78 | { | |
| 74 | 79 | name: "page root", | |
| 75 | - | in: "https://dumber.invalid/home", | |
| 76 | - | want: "dumb://home", | |
| 80 | + | in: "https://dumber.invalid/history", | |
| 81 | + | want: "dumb://history", | |
| 77 | 82 | }, | |
| 78 | 83 | { | |
| 79 | 84 | name: "history page root", | |
| 80 | 85 | in: "https://dumber.invalid/history", | |
| 81 | 86 | want: "dumb://history", | |
| 82 | 87 | }, | |
| 88 | + | { | |
| 89 | + | name: "error page root", | |
| 90 | + | in: "https://dumber.invalid/error", | |
| 91 | + | want: "dumb://error", | |
| 92 | + | }, | |
| 83 | 93 | { | |
| 84 | 94 | name: "favorites page root", | |
| 85 | 95 | in: "https://dumber.invalid/favorites", | |
| @@ -92,8 +102,8 @@ func TestToConceptualInternalURL(t *testing.T) { | |||
| 92 | 102 | }, | |
| 93 | 103 | { | |
| 94 | 104 | 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", | |
| 97 | 107 | }, | |
| 98 | 108 | { | |
| 99 | 109 | name: "root asset is left unchanged", | |
M internal/infrastructure/cef/scheme_handler.go +10 -15
| @@ -26,11 +26,9 @@ import ( | |||
| 26 | 26 | ||
| 27 | 27 | // Scheme path constants matching WebKit's naming. | |
| 28 | 28 | const ( | |
| 29 | - | homePath = "home" | |
| 30 | 29 | historyPath = "history" | |
| 31 | 30 | favoritesPath = "favorites" | |
| 32 | 31 | configPath = "config" | |
| 33 | - | webrtcPath = "webrtc" | |
| 34 | 32 | errorPath = "error" | |
| 35 | 33 | indexHTML = "index.html" | |
| 36 | 34 | maxSchemeTruncatedURLLength = 240 | |
| @@ -38,12 +36,10 @@ const ( | |||
| 38 | 36 | ||
| 39 | 37 | // pageRootFiles maps internal page hosts/paths to their HTML entry points. | |
| 40 | 38 | var pageRootFiles = map[string]string{ | |
| 41 | - | homePath: indexHTML, | |
| 42 | 39 | historyPath: indexHTML, | |
| 43 | 40 | favoritesPath: indexHTML, | |
| 44 | 41 | configPath: indexHTML, | |
| 45 | - | webrtcPath: "webrtc.html", | |
| 46 | - | errorPath: "error.html", | |
| 42 | + | errorPath: indexHTML, | |
| 47 | 43 | } | |
| 48 | 44 | ||
| 49 | 45 | // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual | |
| @@ -85,14 +81,14 @@ func newDumbSchemeHandler( | |||
| 85 | 81 | ctx: ctx, | |
| 86 | 82 | messageRouter: router, | |
| 87 | 83 | transcoder: transcoder, | |
| 88 | - | assetDir: "webui", | |
| 84 | + | assetDir: "systemviews", | |
| 89 | 85 | logger: log.With().Str("component", "scheme-handler").Logger(), | |
| 90 | 86 | currentConfigPayload: currentConfigPayload, | |
| 91 | 87 | defaultConfigPayload: defaultConfigPayload, | |
| 92 | 88 | }, nil | |
| 93 | 89 | } | |
| 94 | 90 | ||
| 95 | - | // setAssets sets the embedded filesystem containing webui assets. | |
| 91 | + | // setAssets sets the embedded filesystem containing systemviews assets. | |
| 96 | 92 | func (h *dumbSchemeHandler) setAssets(assets embed.FS) { | |
| 97 | 93 | h.mu.Lock() | |
| 98 | 94 | defer h.mu.Unlock() | |
| @@ -325,7 +321,7 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |||
| 325 | 321 | } | |
| 326 | 322 | ||
| 327 | 323 | // 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. | |
| 329 | 325 | func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |
| 330 | 326 | if u == nil { | |
| 331 | 327 | return "", "", false | |
| @@ -378,7 +374,7 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||
| 378 | 374 | if len(parts) == 1 { | |
| 379 | 375 | return assetDirForPageHost(page), root, true | |
| 380 | 376 | } | |
| 381 | - | if page == homePath && parts[1] == "crash" { | |
| 377 | + | if page == historyPath && parts[1] == "crash" { | |
| 382 | 378 | return "", "", false | |
| 383 | 379 | } | |
| 384 | 380 | return assetDirForPageHost(page), parts[1], true | |
| @@ -388,16 +384,15 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||
| 388 | 384 | return "", "", false | |
| 389 | 385 | } | |
| 390 | 386 | ||
| 391 | - | // Serve root assets like homepage.min.js, style.css and favicon.png. | |
| 392 | - | return "webui", path, true | |
| 387 | + | return "", "", false | |
| 393 | 388 | } | |
| 394 | 389 | ||
| 395 | 390 | func assetDirForPageHost(host string) string { | |
| 396 | 391 | switch host { | |
| 397 | - | case historyPath, favoritesPath, configPath: | |
| 392 | + | case historyPath, favoritesPath, configPath, errorPath: | |
| 398 | 393 | return "systemviews" | |
| 399 | 394 | default: | |
| 400 | - | return "webui" | |
| 395 | + | return "" | |
| 401 | 396 | } | |
| 402 | 397 | } | |
| 403 | 398 | ||
| @@ -408,8 +403,8 @@ func isCEFCrashPageURL(u *url.URL) bool { | |||
| 408 | 403 | ||
| 409 | 404 | switch { | |
| 410 | 405 | 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: | |
| 413 | 408 | return strings.Trim(u.Path, "/") == "crash" | |
| 414 | 409 | default: | |
| 415 | 410 | 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 | |||
| 39 | 39 | return nil, fmt.Errorf("invalid config format: %w", err) | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | - | log.Info().Msg("saving appearance configuration from webui") | |
| 42 | + | log.Info().Msg("saving appearance configuration") | |
| 43 | 43 | ||
| 44 | 44 | if err := h.saveConfig(ctx, payloadCfg); err != nil { | |
| 45 | 45 | log.Error().Err(err).Msg("failed to save config") | |
M internal/infrastructure/webkit/scheme_handler.go +15 -26
| @@ -21,16 +21,13 @@ import ( | |||
| 21 | 21 | ||
| 22 | 22 | // Scheme path constants | |
| 23 | 23 | 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" | |
| 34 | 31 | ) | |
| 35 | 32 | ||
| 36 | 33 | // SchemeRequest represents a request to a custom URI scheme. | |
| @@ -65,7 +62,7 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse { | |||
| 65 | 62 | type DumbSchemeHandler struct { | |
| 66 | 63 | handlers map[string]PageHandler | |
| 67 | 64 | 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") | |
| 69 | 66 | logger zerolog.Logger | |
| 70 | 67 | mu sync.RWMutex | |
| 71 | 68 | currentConfigPayload func() ([]byte, error) | |
| @@ -78,7 +75,7 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | |||
| 78 | 75 | ||
| 79 | 76 | h := &DumbSchemeHandler{ | |
| 80 | 77 | handlers: make(map[string]PageHandler), | |
| 81 | - | assetDir: "webui", | |
| 78 | + | assetDir: "systemviews", | |
| 82 | 79 | logger: log.With().Str("component", "scheme-handler").Logger(), | |
| 83 | 80 | } | |
| 84 | 81 | ||
| @@ -88,7 +85,7 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | |||
| 88 | 85 | return h | |
| 89 | 86 | } | |
| 90 | 87 | ||
| 91 | - | // SetAssets sets the embedded filesystem containing webui assets. | |
| 88 | + | // SetAssets sets the embedded filesystem containing systemviews assets. | |
| 92 | 89 | func (h *DumbSchemeHandler) SetAssets(assets embed.FS) { | |
| 93 | 90 | h.mu.Lock() | |
| 94 | 91 | defer h.mu.Unlock() | |
| @@ -138,7 +135,7 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||
| 138 | 135 | h.mu.RLock() | |
| 139 | 136 | build := h.currentConfigPayload | |
| 140 | 137 | h.mu.RUnlock() | |
| 141 | - | return h.buildConfigResponse(build) | |
| 138 | + | return buildConfigResponse(build) | |
| 142 | 139 | })) | |
| 143 | 140 | ||
| 144 | 141 | // API: Get default config (used by Reset Defaults in dumb://config) | |
| @@ -150,7 +147,7 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||
| 150 | 147 | h.mu.RLock() | |
| 151 | 148 | build := h.defaultConfigPayload | |
| 152 | 149 | h.mu.RUnlock() | |
| 153 | - | return h.buildConfigResponse(build) | |
| 150 | + | return buildConfigResponse(build) | |
| 154 | 151 | })) | |
| 155 | 152 | } | |
| 156 | 153 | ||
| @@ -173,7 +170,7 @@ func buildCrashPageHTML(originalURI string) string { | |||
| 173 | 170 | return webutil.BuildCrashPageHTML(originalURI) | |
| 174 | 171 | } | |
| 175 | 172 | ||
| 176 | - | func (h *DumbSchemeHandler) buildConfigResponse(build func() ([]byte, error)) *SchemeResponse { | |
| 173 | + | func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse { | |
| 177 | 174 | if build == nil { | |
| 178 | 175 | return &SchemeResponse{ | |
| 179 | 176 | Data: []byte(`{"error": "config payload builder not configured"}`), | |
| @@ -333,12 +330,10 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||
| 333 | 330 | assetDir string | |
| 334 | 331 | file string | |
| 335 | 332 | }{ | |
| 336 | - | HomePath: {assetDir: "webui", file: IndexHTML}, | |
| 337 | 333 | HistoryPath: {assetDir: "systemviews", file: IndexHTML}, | |
| 338 | 334 | FavoritesPath: {assetDir: "systemviews", file: IndexHTML}, | |
| 339 | 335 | 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}, | |
| 342 | 337 | } | |
| 343 | 338 | ||
| 344 | 339 | if root, ok := rootByHost[u.Host]; ok { | |
| @@ -350,14 +345,8 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||
| 350 | 345 | } | |
| 351 | 346 | ||
| 352 | 347 | switch u.Opaque { | |
| 353 | - | case HomePath: | |
| 354 | - | return "webui", IndexHTML, true | |
| 355 | - | case HistoryPath, FavoritesPath, ConfigPath: | |
| 348 | + | case HistoryPath, FavoritesPath, ConfigPath, ErrorPath: | |
| 356 | 349 | return "systemviews", IndexHTML, true | |
| 357 | - | case ErrorPath: | |
| 358 | - | return "webui", "error.html", true | |
| 359 | - | case WebRTCPath: | |
| 360 | - | return "webui", "webrtc.html", true | |
| 361 | 350 | default: | |
| 362 | 351 | return "", "", false | |
| 363 | 352 | } | |
M internal/infrastructure/webkit/scheme_handler_test.go +8 -32
| @@ -12,8 +12,8 @@ import ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | 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")) | |
| 17 | 17 | assert.Empty(t, crashOriginalURI("%%%")) | |
| 18 | 18 | } | |
| 19 | 19 | ||
| @@ -38,8 +38,8 @@ func TestBuildCrashPageHTMLRejectsUnsafeReloadScheme(t *testing.T) { | |||
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | 40 | 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"`) | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | func TestBuildCrashPageHTMLEscapesScriptBreakoutPayload(t *testing.T) { | |
| @@ -60,7 +60,7 @@ func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) { | |||
| 60 | 60 | require.NotNil(t, crashHandler) | |
| 61 | 61 | ||
| 62 | 62 | 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", | |
| 64 | 64 | Path: "/crash", | |
| 65 | 65 | Method: "GET", | |
| 66 | 66 | Scheme: "dumb", | |
| @@ -82,7 +82,7 @@ func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) { | |||
| 82 | 82 | require.NotNil(t, crashHandler) | |
| 83 | 83 | ||
| 84 | 84 | resp := crashHandler.Handle(&SchemeRequest{ | |
| 85 | - | URI: "dumb://home/crash?url=javascript%3Aalert(1)", | |
| 85 | + | URI: "dumb://history/crash?url=javascript%3Aalert(1)", | |
| 86 | 86 | Path: "/crash", | |
| 87 | 87 | Method: "GET", | |
| 88 | 88 | Scheme: "dumb", | |
| @@ -91,32 +91,6 @@ func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) { | |||
| 91 | 91 | assert.Contains(t, string(resp.Data), `data-target=""`) | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 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 | - | ||
| 120 | 94 | func TestHandleAsset_ConfigOpaqueFormServesSystemviewsShell(t *testing.T) { | |
| 121 | 95 | h := NewDumbSchemeHandler(context.Background()) | |
| 122 | 96 | h.SetAssets(assets.WebUIAssets) | |
| @@ -141,6 +115,8 @@ func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) { | |||
| 141 | 115 | "dumb:favorites", | |
| 142 | 116 | "dumb://config", | |
| 143 | 117 | "dumb:config", | |
| 118 | + | "dumb://error", | |
| 119 | + | "dumb:error", | |
| 144 | 120 | } | |
| 145 | 121 | ||
| 146 | 122 | for _, raw := range tests { | |
M internal/ui/app.go +1 -1
| @@ -1408,7 +1408,7 @@ func (a *App) createInitialTab(ctx context.Context) { | |||
| 1408 | 1408 | } | |
| 1409 | 1409 | ||
| 1410 | 1410 | // Create an initial tab using coordinator. | |
| 1411 | - | initialURL := "dumb://home" | |
| 1411 | + | initialURL := "dumb://history" | |
| 1412 | 1412 | if a.deps != nil && a.deps.InitialURL != "" { | |
| 1413 | 1413 | initialURL = a.deps.InitialURL | |
| 1414 | 1414 | } | |
M internal/ui/coordinator/content/appearance.go +1 -1
| @@ -14,7 +14,7 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | const ( | |
| 16 | 16 | aboutBlankURI = "about:blank" | |
| 17 | - | crashPageURI = "dumb://home/crash" | |
| 17 | + | crashPageURI = "dumb://history/crash" | |
| 18 | 18 | logURLMaxLen = 80 | |
| 19 | 19 | oauthParentRefreshDebounce = 200 * time.Millisecond | |
| 20 | 20 | ||
M internal/ui/coordinator/content/callbacks.go +2 -2
| @@ -12,8 +12,8 @@ import ( | |||
| 12 | 12 | "github.com/bnema/puregotk/v4/gdk" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 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" | |
| 17 | 17 | ||
| 18 | 18 | func shouldRenderCrashPage(reason port.WebProcessTerminationReason) bool { | |
| 19 | 19 | switch reason { | |
M internal/ui/coordinator/content/content_termination_test.go +4 -4
| @@ -15,13 +15,13 @@ func TestShouldRenderCrashPage(t *testing.T) { | |||
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | 17 | 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")) | |
| 20 | 20 | assert.Equal(t, "https://example.com", extractOriginalURIFromCrashPage("https://example.com")) | |
| 21 | 21 | assert.Equal(t, "%%%bad", extractOriginalURIFromCrashPage("%%%bad")) | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | 24 | 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")) | |
| 27 | 27 | } | |
M internal/ui/standalone_runtime_test.go +1 -1
| @@ -74,7 +74,7 @@ func TestStandaloneExternalSchemeRules_CustomSchemeIsExternal(t *testing.T) { | |||
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | 76 | 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"} { | |
| 78 | 78 | if urlutil.IsExternalScheme(rawURL) { | |
| 79 | 79 | t.Fatalf("expected %q to stay internal", rawURL) | |
| 80 | 80 | } | |
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 generated file: collapsed
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"); | |