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