From 34db677ef72570430b63b5cea4a8a5209b0b0295 Mon Sep 17 00:00:00 2001 From: dolatron Date: Sat, 7 Mar 2026 11:42:32 -0500 Subject: [PATCH 01/17] Add Templ + HTMX web UI with dashboard (Phase 1) Introduce a server-rendered web UI using Templ templates and HTMX, embedded in the single Go binary. The web UI shares the same DuckDB query engine as the TUI for fast aggregate queries over Parquet files. - Dashboard page showing archive stats (messages, size, attachments, labels, accounts) and account list with quick links to browse views - Templ templates with layout, dashboard, and placeholder pages - HTMX 2.0.4 vendored for future interactive features - Minimal CSS with light/dark theme support via prefers-color-scheme - Web routes mounted on existing chi router with auth middleware - serve command now initializes query engine and auto-builds Parquet cache on startup (same logic as TUI) - Placeholder pages for Browse, Messages, and Search nav links - Buffered template rendering to prevent partial responses on error - Flox manifest updated with templ package - Makefile generate target for templ code generation --- .flox/.gitattributes | 1 + .flox/.gitignore | 5 + .flox/env.json | 4 + .flox/env/manifest.lock | 262 +++++++++++++++ .flox/env/manifest.toml | 107 +++++++ Makefile | 13 +- cmd/msgvault/cmd/serve.go | 40 ++- docs/web-ui-plan.md | 334 ++++++++++++++++++++ go.mod | 1 + go.sum | 2 + internal/api/server.go | 27 +- internal/web/handlers.go | 55 ++++ internal/web/server.go | 47 +++ internal/web/static/htmx.min.js | 1 + internal/web/static/style.css | 257 +++++++++++++++ internal/web/templates/dashboard.templ | 80 +++++ internal/web/templates/dashboard_templ.go | 277 ++++++++++++++++ internal/web/templates/helpers.go | 45 +++ internal/web/templates/layout.templ | 32 ++ internal/web/templates/layout_templ.go | 149 +++++++++ internal/web/templates/placeholder.templ | 10 + internal/web/templates/placeholder_templ.go | 71 +++++ 22 files changed, 1813 insertions(+), 7 deletions(-) create mode 100644 .flox/.gitattributes create mode 100644 .flox/.gitignore create mode 100644 .flox/env.json create mode 100644 .flox/env/manifest.lock create mode 100644 .flox/env/manifest.toml create mode 100644 docs/web-ui-plan.md create mode 100644 internal/web/handlers.go create mode 100644 internal/web/server.go create mode 100644 internal/web/static/htmx.min.js create mode 100644 internal/web/static/style.css create mode 100644 internal/web/templates/dashboard.templ create mode 100644 internal/web/templates/dashboard_templ.go create mode 100644 internal/web/templates/helpers.go create mode 100644 internal/web/templates/layout.templ create mode 100644 internal/web/templates/layout_templ.go create mode 100644 internal/web/templates/placeholder.templ create mode 100644 internal/web/templates/placeholder_templ.go diff --git a/.flox/.gitattributes b/.flox/.gitattributes new file mode 100644 index 00000000..bb5491e1 --- /dev/null +++ b/.flox/.gitattributes @@ -0,0 +1 @@ +env/manifest.lock linguist-generated=true linguist-language=JSON diff --git a/.flox/.gitignore b/.flox/.gitignore new file mode 100644 index 00000000..8d211868 --- /dev/null +++ b/.flox/.gitignore @@ -0,0 +1,5 @@ +run/ +cache/ +lib/ +log/ +!env/ diff --git a/.flox/env.json b/.flox/env.json new file mode 100644 index 00000000..a40b2ded --- /dev/null +++ b/.flox/env.json @@ -0,0 +1,4 @@ +{ + "name": "msgvault", + "version": 1 +} diff --git a/.flox/env/manifest.lock b/.flox/env/manifest.lock new file mode 100644 index 00000000..93d79e70 --- /dev/null +++ b/.flox/env/manifest.lock @@ -0,0 +1,262 @@ +{ + "lockfile-version": 1, + "manifest": { + "version": 1, + "install": { + "go": { + "pkg-path": "go", + "version": "^1.25.7" + }, + "templ": { + "pkg-path": "templ" + } + }, + "hook": { + "on-activate": " # Autogenerated by Flox\n\n # Point GOENV to Flox environment cache\n export GOENV=\"$FLOX_ENV_CACHE/goenv\"\n\n # Install Go dependencies\n go get .\n\n # End autogenerated by Flox\n" + }, + "profile": {}, + "options": {} + }, + "packages": [ + { + "attr_path": "go", + "broken": false, + "derivation": "/nix/store/3hac85v8ifdj7khc9ygw17ncqh3cifdm-go-1.25.7.drv", + "description": "Go Programming language", + "install_id": "go", + "license": "BSD-3-Clause", + "locked_url": "https://github.com/flox/nixpkgs?rev=80bdc1e5ce51f56b19791b52b2901187931f5353", + "name": "go-1.25.7", + "pname": "go", + "rev": "80bdc1e5ce51f56b19791b52b2901187931f5353", + "rev_count": 958232, + "rev_date": "2026-03-04T11:34:51Z", + "scrape_date": "2026-03-06T04:40:04.789485Z", + "stabilities": [ + "staging", + "unstable" + ], + "unfree": false, + "version": "1.25.7", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/hj3rkkp4azj65qvalnbl6ax0sgrfgmgh-go-1.25.7" + }, + "system": "aarch64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "go", + "broken": false, + "derivation": "/nix/store/rygzd1f3h7cza5p7y0ja3nzcyyx4h7zx-go-1.25.7.drv", + "description": "Go Programming language", + "install_id": "go", + "license": "BSD-3-Clause", + "locked_url": "https://github.com/flox/nixpkgs?rev=80bdc1e5ce51f56b19791b52b2901187931f5353", + "name": "go-1.25.7", + "pname": "go", + "rev": "80bdc1e5ce51f56b19791b52b2901187931f5353", + "rev_count": 958232, + "rev_date": "2026-03-04T11:34:51Z", + "scrape_date": "2026-03-06T05:10:51.811029Z", + "stabilities": [ + "staging", + "unstable" + ], + "unfree": false, + "version": "1.25.7", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/2i6kcj0f0xirrblpl43k2yr673bhf2c5-go-1.25.7" + }, + "system": "aarch64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "go", + "broken": false, + "derivation": "/nix/store/5r4cbkbvffn0pm253y15gfwvxhf2dm8i-go-1.25.7.drv", + "description": "Go Programming language", + "install_id": "go", + "license": "BSD-3-Clause", + "locked_url": "https://github.com/flox/nixpkgs?rev=80bdc1e5ce51f56b19791b52b2901187931f5353", + "name": "go-1.25.7", + "pname": "go", + "rev": "80bdc1e5ce51f56b19791b52b2901187931f5353", + "rev_count": 958232, + "rev_date": "2026-03-04T11:34:51Z", + "scrape_date": "2026-03-06T05:39:42.371223Z", + "stabilities": [ + "staging", + "unstable" + ], + "unfree": false, + "version": "1.25.7", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/8yk4r9kmb00ra1r22cxvdwghr8v987nf-go-1.25.7" + }, + "system": "x86_64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "go", + "broken": false, + "derivation": "/nix/store/fq8965avl2cbgcx0ni9xp7ixh8b8k2ki-go-1.25.7.drv", + "description": "Go Programming language", + "install_id": "go", + "license": "BSD-3-Clause", + "locked_url": "https://github.com/flox/nixpkgs?rev=80bdc1e5ce51f56b19791b52b2901187931f5353", + "name": "go-1.25.7", + "pname": "go", + "rev": "80bdc1e5ce51f56b19791b52b2901187931f5353", + "rev_count": 958232, + "rev_date": "2026-03-04T11:34:51Z", + "scrape_date": "2026-03-06T06:13:32.330201Z", + "stabilities": [ + "staging", + "unstable" + ], + "unfree": false, + "version": "1.25.7", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/pz4pxhmlqd3q72crp2sx90k85gf2rqyp-go-1.25.7" + }, + "system": "x86_64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "templ", + "broken": false, + "derivation": "/nix/store/xj77nws3zind0k7fddjkp7j8myi7kbgm-templ-0.3.977.drv", + "description": "Language for writing HTML user interfaces in Go", + "install_id": "templ", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=80bdc1e5ce51f56b19791b52b2901187931f5353", + "name": "templ-0.3.977", + "pname": "templ", + "rev": "80bdc1e5ce51f56b19791b52b2901187931f5353", + "rev_count": 958232, + "rev_date": "2026-03-04T11:34:51Z", + "scrape_date": "2026-03-06T04:41:51.776729Z", + "stabilities": [ + "staging", + "unstable" + ], + "unfree": false, + "version": "0.3.977", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/20ryxrbhhhy9lsz968igq0zvkjxi1mf3-templ-0.3.977" + }, + "system": "aarch64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "templ", + "broken": false, + "derivation": "/nix/store/hizym9kbxzqv0qis3590cfwgfx3ld4ss-templ-0.3.977.drv", + "description": "Language for writing HTML user interfaces in Go", + "install_id": "templ", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=80bdc1e5ce51f56b19791b52b2901187931f5353", + "name": "templ-0.3.977", + "pname": "templ", + "rev": "80bdc1e5ce51f56b19791b52b2901187931f5353", + "rev_count": 958232, + "rev_date": "2026-03-04T11:34:51Z", + "scrape_date": "2026-03-06T05:13:17.101116Z", + "stabilities": [ + "staging", + "unstable" + ], + "unfree": false, + "version": "0.3.977", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/qfw3cg4b0psg9kn392yl07ckigzqman0-templ-0.3.977" + }, + "system": "aarch64-linux", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "templ", + "broken": false, + "derivation": "/nix/store/8rwrxhdid9agf8kggsy40m0b5i4m8xzb-templ-0.3.977.drv", + "description": "Language for writing HTML user interfaces in Go", + "install_id": "templ", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=80bdc1e5ce51f56b19791b52b2901187931f5353", + "name": "templ-0.3.977", + "pname": "templ", + "rev": "80bdc1e5ce51f56b19791b52b2901187931f5353", + "rev_count": 958232, + "rev_date": "2026-03-04T11:34:51Z", + "scrape_date": "2026-03-06T05:41:29.328480Z", + "stabilities": [ + "staging", + "unstable" + ], + "unfree": false, + "version": "0.3.977", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/cr4c8ick9dz4vp4n8jbv04z4x61d1gl8-templ-0.3.977" + }, + "system": "x86_64-darwin", + "group": "toplevel", + "priority": 5 + }, + { + "attr_path": "templ", + "broken": false, + "derivation": "/nix/store/w4x2bxbx2qwiriabkk24h3f0ch5avy0r-templ-0.3.977.drv", + "description": "Language for writing HTML user interfaces in Go", + "install_id": "templ", + "license": "MIT", + "locked_url": "https://github.com/flox/nixpkgs?rev=80bdc1e5ce51f56b19791b52b2901187931f5353", + "name": "templ-0.3.977", + "pname": "templ", + "rev": "80bdc1e5ce51f56b19791b52b2901187931f5353", + "rev_count": 958232, + "rev_date": "2026-03-04T11:34:51Z", + "scrape_date": "2026-03-06T06:16:01.345530Z", + "stabilities": [ + "staging", + "unstable" + ], + "unfree": false, + "version": "0.3.977", + "outputs_to_install": [ + "out" + ], + "outputs": { + "out": "/nix/store/9h8k60j3vk9k1f8hcz4dyf8bgpvm163f-templ-0.3.977" + }, + "system": "x86_64-linux", + "group": "toplevel", + "priority": 5 + } + ] +} diff --git a/.flox/env/manifest.toml b/.flox/env/manifest.toml new file mode 100644 index 00000000..83b9cc40 --- /dev/null +++ b/.flox/env/manifest.toml @@ -0,0 +1,107 @@ +## Flox Environment Manifest ----------------------------------------- +## +## _Everything_ you need to know about the _manifest_ is here: +## +## https://flox.dev/docs/reference/command-reference/manifest.toml/ +## +## ------------------------------------------------------------------- +# Flox manifest version managed by Flox CLI +version = 1 + + +## Install Packages -------------------------------------------------- +## $ flox install gum <- puts a package in [install] section below +## $ flox search gum <- search for a package +## $ flox show gum <- show all versions of a package +## ------------------------------------------------------------------- +[install] +go.pkg-path = "go" +go.version = "^1.25.7" +templ.pkg-path = "templ" + + +## Environment Variables --------------------------------------------- +## ... available for use in the activated environment +## as well as [hook], [profile] scripts and [services] below. +## ------------------------------------------------------------------- +[vars] +# INTRO_MESSAGE = "It's gettin' Flox in here" + + +## Activation Hook --------------------------------------------------- +## ... run by _bash_ shell when you run 'flox activate'. +## ------------------------------------------------------------------- +[hook] +on-activate = """ + # Autogenerated by Flox + + # Point GOENV to Flox environment cache + export GOENV="$FLOX_ENV_CACHE/goenv" + + # Install Go dependencies + go get . + + # End autogenerated by Flox +""" + + +## Profile script ---------------------------------------------------- +## ... sourced by _your shell_ when you run 'flox activate'. +## ------------------------------------------------------------------- +[profile] +# common = ''' +# gum style \ +# --foreground 212 --border-foreground 212 --border double \ +# --align center --width 50 --margin "1 2" --padding "2 4" \ +# $INTRO_MESSAGE +# ''' +## Shell-specific customizations such as setting aliases go here: +# bash = ... +# zsh = ... +# fish = ... + + +## Services --------------------------------------------------------- +## $ flox services start <- Starts all services +## $ flox services status <- Status of running services +## $ flox activate --start-services <- Activates & starts all +## ------------------------------------------------------------------ +[services] +# myservice.command = "python3 -m http.server" + + +## Include ---------------------------------------------------------- +## ... environments to create a composed environment +## ------------------------------------------------------------------ +[include] +# environments = [ +# { dir = "../common" } +# ] + + +## Build and publish your own packages ------------------------------ +## $ flox build +## $ flox publish +## ------------------------------------------------------------------ +[build] +# [build.myproject] +# description = "The coolest project ever" +# version = "0.0.1" +# command = """ +# mkdir -p $out/bin +# cargo build --release +# cp target/release/myproject $out/bin/myproject +# """ + + +## Other Environment Options ----------------------------------------- +[options] +# Systems that environment is compatible with +# systems = [ +# "aarch64-darwin", +# "aarch64-linux", +# "x86_64-darwin", +# "x86_64-linux", +# ] +# Uncomment to disable CUDA detection. +# cuda-detection = false diff --git a/Makefile b/Makefile index 30f4c1db..1fef05fe 100644 --- a/Makefile +++ b/Makefile @@ -12,15 +12,19 @@ LDFLAGS := -X github.com/wesm/msgvault/cmd/msgvault/cmd.Version=$(VERSION) \ LDFLAGS_RELEASE := $(LDFLAGS) -s -w -.PHONY: build build-release install clean test test-v fmt lint tidy shootout run-shootout setup-hooks help +.PHONY: build build-release install clean test test-v fmt lint tidy generate shootout run-shootout setup-hooks help + +# Generate templ templates +generate: + templ generate # Build the binary (debug) -build: +build: generate CGO_ENABLED=1 go build -tags fts5 -ldflags="$(LDFLAGS)" -o msgvault ./cmd/msgvault @chmod +x msgvault # Build with optimizations (release) -build-release: +build-release: generate CGO_ENABLED=1 go build -tags fts5 -ldflags="$(LDFLAGS_RELEASE)" -trimpath -o msgvault ./cmd/msgvault @chmod +x msgvault @@ -83,7 +87,8 @@ run-shootout: shootout help: @echo "msgvault build targets:" @echo "" - @echo " build - Debug build" + @echo " generate - Generate templ templates" + @echo " build - Debug build (includes generate)" @echo " build-release - Release build (optimized, stripped)" @echo " install - Install to ~/.local/bin or GOPATH" @echo "" diff --git a/cmd/msgvault/cmd/serve.go b/cmd/msgvault/cmd/serve.go index 10da3ffa..3ed4465e 100644 --- a/cmd/msgvault/cmd/serve.go +++ b/cmd/msgvault/cmd/serve.go @@ -16,6 +16,7 @@ import ( "github.com/wesm/msgvault/internal/api" "github.com/wesm/msgvault/internal/gmail" "github.com/wesm/msgvault/internal/oauth" + "github.com/wesm/msgvault/internal/query" "github.com/wesm/msgvault/internal/scheduler" "github.com/wesm/msgvault/internal/store" "github.com/wesm/msgvault/internal/sync" @@ -121,8 +122,41 @@ func runServe(cmd *cobra.Command, args []string) error { storeAdapter := &storeAPIAdapter{store: s} schedAdapter := &schedulerAdapter{scheduler: sched} + // Initialize query engine for web UI + var serverOpts []api.ServerOption + analyticsDir := cfg.AnalyticsDir() + + // Build cache if needed (same logic as TUI) + needsBuild, reason := cacheNeedsBuild(dbPath, analyticsDir) + if needsBuild { + fmt.Printf("Building analytics cache (%s)...\n", reason) + result, err := buildCache(dbPath, analyticsDir, true) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to build cache: %v\n", err) + } else if !result.Skipped { + fmt.Printf("Cached %d messages for fast queries.\n", result.ExportedCount) + } + } + + if query.HasCompleteParquetData(analyticsDir) { + duckEngine, err := query.NewDuckDBEngine(analyticsDir, dbPath, s.DB(), query.DuckDBOptions{}) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to open query engine for web UI: %v\n", err) + fmt.Fprintf(os.Stderr, "Web UI will be disabled. JSON API still available.\n") + } else { + defer duckEngine.Close() + serverOpts = append(serverOpts, api.WithQueryEngine(duckEngine)) + logger.Info("web UI enabled", "analytics_dir", analyticsDir) + } + } else { + // Fall back to SQLite engine + sqlEngine := query.NewSQLiteEngine(s.DB()) + serverOpts = append(serverOpts, api.WithQueryEngine(sqlEngine)) + logger.Info("web UI enabled (SQLite fallback - may be slow for large archives)") + } + // Create and start API server - apiServer := api.NewServer(cfg, storeAdapter, schedAdapter, logger) + apiServer := api.NewServer(cfg, storeAdapter, schedAdapter, logger, serverOpts...) // Start API server in goroutine serverErr := make(chan error, 1) @@ -136,8 +170,10 @@ func runServe(cmd *cobra.Command, args []string) error { if bindAddr == "" { bindAddr = "127.0.0.1" } + serverAddr := net.JoinHostPort(bindAddr, strconv.Itoa(cfg.Server.APIPort)) fmt.Printf("msgvault daemon started\n") - fmt.Printf(" API server: http://%s\n", net.JoinHostPort(bindAddr, strconv.Itoa(cfg.Server.APIPort))) + fmt.Printf(" Web UI: http://%s/\n", serverAddr) + fmt.Printf(" API: http://%s/api/v1\n", serverAddr) fmt.Printf(" Scheduled accounts: %d\n", count) fmt.Printf(" Data directory: %s\n", cfg.Data.DataDir) fmt.Println() diff --git a/docs/web-ui-plan.md b/docs/web-ui-plan.md new file mode 100644 index 00000000..a834ae92 --- /dev/null +++ b/docs/web-ui-plan.md @@ -0,0 +1,334 @@ +# msgvault Web UI Plan + +## Stack + +- **Templ** — type-safe Go HTML templating (compiles to Go, no runtime) +- **HTMX** — HTML-over-the-wire interactivity (single JS file, ~14KB gzipped) +- **Go embed** — bundle everything into the single binary +- **chi router** — already in use for the JSON API +- **DuckDB query engine** — same `query.Engine` the TUI uses +- **Flox** — add `templ` package to manifest + +No npm. No node. No JS build step. `templ generate` produces `.go` files that compile with everything else. + +--- + +## Architecture + +``` +internal/ +├── api/ # Existing JSON API (unchanged) +│ ├── server.go +│ ├── handlers.go +│ └── middleware.go +│ +└── web/ # New — Web UI + ├── server.go # Router setup, mount on chi, embed static assets + ├── handlers.go # HTTP handlers (call query.Engine, render Templ) + ├── helpers.go # Template helpers (format bytes, dates, etc.) + ├── static/ # Static assets (embedded via go:embed) + │ ├── htmx.min.js # HTMX library (vendored, ~45KB) + │ └── style.css # Single stylesheet + └── templates/ + ├── layout.templ # Base HTML shell, nav, stats bar + ├── dashboard.templ # Landing page — account overview + stats + ├── aggregates.templ # Aggregate table (senders, domains, labels, time) + ├── messages.templ # Message list table + ├── message.templ # Message detail view + ├── thread.templ # Thread/conversation view + ├── search.templ # Search results (fast + deep) + ├── partials/ + │ ├── table.templ # Reusable data table rows (HTMX swap target) + │ ├── stats_bar.templ # Stats bar fragment (msg count, size, attachments) + │ ├── breadcrumb.templ # Navigation breadcrumb fragment + │ ├── pagination.templ # Pagination controls + │ ├── filters.templ # Filter controls (account, attachments, deleted) + │ └── sort_header.templ # Clickable sort column headers + └── components/ + ├── modal.templ # Generic modal (delete confirm, filter select) + └── search_bar.templ # Search input with mode toggle +``` + +--- + +## Routing + +All web UI routes mount under `/` on the existing chi router alongside `/api/v1/`. +Auth middleware applies to both — same API key mechanism. + +``` +GET / → Dashboard (full page) +GET /browse → Aggregates view (full page) +GET /browse?view=senders&sort=count&dir=desc → Aggregates with params +GET /browse/drill?view=recipients&sender=x → Drill-down (full page) +GET /messages → Message list (full page) +GET /messages?sender=x&label=y → Filtered message list +GET /messages/{id} → Message detail (full page) +GET /messages/{id}/thread → Thread view (full page) +GET /search?q=term&mode=fast → Search results (full page) + +# HTMX partial endpoints (return HTML fragments, not full pages) +GET /htmx/aggregates → Table body rows +GET /htmx/drill → Drill-down table body rows +GET /htmx/messages → Message list rows +GET /htmx/search → Search result rows +GET /htmx/stats → Stats bar fragment +GET /htmx/breadcrumb → Breadcrumb fragment +GET /htmx/filters → Filter panel fragment + +# Static assets +GET /static/* → Embedded static files +``` + +### URL State + +All view state lives in URL query parameters — bookmarkable, shareable, back-button works: + +``` +/browse?view=senders&sort=count&dir=desc&account=2&attachments=1&hide_deleted=1 +/browse/drill?sender=foo@gmail.com&view=recipients&sort=count&dir=desc +/messages?sender=foo@gmail.com&label=INBOX&page=1&sort=date&dir=desc +/search?q=invoice&mode=fast&page=1 +``` + +--- + +## Feature Mapping: TUI → Web + +| TUI Feature | Web Implementation | +|---|---| +| **7 aggregate views** (Tab cycle) | Dropdown/tabs: Senders, Sender Names, Recipients, Recipient Names, Domains, Labels, Time | +| **Drill-down** (Enter) | Click row → `/browse/drill?sender=x&view=recipients` (HTMX pushes URL) | +| **Multi-level drill** | Breadcrumb accumulates filters; each click adds a filter param | +| **Message list** | Click aggregate row → `/messages?sender=x` or drill further | +| **Message detail** | Click message row → `/messages/{id}` | +| **Thread view** | Link from message detail → `/messages/{id}/thread` | +| **Prev/Next message** | Arrow links in detail view header | +| **Sort cycling** (s key) | Clickable column headers with sort arrows | +| **Sort direction** (r key) | Click same header toggles direction | +| **Account filter** (A key) | Dropdown in filter bar, updates via HTMX | +| **Attachment filter** (f key) | Checkbox toggle in filter bar | +| **Hide deleted filter** (f key) | Checkbox toggle in filter bar | +| **Time granularity** (t key) | Year/Month/Day toggle buttons in Time view | +| **Fast search** (/) | Search input with debounce (`hx-trigger="keyup changed delay:200ms"`) | +| **Deep search** (Tab in search) | Toggle button switches `mode=fast` ↔ `mode=deep` | +| **Selection + deletion staging** | Checkboxes on rows → "Stage for Deletion" button → confirm modal | +| **Stats bar** | Persistent stats bar updated via HTMX on filter/view changes | +| **Breadcrumb** | Clickable breadcrumb trail showing drill path | +| **Pagination** | Page controls (Prev/Next/page numbers) or infinite scroll via `hx-trigger="revealed"` | +| **Help** (?) | Not needed — controls are visible | + +--- + +## Query Engine Integration + +The web handlers call the **same `query.Engine` methods** the TUI uses. +No new query code needed. + +| Web Handler | Engine Method | Notes | +|---|---|---| +| `handleDashboard` | `GetTotalStats()`, `ListAccounts()` | Landing page stats | +| `handleAggregates` | `Aggregate(viewType, opts)` | Main browse view | +| `handleDrill` | `SubAggregate(filter, viewType, opts)` | Drill-down | +| `handleMessages` | `ListMessages(filter)` | Filtered message list | +| `handleMessageDetail` | `GetMessage(id)` | Full message view | +| `handleThread` | `ListMessages(filter{ConversationID})` | Thread messages | +| `handleSearch` | `SearchFastWithStats()` or `Search()` | Fast vs deep search | +| `handleStats` | `GetTotalStats(opts)` | Stats bar partial | +| `handleStageDelete` | `GetGmailIDsByFilter(filter)` | Deletion staging | + +### Filter → Query Parameter Mapping + +URL params map directly to query engine types: + +```go +func filterFromRequest(r *http.Request) query.MessageFilter { + return query.MessageFilter{ + Sender: r.URL.Query().Get("sender"), + SenderName: r.URL.Query().Get("sender_name"), + Recipient: r.URL.Query().Get("recipient"), + RecipientName: r.URL.Query().Get("recipient_name"), + Domain: r.URL.Query().Get("domain"), + Label: r.URL.Query().Get("label"), + SourceID: parseOptionalInt64(r, "account"), + WithAttachmentsOnly: r.URL.Query().Get("attachments") == "1", + HideDeletedFromSource: r.URL.Query().Get("hide_deleted") == "1", + Pagination: paginationFromRequest(r), + Sorting: sortingFromRequest(r), + } +} +``` + +--- + +## Server Integration + +The web UI mounts on the **existing** `api.Server` router in `server.go`: + +```go +// internal/api/server.go — add to setupRouter() +func (s *Server) setupRouter() { + // ... existing middleware ... + + // Existing JSON API + r.Route("/api/v1", func(r chi.Router) { /* ... existing ... */ }) + + // New: Web UI + webHandler := web.NewHandler(s.engine, s.store, s.scheduler) + r.Mount("/", webHandler.Routes()) +} +``` + +The `web.Handler` receives the same `query.Engine` the TUI uses. This means: +- The `serve` command needs to initialize the query engine (currently only TUI does this) +- The Parquet cache must exist (auto-build on serve start, same as TUI does) + +### Changes to `serve.go` + +1. Initialize `query.Engine` (DuckDB over Parquet) alongside the store +2. Auto-build Parquet cache if stale (reuse `build_cache.go` logic) +3. Pass engine to `api.Server` +4. Web UI available at `http://localhost:8080/` + +--- + +## Styling Approach + +Single CSS file, no framework. Minimal, functional design: + +- CSS custom properties for theming (light/dark via `prefers-color-scheme`) +- CSS Grid for page layout (sidebar/main or full-width) +- Native HTML `` for data tables (fast rendering, accessible) +- System font stack (no web fonts to load) +- Responsive: tables scroll horizontally on mobile +- Color palette: monochrome with accent color for interactive elements +- File size target: < 5KB CSS + +--- + +## Flox Changes + +Add `templ` to the Flox manifest: + +```toml +[install] +go.pkg-path = "go" +go.version = "^1.25.7" +templ.pkg-path = "templ" +``` + +Add a Makefile target: + +```makefile +generate: + templ generate + +build: generate + go build -o msgvault ./cmd/msgvault +``` + +--- + +## Implementation Phases + +### Phase 1: Foundation +- [ ] Add `templ` to Flox manifest +- [ ] Create `internal/web/` package structure +- [ ] Vendor `htmx.min.js` into `static/` +- [ ] Write `layout.templ` — HTML shell with head, nav, content slot, stats bar +- [ ] Write `style.css` — base styles, table styles, responsive layout +- [ ] Write `server.go` — chi sub-router, static file serving via `go:embed` +- [ ] Wire into existing `api.Server` and `serve.go` command +- [ ] Initialize query engine in `serve` command (port logic from `tui.go`) +- [ ] `GET /` → dashboard with stats and account list +- [ ] Verify: `make build && ./msgvault serve` → browser shows dashboard + +### Phase 2: Browse & Drill-Down +- [ ] `aggregates.templ` — data table with view type selector +- [ ] `GET /browse?view=senders` — aggregate view with all 7 view types +- [ ] Clickable column headers for sort field + direction toggle +- [ ] Account/attachment/deleted filter controls in filter bar +- [ ] `GET /browse/drill?sender=x&view=recipients` — drill-down view +- [ ] Breadcrumb navigation showing drill path +- [ ] Time view with Year/Month/Day granularity toggle +- [ ] HTMX partials: table rows swap on view/sort/filter change +- [ ] Stats bar updates on filter changes + +### Phase 3: Messages & Detail +- [ ] `messages.templ` — message list table with pagination +- [ ] Click aggregate row → message list filtered by that key +- [ ] `message.templ` — full message detail (metadata, body, attachments) +- [ ] Prev/Next navigation between messages +- [ ] Thread view — list all messages in conversation +- [ ] Link from message detail to thread view +- [ ] Pagination controls (page numbers or infinite scroll) + +### Phase 4: Search +- [ ] `search_bar.templ` — persistent search input in header/nav +- [ ] Fast search with debounced HTMX requests (`delay:200ms`) +- [ ] Search results page with message list +- [ ] Fast/Deep mode toggle +- [ ] Search within aggregate views (filter aggregates by search term) +- [ ] Search result count and pagination + +### Phase 5: Deletion Staging +- [ ] Row checkboxes for selection +- [ ] "Select all on page" control +- [ ] "Stage for Deletion" button → confirmation modal +- [ ] Modal shows count, batch ID preview +- [ ] POST endpoint creates deletion manifest +- [ ] Success modal shows batch ID and `delete-staged` command + +### Phase 6: Polish +- [ ] Dark/light theme support via `prefers-color-scheme` +- [ ] Loading indicators for HTMX requests (`htmx:beforeRequest` class) +- [ ] Empty states (no messages, no results, no accounts) +- [ ] Error states (query failures, connection issues) +- [ ] Mobile responsive layout +- [ ] Keyboard shortcuts (optional, via small Alpine.js or vanilla JS) +- [ ] Cache-Control headers for static assets + +--- + +## What We're NOT Building + +- No user authentication system (API key only, same as existing) +- No real-time sync status (poll or manual refresh is fine) +- No inline email composition or reply +- No attachment download (CLI `export-attachment` command exists) +- No settings/config UI (edit `config.toml` directly) +- No WebSocket connections + +--- + +## File Count Estimate + +``` +New files: ~20 + templates: ~12 (.templ files) + Go: ~4 (server.go, handlers.go, helpers.go, helpers_test.go) + Static: ~2 (htmx.min.js, style.css) + Tests: ~2 (handlers_test.go, integration_test.go) + +Modified files: ~4 + api/server.go — mount web routes + cmd/.../serve.go — init query engine, pass to server + Makefile — add generate target + .flox/manifest.toml — add templ +``` + +--- + +## Key Design Decisions + +1. **Templ over html/template**: Type-safe, LSP support, compile-time errors, composable components. Worth the `templ generate` step. + +2. **HTMX over SPA**: The TUI's interaction model is request-response (user acts → data loads → view updates). HTMX models this perfectly. No client-side state management needed. + +3. **Same query engine, not the JSON API**: The existing JSON API uses the `Store` interface (direct SQLite) which is slower for aggregates. The TUI's `query.Engine` (DuckDB over Parquet) is ~3000x faster for the aggregate views that make up most of the UI. The web handlers call the engine directly, not the JSON API. + +4. **URL-driven state**: Every view state is a URL. No client-side routing, no localStorage state. Back button works. Links are shareable. Server always knows the full context. + +5. **No Alpine.js initially**: HTMX handles 95% of interactions. If we need client-side behavior (keyboard shortcuts, dropdown menus), we add a small vanilla JS file or Alpine.js later. + +6. **Vendor HTMX**: No CDN dependency. Single file embedded in binary. Works fully offline, which fits the "offline archive" philosophy. diff --git a/go.mod b/go.mod index 864fcb0c..b45b2498 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.7 require ( github.com/BurntSushi/toml v1.6.0 + github.com/a-h/templ v0.3.1001 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 diff --git a/go.sum b/go.sum index 37af13ea..efb7e1bf 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2Qx cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= +github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y= diff --git a/internal/api/server.go b/internal/api/server.go index 00e4149b..f645ec10 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -14,8 +14,10 @@ import ( "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" "github.com/wesm/msgvault/internal/config" + "github.com/wesm/msgvault/internal/query" "github.com/wesm/msgvault/internal/scheduler" "github.com/wesm/msgvault/internal/store" + "github.com/wesm/msgvault/internal/web" ) // MessageStore defines the store operations the API needs. @@ -46,6 +48,7 @@ type Server struct { cfg *config.Config store MessageStore scheduler SyncScheduler + engine query.Engine // optional: enables web UI when set logger *slog.Logger router chi.Router server *http.Server @@ -53,14 +56,27 @@ type Server struct { cfgMu sync.RWMutex // protects cfg.Accounts } +// ServerOption configures the API server. +type ServerOption func(*Server) + +// WithQueryEngine enables the web UI by providing a query engine. +func WithQueryEngine(engine query.Engine) ServerOption { + return func(s *Server) { + s.engine = engine + } +} + // NewServer creates a new API server. -func NewServer(cfg *config.Config, store MessageStore, sched SyncScheduler, logger *slog.Logger) *Server { +func NewServer(cfg *config.Config, store MessageStore, sched SyncScheduler, logger *slog.Logger, opts ...ServerOption) *Server { s := &Server{ cfg: cfg, store: store, scheduler: sched, logger: logger, } + for _, opt := range opts { + opt(s) + } s.router = s.setupRouter() return s } @@ -123,6 +139,15 @@ func (s *Server) setupRouter() chi.Router { r.Post("/auth/token/{email}", s.handleUploadToken) }) + // Web UI (enabled when query engine is provided) + if s.engine != nil { + webHandler := web.NewHandler(s.engine) + r.Group(func(r chi.Router) { + r.Use(s.authMiddleware) + r.Mount("/", webHandler.Routes()) + }) + } + return r } diff --git a/internal/web/handlers.go b/internal/web/handlers.go new file mode 100644 index 00000000..5a63b95b --- /dev/null +++ b/internal/web/handlers.go @@ -0,0 +1,55 @@ +package web + +import ( + "bytes" + "log/slog" + "net/http" + + "github.com/wesm/msgvault/internal/query" + "github.com/wesm/msgvault/internal/web/templates" +) + +func (h *Handler) handlePlaceholder(title, page string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + if err := templates.Placeholder(title, page).Render(r.Context(), &buf); err != nil { + slog.Error("failed to render placeholder", "error", err) + http.Error(w, "Failed to render page", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = buf.WriteTo(w) + } +} + +func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + stats, err := h.engine.GetTotalStats(ctx, query.StatsOptions{}) + if err != nil { + slog.Error("failed to get stats", "error", err) + http.Error(w, "Failed to load stats", http.StatusInternalServerError) + return + } + + accounts, err := h.engine.ListAccounts(ctx) + if err != nil { + slog.Error("failed to list accounts", "error", err) + http.Error(w, "Failed to load accounts", http.StatusInternalServerError) + return + } + + data := templates.DashboardData{ + Stats: stats, + Accounts: accounts, + } + + var buf bytes.Buffer + if err := templates.Dashboard(data).Render(ctx, &buf); err != nil { + slog.Error("failed to render dashboard", "error", err) + http.Error(w, "Failed to render page", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = buf.WriteTo(w) +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 00000000..1a2957c8 --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,47 @@ +// Package web provides the server-rendered web UI for msgvault. +package web + +import ( + "embed" + "fmt" + "io/fs" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/wesm/msgvault/internal/query" +) + +//go:embed static +var staticFS embed.FS + +// Handler serves the web UI. +type Handler struct { + engine query.Engine + staticFS fs.FS +} + +// NewHandler creates a new web UI handler. +func NewHandler(engine query.Engine) *Handler { + staticSub, err := fs.Sub(staticFS, "static") + if err != nil { + panic(fmt.Sprintf("web: failed to sub static FS: %v", err)) + } + return &Handler{engine: engine, staticFS: staticSub} +} + +// Routes returns a chi.Router with all web UI routes mounted. +func (h *Handler) Routes() chi.Router { + r := chi.NewRouter() + + // Static assets (no auth needed for CSS/JS) + fileServer := http.StripPrefix("/static/", http.FileServer(http.FS(h.staticFS))) + r.Handle("/static/*", fileServer) + + // Pages + r.Get("/", h.handleDashboard) + r.Get("/browse", h.handlePlaceholder("Browse", "browse")) + r.Get("/messages", h.handlePlaceholder("Messages", "messages")) + r.Get("/search", h.handlePlaceholder("Search", "search")) + + return r +} diff --git a/internal/web/static/htmx.min.js b/internal/web/static/htmx.min.js new file mode 100644 index 00000000..59937d71 --- /dev/null +++ b/internal/web/static/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/internal/web/static/style.css b/internal/web/static/style.css new file mode 100644 index 00000000..c8d3e531 --- /dev/null +++ b/internal/web/static/style.css @@ -0,0 +1,257 @@ +:root { + --bg: #fff; + --bg-alt: #f7f7f8; + --bg-hover: #ededef; + --fg: #1a1a1a; + --fg-muted: #6b6b6b; + --border: #d4d4d8; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --danger: #dc2626; + --font: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace; + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --radius: 4px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #18181b; + --bg-alt: #1f1f23; + --bg-hover: #2a2a2f; + --fg: #e4e4e7; + --fg-muted: #a1a1aa; + --border: #3f3f46; + --accent: #60a5fa; + --accent-hover: #93bbfd; + } +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; + color: var(--fg); + background: var(--bg); +} + +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-hover); text-decoration: underline; } + +/* Layout */ +.app { max-width: 1200px; margin: 0 auto; padding: 0 16px; } + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; + border-bottom: 1px solid var(--border); + margin-bottom: 16px; +} + +.header-title { + font-family: var(--font); + font-size: 16px; + font-weight: 700; + color: var(--fg); +} + +.header-nav { + display: flex; + gap: 16px; + align-items: center; +} + +.header-nav a { + color: var(--fg-muted); + font-size: 13px; + font-weight: 500; + padding: 4px 8px; + border-radius: var(--radius); +} + +.header-nav a:hover { + color: var(--fg); + background: var(--bg-hover); + text-decoration: none; +} + +.header-nav a.active { + color: var(--accent); + background: var(--bg-alt); +} + +/* Stats bar */ +.stats-bar { + display: flex; + gap: 24px; + padding: 8px 0; + font-size: 12px; + color: var(--fg-muted); + border-bottom: 1px solid var(--border); + margin-bottom: 16px; + font-family: var(--font); +} + +.stat-item { display: flex; gap: 4px; align-items: center; } +.stat-value { color: var(--fg); font-weight: 600; } + +/* Cards */ +.card { + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + margin-bottom: 12px; +} + +.card-title { + font-size: 13px; + font-weight: 600; + color: var(--fg-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; +} + +/* Dashboard grid */ +.dashboard-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + text-align: center; +} + +.stat-card-value { + font-family: var(--font); + font-size: 28px; + font-weight: 700; + color: var(--fg); + line-height: 1.2; +} + +.stat-card-label { + font-size: 12px; + color: var(--fg-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 4px; +} + +/* Account list */ +.account-list { list-style: none; } + +.account-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} + +.account-item:last-child { border-bottom: none; } + +.account-email { + font-family: var(--font); + font-size: 13px; + font-weight: 500; +} + +.account-meta { + font-size: 12px; + color: var(--fg-muted); +} + +/* Tables */ +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + font-family: var(--font); +} + +.data-table th { + text-align: left; + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + color: var(--fg-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 2px solid var(--border); + white-space: nowrap; + user-select: none; +} + +.data-table th a { + color: var(--fg-muted); + display: flex; + align-items: center; + gap: 4px; +} + +.data-table th a:hover { color: var(--fg); text-decoration: none; } + +.data-table td { + padding: 6px 12px; + border-bottom: 1px solid var(--border); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 400px; +} + +.data-table tbody tr:hover { background: var(--bg-hover); } + +.data-table .num { text-align: right; font-variant-numeric: tabular-nums; } + +/* Empty state */ +.empty-state { + text-align: center; + padding: 48px 16px; + color: var(--fg-muted); +} + +.empty-state-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 8px; + color: var(--fg); +} + +/* Loading indicator */ +.htmx-indicator { display: none; } +.htmx-request .htmx-indicator { display: inline-block; } +.htmx-request.htmx-indicator { display: inline-block; } + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +/* Footer */ +.footer { + padding: 16px 0; + margin-top: 24px; + border-top: 1px solid var(--border); + font-size: 12px; + color: var(--fg-muted); + text-align: center; +} diff --git a/internal/web/templates/dashboard.templ b/internal/web/templates/dashboard.templ new file mode 100644 index 00000000..d99b6012 --- /dev/null +++ b/internal/web/templates/dashboard.templ @@ -0,0 +1,80 @@ +package templates + +import "github.com/wesm/msgvault/internal/query" + +type DashboardData struct { + Stats *query.TotalStats + Accounts []query.AccountInfo +} + +templ Dashboard(data DashboardData) { + @Layout("Dashboard", "dashboard") { + if data.Stats != nil { + @StatsBar(data.Stats) +
+ @statCard(formatCount(data.Stats.MessageCount), "Messages") + @statCard(formatBytes(data.Stats.TotalSize), "Total Size") + @statCard(formatCount(data.Stats.AttachmentCount), "Attachments") + @statCard(formatBytes(data.Stats.AttachmentSize), "Attachment Size") + @statCard(formatCount(data.Stats.LabelCount), "Labels") + @statCard(formatCount(data.Stats.AccountCount), "Accounts") +
+ } +
+
Accounts
+ if len(data.Accounts) == 0 { +
+
No accounts configured
+

Run msgvault add-account you@gmail.com to get started.

+
+ } else { + + } +
+
+
Quick Links
+ +
+ } +} + +templ statCard(value string, label string) { +
+
{ value }
+
{ label }
+
+} + +templ StatsBar(stats *query.TotalStats) { +
+
+ { formatCount(stats.MessageCount) } msgs +
+
+ { formatBytes(stats.TotalSize) } total +
+
+ { formatCount(stats.AttachmentCount) } attachments +
+
+} diff --git a/internal/web/templates/dashboard_templ.go b/internal/web/templates/dashboard_templ.go new file mode 100644 index 00000000..9cb28bd0 --- /dev/null +++ b/internal/web/templates/dashboard_templ.go @@ -0,0 +1,277 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "github.com/wesm/msgvault/internal/query" + +type DashboardData struct { + Stats *query.TotalStats + Accounts []query.AccountInfo +} + +func Dashboard(data DashboardData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + if data.Stats != nil { + templ_7745c5c3_Err = StatsBar(data.Stats).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = statCard(formatCount(data.Stats.MessageCount), "Messages").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = statCard(formatBytes(data.Stats.TotalSize), "Total Size").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = statCard(formatCount(data.Stats.AttachmentCount), "Attachments").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = statCard(formatBytes(data.Stats.AttachmentSize), "Attachment Size").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = statCard(formatCount(data.Stats.LabelCount), "Labels").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = statCard(formatCount(data.Stats.AccountCount), "Accounts").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
Accounts
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data.Accounts) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
No accounts configured

Run msgvault add-account you@gmail.com to get started.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, acct := range data.Accounts { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(acct.Identifier) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 34, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(acct.SourceType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 35, Col: 51} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout("Dashboard", "dashboard").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func statCard(value string, label string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(value) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 63, Col: 38} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 64, Col: 38} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func StatsBar(stats *query.TotalStats) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(stats.MessageCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 71, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " msgs
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(stats.TotalSize)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 74, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " total
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(stats.AttachmentCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 77, Col: 64} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " attachments
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/helpers.go b/internal/web/templates/helpers.go new file mode 100644 index 00000000..d075f71c --- /dev/null +++ b/internal/web/templates/helpers.go @@ -0,0 +1,45 @@ +package templates + +import "fmt" + +// formatBytes formats a byte count into a human-readable string. +func formatBytes(b int64) string { + const ( + KB = 1024 + MB = 1024 * KB + GB = 1024 * MB + ) + switch { + case b >= GB: + return fmt.Sprintf("%.1f GB", float64(b)/float64(GB)) + case b >= MB: + return fmt.Sprintf("%.1f MB", float64(b)/float64(MB)) + case b >= KB: + return fmt.Sprintf("%.1f KB", float64(b)/float64(KB)) + default: + return fmt.Sprintf("%d B", b) + } +} + +// formatCount formats a large number with comma separators. +func formatCount(n int64) string { + if n < 0 { + return "-" + formatCount(-n) + } + if n < 1000 { + return fmt.Sprintf("%d", n) + } + + s := fmt.Sprintf("%d", n) + result := make([]byte, 0, len(s)+len(s)/3) + rem := len(s) % 3 + if rem == 0 { + rem = 3 + } + result = append(result, s[:rem]...) + for i := rem; i < len(s); i += 3 { + result = append(result, ',') + result = append(result, s[i:i+3]...) + } + return string(result) +} diff --git a/internal/web/templates/layout.templ b/internal/web/templates/layout.templ new file mode 100644 index 00000000..eafedba9 --- /dev/null +++ b/internal/web/templates/layout.templ @@ -0,0 +1,32 @@ +package templates + +templ Layout(title string, activePage string) { + + + + + + { title } - msgvault + + + + + +
+
+ msgvault + +
+ { children... } +
+ msgvault +
+
+ + +} diff --git a/internal/web/templates/layout_templ.go b/internal/web/templates/layout_templ.go new file mode 100644 index 00000000..0904a9fa --- /dev/null +++ b/internal/web/templates/layout_templ.go @@ -0,0 +1,149 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func Layout(title string, activePage string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 9, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - msgvault
msgvault
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
msgvault
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/placeholder.templ b/internal/web/templates/placeholder.templ new file mode 100644 index 00000000..190050ce --- /dev/null +++ b/internal/web/templates/placeholder.templ @@ -0,0 +1,10 @@ +package templates + +templ Placeholder(title string, activePage string) { + @Layout(title, activePage) { +
+
{ title }
+

Coming soon.

+
+ } +} diff --git a/internal/web/templates/placeholder_templ.go b/internal/web/templates/placeholder_templ.go new file mode 100644 index 00000000..82203299 --- /dev/null +++ b/internal/web/templates/placeholder_templ.go @@ -0,0 +1,71 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func Placeholder(title string, activePage string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/placeholder.templ`, Line: 6, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

Coming soon.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout(title, activePage).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate From 4fa68469f9dfda0086ad6c139d6b8b3d09fd63f9 Mon Sep 17 00:00:00 2001 From: dolatron Date: Sun, 8 Mar 2026 09:35:19 -0400 Subject: [PATCH 02/17] Add browse and drill-down views with aggregate tables (Phase 2) --- internal/web/handlers.go | 123 +++ internal/web/params.go | 188 ++++ internal/web/server.go | 3 +- internal/web/templates/aggregates.templ | 403 ++++++++ internal/web/templates/aggregates_templ.go | 1067 ++++++++++++++++++++ internal/web/templates/helpers.go | 30 +- 6 files changed, 1812 insertions(+), 2 deletions(-) create mode 100644 internal/web/params.go create mode 100644 internal/web/templates/aggregates.templ create mode 100644 internal/web/templates/aggregates_templ.go diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 5a63b95b..03a42c92 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -53,3 +53,126 @@ func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = buf.WriteTo(w) } + +func (h *Handler) handleBrowse(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + viewType := parseViewType(r) + opts := parseAggregateOptions(r) + + rows, err := h.engine.Aggregate(ctx, viewType, opts) + if err != nil { + slog.Error("failed to aggregate", "error", err, "view", viewType) + http.Error(w, "Failed to load data", http.StatusInternalServerError) + return + } + + stats, err := h.engine.GetTotalStats(ctx, query.StatsOptions{ + SourceID: opts.SourceID, + WithAttachmentsOnly: opts.WithAttachmentsOnly, + HideDeletedFromSource: opts.HideDeletedFromSource, + }) + if err != nil { + slog.Error("failed to get stats", "error", err) + } + + data := templates.BrowseData{ + Stats: stats, + Rows: rows, + ViewType: viewTypeToString(viewType), + ViewLabel: viewType.String(), + SortField: sortFieldToString(opts.SortField), + SortDir: sortDirToString(opts.SortDirection), + Granularity: timeGranularityToString(opts.TimeGranularity), + AccountID: r.URL.Query().Get("account"), + Attachments: opts.WithAttachmentsOnly, + HideDeleted: opts.HideDeletedFromSource, + } + + var buf bytes.Buffer + if err := templates.Aggregates(data).Render(ctx, &buf); err != nil { + slog.Error("failed to render aggregates", "error", err) + http.Error(w, "Failed to render page", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = buf.WriteTo(w) +} + +func (h *Handler) handleDrill(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + viewType := parseViewType(r) + opts := parseAggregateOptions(r) + filter := parseDrillFilter(r) + + rows, err := h.engine.SubAggregate(ctx, filter, viewType, opts) + if err != nil { + slog.Error("failed to sub-aggregate", "error", err, "view", viewType) + http.Error(w, "Failed to load data", http.StatusInternalServerError) + return + } + + stats, err := h.engine.GetTotalStats(ctx, query.StatsOptions{ + SourceID: opts.SourceID, + WithAttachmentsOnly: opts.WithAttachmentsOnly, + HideDeletedFromSource: opts.HideDeletedFromSource, + }) + if err != nil { + slog.Error("failed to get stats", "error", err) + } + + // Build drill filters map from current request params (deterministic order) + drillFilters := make(map[string]string) + drillKeys := []string{"sender", "sender_name", "recipient", "recipient_name", "domain", "label", "time_period"} + for _, key := range drillKeys { + if _, ok := r.URL.Query()[key]; ok { + drillFilters[key] = r.URL.Query().Get(key) + } + } + + // Build breadcrumbs with full state preservation + browseURL := templates.BrowseData{ + ViewType: viewTypeToString(viewType), + SortField: sortFieldToString(opts.SortField), + SortDir: sortDirToString(opts.SortDirection), + Granularity: timeGranularityToString(opts.TimeGranularity), + AccountID: r.URL.Query().Get("account"), + Attachments: opts.WithAttachmentsOnly, + HideDeleted: opts.HideDeletedFromSource, + } + breadcrumbs := []templates.Breadcrumb{ + {Label: "Browse", URL: browseURL.ViewTabURL(viewTypeToString(viewType))}, + } + for _, key := range drillKeys { + if v, ok := drillFilters[key]; ok { + label := key + ": " + v + if v == "" { + label = key + ": (empty)" + } + breadcrumbs = append(breadcrumbs, templates.Breadcrumb{Label: label}) + } + } + + data := templates.BrowseData{ + Stats: stats, + Rows: rows, + ViewType: viewTypeToString(viewType), + ViewLabel: viewType.String(), + SortField: sortFieldToString(opts.SortField), + SortDir: sortDirToString(opts.SortDirection), + Granularity: timeGranularityToString(opts.TimeGranularity), + AccountID: r.URL.Query().Get("account"), + Attachments: opts.WithAttachmentsOnly, + HideDeleted: opts.HideDeletedFromSource, + DrillFilters: drillFilters, + Breadcrumbs: breadcrumbs, + } + + var buf bytes.Buffer + if err := templates.Aggregates(data).Render(ctx, &buf); err != nil { + slog.Error("failed to render drill-down", "error", err) + http.Error(w, "Failed to render page", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = buf.WriteTo(w) +} diff --git a/internal/web/params.go b/internal/web/params.go new file mode 100644 index 00000000..3d0fc83b --- /dev/null +++ b/internal/web/params.go @@ -0,0 +1,188 @@ +package web + +import ( + "net/http" + "strconv" + + "github.com/wesm/msgvault/internal/query" +) + +func parseViewType(r *http.Request) query.ViewType { + switch r.URL.Query().Get("view") { + case "senders": + return query.ViewSenders + case "sender_names": + return query.ViewSenderNames + case "recipients": + return query.ViewRecipients + case "recipient_names": + return query.ViewRecipientNames + case "domains": + return query.ViewDomains + case "labels": + return query.ViewLabels + case "time": + return query.ViewTime + default: + return query.ViewSenders + } +} + +func viewTypeToString(v query.ViewType) string { + switch v { + case query.ViewSenders: + return "senders" + case query.ViewSenderNames: + return "sender_names" + case query.ViewRecipients: + return "recipients" + case query.ViewRecipientNames: + return "recipient_names" + case query.ViewDomains: + return "domains" + case query.ViewLabels: + return "labels" + case query.ViewTime: + return "time" + default: + return "senders" + } +} + +func parseSortField(r *http.Request) query.SortField { + switch r.URL.Query().Get("sort") { + case "count": + return query.SortByCount + case "size": + return query.SortBySize + case "attachments": + return query.SortByAttachmentSize + case "name": + return query.SortByName + default: + return query.SortByCount + } +} + +func sortFieldToString(f query.SortField) string { + switch f { + case query.SortByCount: + return "count" + case query.SortBySize: + return "size" + case query.SortByAttachmentSize: + return "attachments" + case query.SortByName: + return "name" + default: + return "count" + } +} + +func parseSortDirection(r *http.Request) query.SortDirection { + if r.URL.Query().Get("dir") == "asc" { + return query.SortAsc + } + return query.SortDesc +} + +func sortDirToString(d query.SortDirection) string { + if d == query.SortAsc { + return "asc" + } + return "desc" +} + +func parseTimeGranularity(r *http.Request) query.TimeGranularity { + switch r.URL.Query().Get("granularity") { + case "year": + return query.TimeYear + case "month": + return query.TimeMonth + case "day": + return query.TimeDay + default: + return query.TimeMonth + } +} + +func timeGranularityToString(g query.TimeGranularity) string { + switch g { + case query.TimeYear: + return "year" + case query.TimeMonth: + return "month" + case query.TimeDay: + return "day" + default: + return "month" + } +} + +func parseOptionalInt64(r *http.Request, key string) *int64 { + s := r.URL.Query().Get(key) + if s == "" { + return nil + } + v, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return nil + } + return &v +} + +func parseBool(r *http.Request, key string) bool { + return r.URL.Query().Get(key) == "1" +} + +func parseAggregateOptions(r *http.Request) query.AggregateOptions { + opts := query.DefaultAggregateOptions() + opts.SortField = parseSortField(r) + opts.SortDirection = parseSortDirection(r) + opts.SourceID = parseOptionalInt64(r, "account") + opts.WithAttachmentsOnly = parseBool(r, "attachments") + opts.HideDeletedFromSource = parseBool(r, "hide_deleted") + opts.TimeGranularity = parseTimeGranularity(r) + opts.Limit = 500 + return opts +} + +func parseDrillFilter(r *http.Request) query.MessageFilter { + q := r.URL.Query() + f := query.MessageFilter{ + Sender: q.Get("sender"), + SenderName: q.Get("sender_name"), + Recipient: q.Get("recipient"), + RecipientName: q.Get("recipient_name"), + Domain: q.Get("domain"), + Label: q.Get("label"), + SourceID: parseOptionalInt64(r, "account"), + WithAttachmentsOnly: parseBool(r, "attachments"), + HideDeletedFromSource: parseBool(r, "hide_deleted"), + } + + // Handle empty-key drill-down: when a filter param is present but empty, + // set EmptyValueTargets so the query engine filters for NULL/empty values. + emptyTargets := map[string]query.ViewType{ + "sender": query.ViewSenders, + "sender_name": query.ViewSenderNames, + "recipient": query.ViewRecipients, + "recipient_name": query.ViewRecipientNames, + "domain": query.ViewDomains, + "label": query.ViewLabels, + } + for param, viewType := range emptyTargets { + if _, ok := q[param]; ok && q.Get(param) == "" { + f.SetEmptyTarget(viewType) + } + } + + timePeriod := q.Get("time_period") + if timePeriod != "" { + f.TimeRange = query.TimeRange{ + Period: timePeriod, + Granularity: parseTimeGranularity(r), + } + } + return f +} diff --git a/internal/web/server.go b/internal/web/server.go index 1a2957c8..2b3c5a38 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -39,7 +39,8 @@ func (h *Handler) Routes() chi.Router { // Pages r.Get("/", h.handleDashboard) - r.Get("/browse", h.handlePlaceholder("Browse", "browse")) + r.Get("/browse", h.handleBrowse) + r.Get("/browse/drill", h.handleDrill) r.Get("/messages", h.handlePlaceholder("Messages", "messages")) r.Get("/search", h.handlePlaceholder("Search", "search")) diff --git a/internal/web/templates/aggregates.templ b/internal/web/templates/aggregates.templ new file mode 100644 index 00000000..20a3aeb7 --- /dev/null +++ b/internal/web/templates/aggregates.templ @@ -0,0 +1,403 @@ +package templates + +import ( + "fmt" + "net/url" + "sort" + "github.com/wesm/msgvault/internal/query" +) + +type BrowseData struct { + Stats *query.TotalStats + Rows []query.AggregateRow + ViewType string + ViewLabel string + SortField string + SortDir string + Granularity string + AccountID string + Attachments bool + HideDeleted bool + // Drill-down context + DrillFilters map[string]string + Breadcrumbs []Breadcrumb +} + +type Breadcrumb struct { + Label string + URL string +} + +// buildURL constructs a URL with properly encoded query parameters. +func buildURL(path string, params ...string) string { + u := url.URL{Path: path} + q := u.Query() + for i := 0; i+1 < len(params); i += 2 { + if params[i+1] != "" { + q.Set(params[i], params[i+1]) + } + } + u.RawQuery = q.Encode() + return u.String() +} + +// addFilterParams appends the common filter params to a url.Values. +func (d BrowseData) addFilterParams(q url.Values) { + if d.AccountID != "" { + q.Set("account", d.AccountID) + } + if d.Attachments { + q.Set("attachments", "1") + } + if d.HideDeleted { + q.Set("hide_deleted", "1") + } +} + +// addDrillParams appends drill filter params to a url.Values. +func (d BrowseData) addDrillParams(q url.Values) { + for k, v := range d.DrillFilters { + q.Set(k, v) + } +} + +// currentBase returns the current page URL with all state preserved. +func (d BrowseData) currentBase() string { + var path string + if len(d.DrillFilters) > 0 { + path = "/browse/drill" + } else { + path = "/browse" + } + q := url.Values{} + q.Set("view", d.ViewType) + q.Set("sort", d.SortField) + q.Set("dir", d.SortDir) + if d.Granularity != "" { + q.Set("granularity", d.Granularity) + } + d.addDrillParams(q) + d.addFilterParams(q) + return path + "?" + q.Encode() +} + +// sortURL returns the URL for clicking a column header to sort. +func (d BrowseData) sortURL(field string) string { + dir := "desc" + if d.SortField == field && d.SortDir == "desc" { + dir = "asc" + } + var path string + if len(d.DrillFilters) > 0 { + path = "/browse/drill" + } else { + path = "/browse" + } + q := url.Values{} + q.Set("view", d.ViewType) + q.Set("sort", field) + q.Set("dir", dir) + if d.Granularity != "" { + q.Set("granularity", d.Granularity) + } + d.addDrillParams(q) + d.addFilterParams(q) + return path + "?" + q.Encode() +} + +// sortIndicator returns the sort arrow for a column header. +func (d BrowseData) sortIndicator(field string) string { + if d.SortField != field { + return "" + } + if d.SortDir == "asc" { + return " ↑" + } + return " ↓" +} + +// drillURL returns the URL for drilling into an aggregate row. +func (d BrowseData) drillURL(key string) string { + filterKey := viewTypeToFilterParam(d.ViewType) + q := url.Values{} + q.Set("view", d.ViewType) + if key == "" { + q.Set(filterKey, "") + q.Set("empty_"+filterKey, "1") + } else { + q.Set(filterKey, key) + } + d.addDrillParams(q) + if d.Granularity != "" { + q.Set("granularity", d.Granularity) + } + d.addFilterParams(q) + return "/browse/drill?" + q.Encode() +} + +// messagesURL returns the URL for viewing messages matching a filter. +func (d BrowseData) messagesURL(key string) string { + filterKey := viewTypeToFilterParam(d.ViewType) + q := url.Values{} + if key == "" { + q.Set(filterKey, "") + q.Set("empty_"+filterKey, "1") + } else { + q.Set(filterKey, key) + } + d.addDrillParams(q) + d.addFilterParams(q) + return "/messages?" + q.Encode() +} + +// ViewTabURL returns the URL for switching to a different view type. +func (d BrowseData) ViewTabURL(viewType string) string { + q := url.Values{} + q.Set("view", viewType) + q.Set("sort", d.SortField) + q.Set("dir", d.SortDir) + d.addFilterParams(q) + return "/browse?" + q.Encode() +} + +// granularityTabURL returns the URL for switching time granularity. +func (d BrowseData) granularityTabURL(granularity string) string { + q := url.Values{} + q.Set("view", "time") + q.Set("granularity", granularity) + q.Set("sort", d.SortField) + q.Set("dir", d.SortDir) + d.addFilterParams(q) + return "/browse?" + q.Encode() +} + +// sortedDrillKeys returns drill filter keys in deterministic order. +func (d BrowseData) sortedDrillKeys() []string { + keys := make([]string, 0, len(d.DrillFilters)) + for k := range d.DrillFilters { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// filterToggleURL returns the URL to toggle a boolean filter on or off. +func (d BrowseData) filterToggleURL(key string, currentlyOn bool) string { + base := d.currentBase() + if currentlyOn { + return deleteParam(base, key) + } + return addParam(base, key, "1") +} + +func viewTypeToFilterParam(viewType string) string { + switch viewType { + case "senders": + return "sender" + case "sender_names": + return "sender_name" + case "recipients": + return "recipient" + case "recipient_names": + return "recipient_name" + case "domains": + return "domain" + case "labels": + return "label" + case "time": + return "time_period" + default: + return "sender" + } +} + +templ Aggregates(data BrowseData) { + @Layout(data.ViewLabel, "browse") { + if data.Stats != nil { + @StatsBar(data.Stats) + } +
+
+ @viewSelector(data) + if data.ViewType == "time" { + @granularitySelector(data) + } +
+ @filterControls(data) +
+ if len(data.Breadcrumbs) > 0 { + + } + if len(data.Rows) == 0 { +
+
No data
+

No messages match the current filters.

+
+ } else { + @AggregateTable(data) + } + } +} + +templ AggregateTable(data BrowseData) { +
+ + + + + + + + + + for _, row := range data.Rows { + + + + + + + } + +
+ + { data.ViewLabel }{ data.sortIndicator("name") } + + + + Count{ data.sortIndicator("count") } + + + + Size{ data.sortIndicator("size") } + + + + Attachments{ data.sortIndicator("attachments") } + +
+ + if row.Key == "" { + (empty) + } else { + { row.Key } + } + + + + { formatCount(row.Count) } + + { formatBytes(row.TotalSize) } + if row.AttachmentCount > 0 { + { formatCount(row.AttachmentCount) } ({ formatBytes(row.AttachmentSize) }) + } else { + { fmt.Sprintf("-") } + } +
+ if len(data.Rows) > 0 { +
+ Showing { formatCount(int64(len(data.Rows))) } of { formatCount(data.Rows[0].TotalUnique) } unique entries +
+ } +} + +templ viewSelector(data BrowseData) { +
+ @viewTab("Senders", "senders", data) + @viewTab("Names", "sender_names", data) + @viewTab("Recipients", "recipients", data) + @viewTab("Domains", "domains", data) + @viewTab("Labels", "labels", data) + @viewTab("Time", "time", data) +
+} + +templ viewTab(label string, viewType string, data BrowseData) { + if data.ViewType == viewType { + + { label } + + } else { + + { label } + + } +} + +templ granularitySelector(data BrowseData) { +
+ @granularityTab("Year", "year", data) + @granularityTab("Month", "month", data) + @granularityTab("Day", "day", data) +
+} + +templ granularityTab(label string, granularity string, data BrowseData) { + if data.Granularity == granularity { + + { label } + + } else { + + { label } + + } +} + +templ filterControls(data BrowseData) { +
+ if data.Attachments { + + Attachments Only + + } else { + + Attachments Only + + } + if data.HideDeleted { + + Hide Deleted + + } else { + + Hide Deleted + + } +
+} diff --git a/internal/web/templates/aggregates_templ.go b/internal/web/templates/aggregates_templ.go new file mode 100644 index 00000000..225a2637 --- /dev/null +++ b/internal/web/templates/aggregates_templ.go @@ -0,0 +1,1067 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "github.com/wesm/msgvault/internal/query" + "net/url" + "sort" +) + +type BrowseData struct { + Stats *query.TotalStats + Rows []query.AggregateRow + ViewType string + ViewLabel string + SortField string + SortDir string + Granularity string + AccountID string + Attachments bool + HideDeleted bool + // Drill-down context + DrillFilters map[string]string + Breadcrumbs []Breadcrumb +} + +type Breadcrumb struct { + Label string + URL string +} + +// buildURL constructs a URL with properly encoded query parameters. +func buildURL(path string, params ...string) string { + u := url.URL{Path: path} + q := u.Query() + for i := 0; i+1 < len(params); i += 2 { + if params[i+1] != "" { + q.Set(params[i], params[i+1]) + } + } + u.RawQuery = q.Encode() + return u.String() +} + +// addFilterParams appends the common filter params to a url.Values. +func (d BrowseData) addFilterParams(q url.Values) { + if d.AccountID != "" { + q.Set("account", d.AccountID) + } + if d.Attachments { + q.Set("attachments", "1") + } + if d.HideDeleted { + q.Set("hide_deleted", "1") + } +} + +// addDrillParams appends drill filter params to a url.Values. +func (d BrowseData) addDrillParams(q url.Values) { + for k, v := range d.DrillFilters { + q.Set(k, v) + } +} + +// currentBase returns the current page URL with all state preserved. +func (d BrowseData) currentBase() string { + var path string + if len(d.DrillFilters) > 0 { + path = "/browse/drill" + } else { + path = "/browse" + } + q := url.Values{} + q.Set("view", d.ViewType) + q.Set("sort", d.SortField) + q.Set("dir", d.SortDir) + if d.Granularity != "" { + q.Set("granularity", d.Granularity) + } + d.addDrillParams(q) + d.addFilterParams(q) + return path + "?" + q.Encode() +} + +// sortURL returns the URL for clicking a column header to sort. +func (d BrowseData) sortURL(field string) string { + dir := "desc" + if d.SortField == field && d.SortDir == "desc" { + dir = "asc" + } + var path string + if len(d.DrillFilters) > 0 { + path = "/browse/drill" + } else { + path = "/browse" + } + q := url.Values{} + q.Set("view", d.ViewType) + q.Set("sort", field) + q.Set("dir", dir) + if d.Granularity != "" { + q.Set("granularity", d.Granularity) + } + d.addDrillParams(q) + d.addFilterParams(q) + return path + "?" + q.Encode() +} + +// sortIndicator returns the sort arrow for a column header. +func (d BrowseData) sortIndicator(field string) string { + if d.SortField != field { + return "" + } + if d.SortDir == "asc" { + return " ↑" + } + return " ↓" +} + +// drillURL returns the URL for drilling into an aggregate row. +func (d BrowseData) drillURL(key string) string { + filterKey := viewTypeToFilterParam(d.ViewType) + q := url.Values{} + q.Set("view", d.ViewType) + if key == "" { + q.Set(filterKey, "") + q.Set("empty_"+filterKey, "1") + } else { + q.Set(filterKey, key) + } + d.addDrillParams(q) + if d.Granularity != "" { + q.Set("granularity", d.Granularity) + } + d.addFilterParams(q) + return "/browse/drill?" + q.Encode() +} + +// messagesURL returns the URL for viewing messages matching a filter. +func (d BrowseData) messagesURL(key string) string { + filterKey := viewTypeToFilterParam(d.ViewType) + q := url.Values{} + if key == "" { + q.Set(filterKey, "") + q.Set("empty_"+filterKey, "1") + } else { + q.Set(filterKey, key) + } + d.addDrillParams(q) + d.addFilterParams(q) + return "/messages?" + q.Encode() +} + +// ViewTabURL returns the URL for switching to a different view type. +func (d BrowseData) ViewTabURL(viewType string) string { + q := url.Values{} + q.Set("view", viewType) + q.Set("sort", d.SortField) + q.Set("dir", d.SortDir) + d.addFilterParams(q) + return "/browse?" + q.Encode() +} + +// granularityTabURL returns the URL for switching time granularity. +func (d BrowseData) granularityTabURL(granularity string) string { + q := url.Values{} + q.Set("view", "time") + q.Set("granularity", granularity) + q.Set("sort", d.SortField) + q.Set("dir", d.SortDir) + d.addFilterParams(q) + return "/browse?" + q.Encode() +} + +// sortedDrillKeys returns drill filter keys in deterministic order. +func (d BrowseData) sortedDrillKeys() []string { + keys := make([]string, 0, len(d.DrillFilters)) + for k := range d.DrillFilters { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// filterToggleURL returns the URL to toggle a boolean filter on or off. +func (d BrowseData) filterToggleURL(key string, currentlyOn bool) string { + base := d.currentBase() + if currentlyOn { + return deleteParam(base, key) + } + return addParam(base, key, "1") +} + +func viewTypeToFilterParam(viewType string) string { + switch viewType { + case "senders": + return "sender" + case "sender_names": + return "sender_name" + case "recipients": + return "recipient" + case "recipient_names": + return "recipient_name" + case "domains": + return "domain" + case "labels": + return "label" + case "time": + return "time_period" + default: + return "sender" + } +} + +func Aggregates(data BrowseData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + if data.Stats != nil { + templ_7745c5c3_Err = StatsBar(data.Stats).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = viewSelector(data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.ViewType == "time" { + templ_7745c5c3_Err = granularitySelector(data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = filterControls(data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data.Breadcrumbs) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data.Rows) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
No data

No messages match the current filters.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = AggregateTable(data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) + templ_7745c5c3_Err = Layout(data.ViewLabel, "browse").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func AggregateTable(data BrowseData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var6 := templ.GetChildren(ctx) + if templ_7745c5c3_Var6 == nil { + templ_7745c5c3_Var6 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, row := range data.Rows { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.ViewLabel) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 259, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(data.sortIndicator("name")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 259, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Count") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(data.sortIndicator("count")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 264, Col: 40} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Size") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(data.sortIndicator("size")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 269, Col: 38} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "Attachments") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(data.sortIndicator("attachments")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 274, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if row.Key == "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "(empty)") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(row.Key) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 287, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(row.Count)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 293, Col: 31} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(row.TotalSize)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 296, Col: 49} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if row.AttachmentCount > 0 { + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(row.AttachmentCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 299, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(row.AttachmentSize)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 299, Col: 78} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ")") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("-")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 301, Col: 25} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data.Rows) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
Showing ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(int64(len(data.Rows)))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 310, Col: 47} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " of ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(data.Rows[0].TotalUnique)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 310, Col: 92} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " unique entries
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func viewSelector(data BrowseData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var26 := templ.GetChildren(ctx) + if templ_7745c5c3_Var26 == nil { + templ_7745c5c3_Var26 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = viewTab("Senders", "senders", data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = viewTab("Names", "sender_names", data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = viewTab("Recipients", "recipients", data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = viewTab("Domains", "domains", data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = viewTab("Labels", "labels", data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = viewTab("Time", "time", data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func viewTab(label string, viewType string, data BrowseData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var27 := templ.GetChildren(ctx) + if templ_7745c5c3_Var27 == nil { + templ_7745c5c3_Var27 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if data.ViewType == viewType { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 332, Col: 10} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 339, Col: 10} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func granularitySelector(data BrowseData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var32 := templ.GetChildren(ctx) + if templ_7745c5c3_Var32 == nil { + templ_7745c5c3_Var32 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = granularityTab("Year", "year", data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = granularityTab("Month", "month", data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = granularityTab("Day", "day", data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func granularityTab(label string, granularity string, data BrowseData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var33 := templ.GetChildren(ctx) + if templ_7745c5c3_Var33 == nil { + templ_7745c5c3_Var33 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if data.Granularity == granularity { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 358, Col: 10} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var37 string + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 365, Col: 10} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func filterControls(data BrowseData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var38 := templ.GetChildren(ctx) + if templ_7745c5c3_Var38 == nil { + templ_7745c5c3_Var38 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Attachments { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "Attachments Only ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "Attachments Only ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.HideDeleted { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "Hide Deleted") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "Hide Deleted") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/helpers.go b/internal/web/templates/helpers.go index d075f71c..f5e91595 100644 --- a/internal/web/templates/helpers.go +++ b/internal/web/templates/helpers.go @@ -1,6 +1,10 @@ package templates -import "fmt" +import ( + "fmt" + "net/url" + "strings" +) // formatBytes formats a byte count into a human-readable string. func formatBytes(b int64) string { @@ -43,3 +47,27 @@ func formatCount(n int64) string { } return string(result) } + +// addParam appends a query parameter to a URL string. +func addParam(base, key, value string) string { + if value == "" { + return base + } + sep := "&" + if !strings.Contains(base, "?") { + sep = "?" + } + return base + sep + url.QueryEscape(key) + "=" + url.QueryEscape(value) +} + +// deleteParam removes a query parameter from a URL string. +func deleteParam(base, key string) string { + u, err := url.Parse(base) + if err != nil { + return base + } + q := u.Query() + q.Del(key) + u.RawQuery = q.Encode() + return u.String() +} From 6ce434f21ea5c0fbc95372095b39ef80b71c0553 Mon Sep 17 00:00:00 2001 From: dolatron Date: Sun, 8 Mar 2026 09:46:28 -0400 Subject: [PATCH 03/17] Add message list and detail views (Phase 3) --- internal/web/handlers.go | 101 +++ internal/web/params.go | 93 +++ internal/web/server.go | 3 +- internal/web/templates/helpers.go | 22 + internal/web/templates/message_detail.templ | 133 ++++ .../web/templates/message_detail_templ.go | 441 +++++++++++++ internal/web/templates/messages.templ | 238 +++++++ internal/web/templates/messages_templ.go | 600 ++++++++++++++++++ 8 files changed, 1630 insertions(+), 1 deletion(-) create mode 100644 internal/web/templates/message_detail.templ create mode 100644 internal/web/templates/message_detail_templ.go create mode 100644 internal/web/templates/messages.templ create mode 100644 internal/web/templates/messages_templ.go diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 03a42c92..976de452 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -4,7 +4,10 @@ import ( "bytes" "log/slog" "net/http" + "net/url" + "strconv" + "github.com/go-chi/chi/v5" "github.com/wesm/msgvault/internal/query" "github.com/wesm/msgvault/internal/web/templates" ) @@ -176,3 +179,101 @@ func (h *Handler) handleDrill(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = buf.WriteTo(w) } + +func (h *Handler) handleMessages(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + filter := parseMessageFilter(r) + page := parsePage(r) + + // Fetch one extra row to detect if there are more pages + pageSize := filter.Pagination.Limit + filter.Pagination.Limit = pageSize + 1 + + messages, err := h.engine.ListMessages(ctx, filter) + if err != nil { + slog.Error("failed to list messages", "error", err) + http.Error(w, "Failed to load messages", http.StatusInternalServerError) + return + } + + hasMore := len(messages) > pageSize + if hasMore { + messages = messages[:pageSize] + } + + // Build filter map for template URL construction + filters := make(map[string]string) + filterKeys := []string{"sender", "sender_name", "recipient", "recipient_name", "domain", "label", "time_period", "conversation"} + for _, key := range filterKeys { + if _, ok := r.URL.Query()[key]; ok { + filters[key] = r.URL.Query().Get(key) + } + } + + data := templates.MessagesData{ + Messages: messages, + Page: page, + PageSize: pageSize, + HasMore: hasMore, + SortField: messageSortFieldToString(filter.Sorting.Field), + SortDir: sortDirToString(filter.Sorting.Direction), + Filters: filters, + AccountID: r.URL.Query().Get("account"), + Attachments: filter.WithAttachmentsOnly, + HideDeleted: filter.HideDeletedFromSource, + } + + var buf bytes.Buffer + if err := templates.Messages(data).Render(ctx, &buf); err != nil { + slog.Error("failed to render messages", "error", err) + http.Error(w, "Failed to render page", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = buf.WriteTo(w) +} + +func (h *Handler) handleMessageDetail(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + http.Error(w, "Invalid message ID", http.StatusBadRequest) + return + } + + msg, err := h.engine.GetMessage(ctx, id) + if err != nil { + slog.Error("failed to get message", "error", err, "id", id) + http.Error(w, "Failed to load message", http.StatusInternalServerError) + return + } + if msg == nil { + http.Error(w, "Message not found", http.StatusNotFound) + return + } + + // Build back URL from referer, restricted to same-origin paths only + backURL := "/messages" + if ref := r.Header.Get("Referer"); ref != "" { + if u, err := url.Parse(ref); err == nil && u.Host == "" { + backURL = ref + } else if err == nil && u.Host == r.Host { + backURL = u.RequestURI() + } + } + + data := templates.MessageDetailData{ + Message: msg, + BackURL: backURL, + } + + var buf bytes.Buffer + if err := templates.MessageDetailPage(data).Render(ctx, &buf); err != nil { + slog.Error("failed to render message detail", "error", err) + http.Error(w, "Failed to render page", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = buf.WriteTo(w) +} diff --git a/internal/web/params.go b/internal/web/params.go index 3d0fc83b..15f1a99c 100644 --- a/internal/web/params.go +++ b/internal/web/params.go @@ -147,6 +147,99 @@ func parseAggregateOptions(r *http.Request) query.AggregateOptions { return opts } +func parseMessageSortField(r *http.Request) query.MessageSortField { + switch r.URL.Query().Get("sort") { + case "date": + return query.MessageSortByDate + case "size": + return query.MessageSortBySize + case "subject": + return query.MessageSortBySubject + default: + return query.MessageSortByDate + } +} + +func messageSortFieldToString(f query.MessageSortField) string { + switch f { + case query.MessageSortByDate: + return "date" + case query.MessageSortBySize: + return "size" + case query.MessageSortBySubject: + return "subject" + default: + return "date" + } +} + +func parsePage(r *http.Request) int { + s := r.URL.Query().Get("page") + if s == "" { + return 1 + } + p, err := strconv.Atoi(s) + if err != nil || p < 1 { + return 1 + } + return p +} + +func parseMessageFilter(r *http.Request) query.MessageFilter { + q := r.URL.Query() + f := query.MessageFilter{ + Sender: q.Get("sender"), + SenderName: q.Get("sender_name"), + Recipient: q.Get("recipient"), + RecipientName: q.Get("recipient_name"), + Domain: q.Get("domain"), + Label: q.Get("label"), + SourceID: parseOptionalInt64(r, "account"), + WithAttachmentsOnly: parseBool(r, "attachments"), + HideDeletedFromSource: parseBool(r, "hide_deleted"), + Sorting: query.MessageSorting{ + Field: parseMessageSortField(r), + Direction: parseSortDirection(r), + }, + } + + // Handle empty-key filters + emptyTargets := map[string]query.ViewType{ + "sender": query.ViewSenders, + "sender_name": query.ViewSenderNames, + "recipient": query.ViewRecipients, + "recipient_name": query.ViewRecipientNames, + "domain": query.ViewDomains, + "label": query.ViewLabels, + } + for param, viewType := range emptyTargets { + if _, ok := q[param]; ok && q.Get(param) == "" { + f.SetEmptyTarget(viewType) + } + } + + timePeriod := q.Get("time_period") + if timePeriod != "" { + f.TimeRange = query.TimeRange{ + Period: timePeriod, + Granularity: parseTimeGranularity(r), + } + } + + convID := parseOptionalInt64(r, "conversation") + if convID != nil { + f.ConversationID = convID + } + + page := parsePage(r) + f.Pagination = query.Pagination{ + Limit: 100, + Offset: (page - 1) * 100, + } + + return f +} + func parseDrillFilter(r *http.Request) query.MessageFilter { q := r.URL.Query() f := query.MessageFilter{ diff --git a/internal/web/server.go b/internal/web/server.go index 2b3c5a38..dd3ae119 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -41,7 +41,8 @@ func (h *Handler) Routes() chi.Router { r.Get("/", h.handleDashboard) r.Get("/browse", h.handleBrowse) r.Get("/browse/drill", h.handleDrill) - r.Get("/messages", h.handlePlaceholder("Messages", "messages")) + r.Get("/messages", h.handleMessages) + r.Get("/messages/{id}", h.handleMessageDetail) r.Get("/search", h.handlePlaceholder("Search", "search")) return r diff --git a/internal/web/templates/helpers.go b/internal/web/templates/helpers.go index f5e91595..0daa2d06 100644 --- a/internal/web/templates/helpers.go +++ b/internal/web/templates/helpers.go @@ -2,8 +2,11 @@ package templates import ( "fmt" + "html" "net/url" + "regexp" "strings" + "time" ) // formatBytes formats a byte count into a human-readable string. @@ -71,3 +74,22 @@ func deleteParam(base, key string) string { u.RawQuery = q.Encode() return u.String() } + +// formatMessageDate formats a time for the message list. +func formatMessageDate(t time.Time) string { + now := time.Now() + if t.Year() == now.Year() { + return t.Format("Jan 02 15:04") + } + return t.Format("Jan 02, 2006") +} + +// htmlTagRe matches HTML tags for stripping. +var htmlTagRe = regexp.MustCompile(`<[^>]*>`) + +// htmlToPlainText strips all HTML tags and returns plain text. +// Used to extract readable content from HTML email bodies. +func htmlToPlainText(s string) string { + text := htmlTagRe.ReplaceAllString(s, "") + return html.UnescapeString(text) +} diff --git a/internal/web/templates/message_detail.templ b/internal/web/templates/message_detail.templ new file mode 100644 index 00000000..d0f27fb5 --- /dev/null +++ b/internal/web/templates/message_detail.templ @@ -0,0 +1,133 @@ +package templates + +import ( + "fmt" + "strings" + "github.com/wesm/msgvault/internal/query" +) + +type MessageDetailData struct { + Message *query.MessageDetail + // Navigation context for back link + BackURL string +} + +func formatAddress(a query.Address) string { + if a.Name != "" { + return a.Name + " <" + a.Email + ">" + } + return a.Email +} + +func formatAddressList(addrs []query.Address) string { + parts := make([]string, len(addrs)) + for i, a := range addrs { + parts[i] = formatAddress(a) + } + return strings.Join(parts, ", ") +} + +templ MessageDetailPage(data MessageDetailData) { + @Layout("Message", "messages") { + if data.Message == nil { +
+
Message not found
+

The requested message could not be loaded.

+
+ } else { + + @messageHeader(data.Message) + @messageBody(data.Message) + } + } +} + +templ messageHeader(msg *query.MessageDetail) { +
+

+ if msg.Subject != "" { + { msg.Subject } + } else { + (no subject) + } +

+ + + + + + + if len(msg.From) > 0 { + + + + + } + if len(msg.To) > 0 { + + + + + } + if len(msg.Cc) > 0 { + + + + + } + if len(msg.Bcc) > 0 { + + + + + } + if len(msg.Labels) > 0 { + + + + + } + +
Date{ msg.SentAt.Format("Mon, 02 Jan 2006 15:04:05 MST") }
From{ formatAddressList(msg.From) }
To{ formatAddressList(msg.To) }
Cc{ formatAddressList(msg.Cc) }
Bcc{ formatAddressList(msg.Bcc) }
Labels + for _, label := range msg.Labels { + + { label } + + } +
+ if len(msg.Attachments) > 0 { +
+
+ Attachments ({ fmt.Sprintf("%d", len(msg.Attachments)) }) +
+ for _, att := range msg.Attachments { +
+ { att.Filename } + ({ formatBytes(att.Size) }) +
+ } +
+ } + if msg.ConversationID > 0 { +
+ + View thread + +
+ } +
+} + +templ messageBody(msg *query.MessageDetail) { +
+ if msg.BodyText != "" { +
{ msg.BodyText }
+ } else if msg.BodyHTML != "" { +
{ htmlToPlainText(msg.BodyHTML) }
+ } else { +

(No message content)

+ } +
+} diff --git a/internal/web/templates/message_detail_templ.go b/internal/web/templates/message_detail_templ.go new file mode 100644 index 00000000..5f24488f --- /dev/null +++ b/internal/web/templates/message_detail_templ.go @@ -0,0 +1,441 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "github.com/wesm/msgvault/internal/query" + "strings" +) + +type MessageDetailData struct { + Message *query.MessageDetail + // Navigation context for back link + BackURL string +} + +func formatAddress(a query.Address) string { + if a.Name != "" { + return a.Name + " <" + a.Email + ">" + } + return a.Email +} + +func formatAddressList(addrs []query.Address) string { + parts := make([]string, len(addrs)) + for i, a := range addrs { + parts[i] = formatAddress(a) + } + return strings.Join(parts, ", ") +} + +func MessageDetailPage(data MessageDetailData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + if data.Message == nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
Message not found

The requested message could not be loaded.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = messageHeader(data.Message).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = messageBody(data.Message).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) + templ_7745c5c3_Err = Layout("Message", "messages").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func messageHeader(msg *query.MessageDetail) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if msg.Subject != "" { + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(msg.Subject) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 51, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "(no subject)") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(msg.From) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(msg.To) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(msg.Cc) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(msg.Bcc) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(msg.Labels) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
Date") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(msg.SentAt.Format("Mon, 02 Jan 2006 15:04:05 MST")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 60, Col: 85} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
From") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(formatAddressList(msg.From)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 65, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
To") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(formatAddressList(msg.To)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 71, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
Cc") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatAddressList(msg.Cc)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 77, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
Bcc") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(formatAddressList(msg.Bcc)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 83, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
Labels") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, label := range msg.Labels { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 92, Col: 16} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(msg.Attachments) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
Attachments (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(msg.Attachments))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 103, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ")
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, att := range msg.Attachments { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(att.Filename) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 107, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(att.Size)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 108, Col: 103} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, ")
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if msg.ConversationID > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
View thread
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func messageBody(msg *query.MessageDetail) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var16 := templ.GetChildren(ctx) + if templ_7745c5c3_Var16 == nil { + templ_7745c5c3_Var16 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if msg.BodyText != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var17 string
+			templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(msg.BodyText)
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 126, Col: 148}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if msg.BodyHTML != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var18 string
+			templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(htmlToPlainText(msg.BodyHTML))
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 128, Col: 165}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

(No message content)

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/messages.templ b/internal/web/templates/messages.templ new file mode 100644 index 00000000..c3deeda2 --- /dev/null +++ b/internal/web/templates/messages.templ @@ -0,0 +1,238 @@ +package templates + +import ( + "fmt" + "net/url" + "strings" + "github.com/wesm/msgvault/internal/query" +) + +type MessagesData struct { + Messages []query.MessageSummary + Page int + PageSize int + HasMore bool + SortField string + SortDir string + // Filter context + Filters map[string]string + AccountID string + Attachments bool + HideDeleted bool +} + +func (d MessagesData) baseQuery() url.Values { + q := url.Values{} + q.Set("sort", d.SortField) + q.Set("dir", d.SortDir) + for k, v := range d.Filters { + q.Set(k, v) + } + if d.AccountID != "" { + q.Set("account", d.AccountID) + } + if d.Attachments { + q.Set("attachments", "1") + } + if d.HideDeleted { + q.Set("hide_deleted", "1") + } + return q +} + +func (d MessagesData) sortURL(field string) string { + dir := "desc" + if d.SortField == field && d.SortDir == "desc" { + dir = "asc" + } + q := d.baseQuery() + q.Set("sort", field) + q.Set("dir", dir) + q.Del("page") + return "/messages?" + q.Encode() +} + +func (d MessagesData) sortIndicator(field string) string { + if d.SortField != field { + return "" + } + if d.SortDir == "asc" { + return " ↑" + } + return " ↓" +} + +func (d MessagesData) pageURL(page int) string { + q := d.baseQuery() + if page > 1 { + q.Set("page", fmt.Sprintf("%d", page)) + } else { + q.Del("page") + } + return "/messages?" + q.Encode() +} + +func (d MessagesData) filterSummary() string { + var parts []string + order := []struct{ key, label string }{ + {"sender", "Sender"}, + {"sender_name", "Name"}, + {"recipient", "Recipient"}, + {"recipient_name", "Recipient Name"}, + {"domain", "Domain"}, + {"label", "Label"}, + {"time_period", "Period"}, + } + for _, item := range order { + if v, ok := d.Filters[item.key]; ok { + if v == "" { + parts = append(parts, item.label+": (empty)") + } else { + parts = append(parts, item.label+": "+v) + } + } + } + if len(parts) == 0 { + return "All Messages" + } + return strings.Join(parts, " / ") +} + +func (d MessagesData) browseBackURL() string { + q := url.Values{} + // Map filters back to browse view type + viewType := "senders" + filterOrder := []struct{ param, view string }{ + {"label", "labels"}, + {"domain", "domains"}, + {"recipient_name", "recipient_names"}, + {"recipient", "recipients"}, + {"sender_name", "sender_names"}, + {"sender", "senders"}, + {"time_period", "time"}, + } + for _, f := range filterOrder { + if _, ok := d.Filters[f.param]; ok { + viewType = f.view + break + } + } + q.Set("view", viewType) + if d.AccountID != "" { + q.Set("account", d.AccountID) + } + if d.Attachments { + q.Set("attachments", "1") + } + if d.HideDeleted { + q.Set("hide_deleted", "1") + } + return "/browse?" + q.Encode() +} + +templ Messages(data MessagesData) { + @Layout("Messages", "messages") { + + if len(data.Messages) == 0 { +
+
No messages
+

No messages match the current filters.

+
+ } else { +
+ Showing { formatCount(int64(len(data.Messages))) } messages + if data.Page > 1 { + (page { fmt.Sprintf("%d", data.Page) }) + } +
+ @MessageTable(data) + @pagination(data) + } + } +} + +templ MessageTable(data MessagesData) { + + + + + + + + + + + for _, msg := range data.Messages { + + + + + + + } + +
+ + Date{ data.sortIndicator("date") } + + From + + Subject{ data.sortIndicator("subject") } + + + + Size{ data.sortIndicator("size") } + +
+ { formatMessageDate(msg.SentAt) } + + if msg.FromName != "" { + { msg.FromName } + } else { + { msg.FromEmail } + } + + + if msg.Subject != "" { + { msg.Subject } + } else { + (no subject) + } + + if msg.HasAttachments { + + { fmt.Sprintf("[%d]", msg.AttachmentCount) } + + } + { formatBytes(msg.SizeEstimate) }
+} + +templ pagination(data MessagesData) { + if data.Page > 1 || data.HasMore { +
+ if data.Page > 1 { + + Prev + + } + + Page { fmt.Sprintf("%d", data.Page) } + + if data.HasMore { + + Next + + } +
+ } +} diff --git a/internal/web/templates/messages_templ.go b/internal/web/templates/messages_templ.go new file mode 100644 index 00000000..5fa8995d --- /dev/null +++ b/internal/web/templates/messages_templ.go @@ -0,0 +1,600 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "github.com/wesm/msgvault/internal/query" + "net/url" + "strings" +) + +type MessagesData struct { + Messages []query.MessageSummary + Page int + PageSize int + HasMore bool + SortField string + SortDir string + // Filter context + Filters map[string]string + AccountID string + Attachments bool + HideDeleted bool +} + +func (d MessagesData) baseQuery() url.Values { + q := url.Values{} + q.Set("sort", d.SortField) + q.Set("dir", d.SortDir) + for k, v := range d.Filters { + q.Set(k, v) + } + if d.AccountID != "" { + q.Set("account", d.AccountID) + } + if d.Attachments { + q.Set("attachments", "1") + } + if d.HideDeleted { + q.Set("hide_deleted", "1") + } + return q +} + +func (d MessagesData) sortURL(field string) string { + dir := "desc" + if d.SortField == field && d.SortDir == "desc" { + dir = "asc" + } + q := d.baseQuery() + q.Set("sort", field) + q.Set("dir", dir) + q.Del("page") + return "/messages?" + q.Encode() +} + +func (d MessagesData) sortIndicator(field string) string { + if d.SortField != field { + return "" + } + if d.SortDir == "asc" { + return " ↑" + } + return " ↓" +} + +func (d MessagesData) pageURL(page int) string { + q := d.baseQuery() + if page > 1 { + q.Set("page", fmt.Sprintf("%d", page)) + } else { + q.Del("page") + } + return "/messages?" + q.Encode() +} + +func (d MessagesData) filterSummary() string { + var parts []string + order := []struct{ key, label string }{ + {"sender", "Sender"}, + {"sender_name", "Name"}, + {"recipient", "Recipient"}, + {"recipient_name", "Recipient Name"}, + {"domain", "Domain"}, + {"label", "Label"}, + {"time_period", "Period"}, + } + for _, item := range order { + if v, ok := d.Filters[item.key]; ok { + if v == "" { + parts = append(parts, item.label+": (empty)") + } else { + parts = append(parts, item.label+": "+v) + } + } + } + if len(parts) == 0 { + return "All Messages" + } + return strings.Join(parts, " / ") +} + +func (d MessagesData) browseBackURL() string { + q := url.Values{} + // Map filters back to browse view type + viewType := "senders" + filterOrder := []struct{ param, view string }{ + {"label", "labels"}, + {"domain", "domains"}, + {"recipient_name", "recipient_names"}, + {"recipient", "recipients"}, + {"sender_name", "sender_names"}, + {"sender", "senders"}, + {"time_period", "time"}, + } + for _, f := range filterOrder { + if _, ok := d.Filters[f.param]; ok { + viewType = f.view + break + } + } + q.Set("view", viewType) + if d.AccountID != "" { + q.Set("account", d.AccountID) + } + if d.Attachments { + q.Set("attachments", "1") + } + if d.HideDeleted { + q.Set("hide_deleted", "1") + } + return "/browse?" + q.Encode() +} + +func Messages(data MessagesData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data.Messages) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
No messages

No messages match the current filters.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
Showing ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(int64(len(data.Messages)))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 147, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " messages ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Page > 1 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "(page ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Page)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 149, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, ")") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = MessageTable(data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = pagination(data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) + templ_7745c5c3_Err = Layout("Messages", "messages").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func MessageTable(data MessagesData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var7 := templ.GetChildren(ctx) + if templ_7745c5c3_Var7 == nil { + templ_7745c5c3_Var7 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, msg := range data.Messages { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
Date") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(data.sortIndicator("date")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 164, Col: 38} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "FromSubject") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(data.sortIndicator("subject")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 170, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Size") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(data.sortIndicator("size")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 175, Col: 38} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(formatMessageDate(msg.SentAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 184, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if msg.FromName != "" { + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(msg.FromName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 188, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(msg.FromEmail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 190, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if msg.Subject != "" { + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(msg.Subject) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 196, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "(no subject)") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if msg.HasAttachments { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("[%d]", msg.AttachmentCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 203, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(msg.SizeEstimate)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 207, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func pagination(data MessagesData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var22 := templ.GetChildren(ctx) + if templ_7745c5c3_Var22 == nil { + templ_7745c5c3_Var22 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if data.Page > 1 || data.HasMore { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Page > 1 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "Prev ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "Page ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Page)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 226, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.HasMore { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "Next") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate From bfddd470755a670a046a2ca2a507074acbe15f54 Mon Sep 17 00:00:00 2001 From: dolatron Date: Sun, 8 Mar 2026 09:52:32 -0400 Subject: [PATCH 04/17] Add search page with fast and deep modes (Phase 4) --- internal/web/handlers.go | 72 ++++ internal/web/server.go | 2 +- internal/web/templates/search.templ | 197 +++++++++ internal/web/templates/search_templ.go | 526 +++++++++++++++++++++++++ 4 files changed, 796 insertions(+), 1 deletion(-) create mode 100644 internal/web/templates/search.templ create mode 100644 internal/web/templates/search_templ.go diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 976de452..280d6280 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -9,6 +9,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/wesm/msgvault/internal/query" + "github.com/wesm/msgvault/internal/search" "github.com/wesm/msgvault/internal/web/templates" ) @@ -277,3 +278,74 @@ func (h *Handler) handleMessageDetail(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = buf.WriteTo(w) } + +func (h *Handler) handleSearch(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + queryStr := r.URL.Query().Get("q") + mode := r.URL.Query().Get("mode") + if mode != "deep" { + mode = "fast" + } + page := parsePage(r) + pageSize := 100 + + data := templates.SearchData{ + Query: queryStr, + Mode: mode, + Page: page, + PageSize: pageSize, + } + + if queryStr != "" { + parsed := search.Parse(queryStr) + offset := (page - 1) * pageSize + + var messages []query.MessageSummary + var err error + + if mode == "deep" { + messages, err = h.engine.Search(ctx, parsed, pageSize+1, offset) + } else { + result, searchErr := h.engine.SearchFastWithStats( + ctx, parsed, queryStr, query.MessageFilter{}, + query.ViewSenders, pageSize+1, offset, + ) + if searchErr == nil { + messages = result.Messages + data.Stats = result.Stats + } + err = searchErr + } + + if err != nil { + slog.Error("search failed", "error", err, "query", queryStr, "mode", mode) + http.Error(w, "Search failed", http.StatusInternalServerError) + return + } + + if len(messages) > pageSize { + data.HasMore = true + messages = messages[:pageSize] + } + data.Messages = messages + } + + // Ensure stats bar is always shown (deep search doesn't return stats) + if data.Stats == nil { + stats, statsErr := h.engine.GetTotalStats(ctx, query.StatsOptions{}) + if statsErr != nil { + slog.Error("failed to get stats for search page", "error", statsErr) + } else { + data.Stats = stats + } + } + + var buf bytes.Buffer + if err := templates.Search(data).Render(ctx, &buf); err != nil { + slog.Error("failed to render search", "error", err) + http.Error(w, "Failed to render page", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = buf.WriteTo(w) +} diff --git a/internal/web/server.go b/internal/web/server.go index dd3ae119..808cc1d9 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -43,7 +43,7 @@ func (h *Handler) Routes() chi.Router { r.Get("/browse/drill", h.handleDrill) r.Get("/messages", h.handleMessages) r.Get("/messages/{id}", h.handleMessageDetail) - r.Get("/search", h.handlePlaceholder("Search", "search")) + r.Get("/search", h.handleSearch) return r } diff --git a/internal/web/templates/search.templ b/internal/web/templates/search.templ new file mode 100644 index 00000000..fc408adb --- /dev/null +++ b/internal/web/templates/search.templ @@ -0,0 +1,197 @@ +package templates + +import ( + "fmt" + "net/url" + "github.com/wesm/msgvault/internal/query" +) + +type SearchData struct { + Query string + Mode string // "fast" or "deep" + Messages []query.MessageSummary + Page int + PageSize int + HasMore bool + Stats *query.TotalStats +} + +func (d SearchData) searchURL(page int) string { + q := url.Values{} + q.Set("q", d.Query) + q.Set("mode", d.Mode) + if page > 1 { + q.Set("page", fmt.Sprintf("%d", page)) + } + return "/search?" + q.Encode() +} + +func (d SearchData) modeToggleURL() string { + q := url.Values{} + q.Set("q", d.Query) + if d.Mode == "fast" { + q.Set("mode", "deep") + } else { + q.Set("mode", "fast") + } + return "/search?" + q.Encode() +} + +func (d SearchData) otherMode() string { + if d.Mode == "fast" { + return "deep" + } + return "fast" +} + +templ Search(data SearchData) { + @Layout("Search", "search") { + if data.Stats != nil { + @StatsBar(data.Stats) + } +
+
+ + + +
+
+ if data.Mode == "fast" { + + Fast + + + Deep + + } else { + + Fast + + + Deep + + } + + if data.Mode == "fast" { + Searches subject and sender (faster) + } else { + Searches full message body (slower) + } + +
+
+ if data.Query == "" { +
+
Search your archive
+

Use Gmail-like syntax: from:user@example.com, subject:invoice, has:attachment, before:2024/01/01

+
+ } else if len(data.Messages) == 0 { +
+
No results
+

+ No messages match "{ data.Query }" + if data.Mode == "fast" { + — try deep search to include message bodies + } +

+
+ } else { +
+ Showing { formatCount(int64(len(data.Messages))) } results + if data.Page > 1 { + (page { fmt.Sprintf("%d", data.Page) }) + } +
+ @searchResultsTable(data) + @searchPagination(data) + } + } +} + +templ searchResultsTable(data SearchData) { + + + + + + + + + + + for _, msg := range data.Messages { + + + + + + + } + +
DateFromSubjectSize
+ { formatMessageDate(msg.SentAt) } + + if msg.FromName != "" { + { msg.FromName } + } else { + { msg.FromEmail } + } + + + if msg.Subject != "" { + { msg.Subject } + } else { + (no subject) + } + + if msg.HasAttachments { + + { fmt.Sprintf("[%d]", msg.AttachmentCount) } + + } + { formatBytes(msg.SizeEstimate) }
+} + +templ searchPagination(data SearchData) { + if data.Page > 1 || data.HasMore { +
+ if data.Page > 1 { + + Prev + + } + + Page { fmt.Sprintf("%d", data.Page) } + + if data.HasMore { + + Next + + } +
+ } +} diff --git a/internal/web/templates/search_templ.go b/internal/web/templates/search_templ.go new file mode 100644 index 00000000..d48398ec --- /dev/null +++ b/internal/web/templates/search_templ.go @@ -0,0 +1,526 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "github.com/wesm/msgvault/internal/query" + "net/url" +) + +type SearchData struct { + Query string + Mode string // "fast" or "deep" + Messages []query.MessageSummary + Page int + PageSize int + HasMore bool + Stats *query.TotalStats +} + +func (d SearchData) searchURL(page int) string { + q := url.Values{} + q.Set("q", d.Query) + q.Set("mode", d.Mode) + if page > 1 { + q.Set("page", fmt.Sprintf("%d", page)) + } + return "/search?" + q.Encode() +} + +func (d SearchData) modeToggleURL() string { + q := url.Values{} + q.Set("q", d.Query) + if d.Mode == "fast" { + q.Set("mode", "deep") + } else { + q.Set("mode", "fast") + } + return "/search?" + q.Encode() +} + +func (d SearchData) otherMode() string { + if d.Mode == "fast" { + return "deep" + } + return "fast" +} + +func Search(data SearchData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + if data.Stats != nil { + templ_7745c5c3_Err = StatsBar(data.Stats).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Mode == "fast" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Fast Deep ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Fast Deep ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Mode == "fast" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Searches subject and sender (faster)") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Searches full message body (slower)") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Query == "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
Search your archive

Use Gmail-like syntax: from:user@example.com, subject:invoice, has:attachment, before:2024/01/01

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if len(data.Messages) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
No results

No messages match \"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.Query) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 110, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Mode == "fast" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "— try deep search to include message bodies") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
Showing ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(int64(len(data.Messages)))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 118, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " results ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Page > 1 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "(page ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Page)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 120, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ")") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = searchResultsTable(data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = searchPagination(data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) + templ_7745c5c3_Err = Layout("Search", "search").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func searchResultsTable(data SearchData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, msg := range data.Messages { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
DateFromSubjectSize
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(formatMessageDate(msg.SentAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 143, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if msg.FromName != "" { + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(msg.FromName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 147, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(msg.FromEmail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 149, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if msg.Subject != "" { + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(msg.Subject) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 155, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "(no subject)") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if msg.HasAttachments { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("[%d]", msg.AttachmentCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 162, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(msg.SizeEstimate)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 166, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func searchPagination(data SearchData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var19 := templ.GetChildren(ctx) + if templ_7745c5c3_Var19 == nil { + templ_7745c5c3_Var19 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if data.Page > 1 || data.HasMore { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Page > 1 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "Prev ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "Page ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Page)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 185, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.HasMore { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "Next") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate From 994fd70473b41842c7f80c04f045f3703302cdc7 Mon Sep 17 00:00:00 2001 From: dolatron Date: Sun, 8 Mar 2026 09:58:31 -0400 Subject: [PATCH 05/17] Remove Messages nav link and add search filters for deleted/attachments --- internal/web/handlers.go | 32 ++- internal/web/templates/layout.templ | 1 - internal/web/templates/layout_templ.go | 30 +-- internal/web/templates/search.templ | 86 ++++-- internal/web/templates/search_templ.go | 353 ++++++++++++++++--------- 5 files changed, 332 insertions(+), 170 deletions(-) diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 280d6280..067c8484 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -288,18 +288,35 @@ func (h *Handler) handleSearch(w http.ResponseWriter, r *http.Request) { } page := parsePage(r) pageSize := 100 + hideDeleted := parseBool(r, "hide_deleted") + attachments := parseBool(r, "attachments") data := templates.SearchData{ - Query: queryStr, - Mode: mode, - Page: page, - PageSize: pageSize, + Query: queryStr, + Mode: mode, + Page: page, + PageSize: pageSize, + HideDeleted: hideDeleted, + Attachments: attachments, } if queryStr != "" { parsed := search.Parse(queryStr) + // Apply hide_deleted from the search parser too + if hideDeleted { + parsed.HideDeleted = true + } + if attachments { + t := true + parsed.HasAttachment = &t + } offset := (page - 1) * pageSize + filter := query.MessageFilter{ + HideDeletedFromSource: hideDeleted, + WithAttachmentsOnly: attachments, + } + var messages []query.MessageSummary var err error @@ -307,7 +324,7 @@ func (h *Handler) handleSearch(w http.ResponseWriter, r *http.Request) { messages, err = h.engine.Search(ctx, parsed, pageSize+1, offset) } else { result, searchErr := h.engine.SearchFastWithStats( - ctx, parsed, queryStr, query.MessageFilter{}, + ctx, parsed, queryStr, filter, query.ViewSenders, pageSize+1, offset, ) if searchErr == nil { @@ -332,7 +349,10 @@ func (h *Handler) handleSearch(w http.ResponseWriter, r *http.Request) { // Ensure stats bar is always shown (deep search doesn't return stats) if data.Stats == nil { - stats, statsErr := h.engine.GetTotalStats(ctx, query.StatsOptions{}) + stats, statsErr := h.engine.GetTotalStats(ctx, query.StatsOptions{ + HideDeletedFromSource: hideDeleted, + WithAttachmentsOnly: attachments, + }) if statsErr != nil { slog.Error("failed to get stats for search page", "error", statsErr) } else { diff --git a/internal/web/templates/layout.templ b/internal/web/templates/layout.templ index eafedba9..533c4121 100644 --- a/internal/web/templates/layout.templ +++ b/internal/web/templates/layout.templ @@ -18,7 +18,6 @@ templ Layout(title string, activePage string) { diff --git a/internal/web/templates/layout_templ.go b/internal/web/templates/layout_templ.go index 0904a9fa..0184d754 100644 --- a/internal/web/templates/layout_templ.go +++ b/internal/web/templates/layout_templ.go @@ -90,12 +90,12 @@ func Layout(title string, activePage string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 = []any{templ.KV("active", activePage == "messages")} + var templ_7745c5c3_Var7 = []any{templ.KV("active", activePage == "search")} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "Messages ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var9 = []any{templ.KV("active", activePage == "search")} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Search") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">Search") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -138,7 +116,7 @@ func Layout(title string, activePage string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
msgvault
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
msgvault
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/web/templates/search.templ b/internal/web/templates/search.templ index fc408adb..e8a058c5 100644 --- a/internal/web/templates/search.templ +++ b/internal/web/templates/search.templ @@ -7,19 +7,32 @@ import ( ) type SearchData struct { - Query string - Mode string // "fast" or "deep" - Messages []query.MessageSummary - Page int - PageSize int - HasMore bool - Stats *query.TotalStats + Query string + Mode string // "fast" or "deep" + Messages []query.MessageSummary + Page int + PageSize int + HasMore bool + Stats *query.TotalStats + HideDeleted bool + Attachments bool } -func (d SearchData) searchURL(page int) string { +func (d SearchData) baseQuery() url.Values { q := url.Values{} q.Set("q", d.Query) q.Set("mode", d.Mode) + if d.HideDeleted { + q.Set("hide_deleted", "1") + } + if d.Attachments { + q.Set("attachments", "1") + } + return q +} + +func (d SearchData) searchURL(page int) string { + q := d.baseQuery() if page > 1 { q.Set("page", fmt.Sprintf("%d", page)) } @@ -27,21 +40,25 @@ func (d SearchData) searchURL(page int) string { } func (d SearchData) modeToggleURL() string { - q := url.Values{} - q.Set("q", d.Query) + q := d.baseQuery() if d.Mode == "fast" { q.Set("mode", "deep") } else { q.Set("mode", "fast") } + q.Del("page") return "/search?" + q.Encode() } -func (d SearchData) otherMode() string { - if d.Mode == "fast" { - return "deep" +func (d SearchData) filterToggleURL(key string, currentlyOn bool) string { + q := d.baseQuery() + if currentlyOn { + q.Del(key) + } else { + q.Set(key, "1") } - return "fast" + q.Del("page") + return "/search?" + q.Encode() } templ Search(data SearchData) { @@ -60,6 +77,12 @@ templ Search(data SearchData) { style="flex: 1; padding: 8px 12px; font-size: 14px; font-family: var(--font); border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--fg); outline: none;" /> + if data.HideDeleted { + + } + if data.Attachments { + + }
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"> ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.HideDeleted { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Attachments { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.Mode == "fast" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Fast Fast Deep ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" style=\"padding: 2px 8px; border-radius: 3px; font-size: 11px; border: 1px solid var(--border); color: var(--fg-muted); text-decoration: none;\">Deep ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Fast Deep ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" style=\"padding: 2px 8px; border-radius: 3px; font-size: 11px; border: 1px solid var(--border); color: var(--fg-muted); text-decoration: none;\">Fast Deep ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.Mode == "fast" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Searches subject and sender (faster)") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Searches subject and sender (faster)") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Searches full message body (slower)") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Searches full message body (slower)") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.HideDeleted { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Hide Deleted ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Hide Deleted ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Attachments { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Attachments Only") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "Attachments Only") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.Query == "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
Search your archive

Use Gmail-like syntax: from:user@example.com, subject:invoice, has:attachment, before:2024/01/01

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
Search your archive

Use Gmail-like syntax: from:user@example.com, subject:invoice, has:attachment, before:2024/01/01

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if len(data.Messages) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
No results

No messages match \"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

No results

No messages match \"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.Query) + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(data.Query) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 110, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 164, Col: 36} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.Mode == "fast" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "— try deep search to include message bodies") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">deep search to include message bodies") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
Showing ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
Showing ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(int64(len(data.Messages)))) + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(int64(len(data.Messages)))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 118, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 172, Col: 52} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " results ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " results ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.Page > 1 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "(page ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "(page ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Page)) + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Page)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 120, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 174, Col: 41} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ")") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ")") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -268,7 +379,7 @@ func Search(data SearchData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -303,129 +414,129 @@ func searchResultsTable(data SearchData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var11 := templ.GetChildren(ctx) - if templ_7745c5c3_Var11 == nil { - templ_7745c5c3_Var11 = templ.NopComponent + templ_7745c5c3_Var15 := templ.GetChildren(ctx) + if templ_7745c5c3_Var15 == nil { + templ_7745c5c3_Var15 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
DateFromSubjectSize
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, msg := range data.Messages { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
DateFromSubjectSize
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(formatMessageDate(msg.SentAt)) + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(formatMessageDate(msg.SentAt)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 143, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 197, Col: 37} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if msg.FromName != "" { - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(msg.FromName) + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(msg.FromName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 147, Col: 21} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 201, Col: 21} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(msg.FromEmail) + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(msg.FromEmail) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 149, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 203, Col: 22} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if msg.Subject != "" { - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(msg.Subject) + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(msg.Subject) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 155, Col: 21} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 209, Col: 21} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "(no subject)") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "(no subject)") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if msg.HasAttachments { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("[%d]", msg.AttachmentCount)) + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("[%d]", msg.AttachmentCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 162, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 216, Col: 50} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(msg.SizeEstimate)) + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(msg.SizeEstimate)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 166, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 220, Col: 52} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -449,72 +560,72 @@ func searchPagination(data SearchData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var19 := templ.GetChildren(ctx) - if templ_7745c5c3_Var19 == nil { - templ_7745c5c3_Var19 = templ.NopComponent + templ_7745c5c3_Var23 := templ.GetChildren(ctx) + if templ_7745c5c3_Var23 == nil { + templ_7745c5c3_Var23 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if data.Page > 1 || data.HasMore { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.Page > 1 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "Prev ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" style=\"padding: 4px 10px; border-radius: 4px; border: 1px solid var(--border); color: var(--fg-muted); text-decoration: none;\">Prev ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "Page ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "Page ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var21 string - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Page)) + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Page)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 185, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 239, Col: 39} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.HasMore { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "Next") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" style=\"padding: 4px 10px; border-radius: 4px; border: 1px solid var(--border); color: var(--fg-muted); text-decoration: none;\">Next") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } From 384cb536b18814f3c7625a19f5b4f9d00decfc8f Mon Sep 17 00:00:00 2001 From: dolatron Date: Sun, 8 Mar 2026 10:40:36 -0400 Subject: [PATCH 06/17] Code review cleanup: security fixes, dead code removal, and UX polish - Fix XSS via javascript: URI in Referer back URL (validate scheme + path) - Fix htmlToPlainText to strip and blocks + // (including their content) before tag stripping to avoid rendering CSS/JS as text. + // Go's regexp (RE2) doesn't support backreferences, so we use separate patterns. + styleRe = regexp.MustCompile(`(?is)]*>.*?`) + scriptRe = regexp.MustCompile(`(?is)]*>.*?`) + // htmlTagRe matches HTML tags for stripping. + htmlTagRe = regexp.MustCompile(`<[^>]*>`) +) -// htmlToPlainText strips all HTML tags and returns plain text. +// htmlToPlainText strips style/script blocks and all HTML tags, returning plain text. // Used to extract readable content from HTML email bodies. func htmlToPlainText(s string) string { - text := htmlTagRe.ReplaceAllString(s, "") + // Remove style/script blocks first (their content is not displayable text) + text := styleRe.ReplaceAllString(s, "") + text = scriptRe.ReplaceAllString(text, "") + text = htmlTagRe.ReplaceAllString(text, "") return html.UnescapeString(text) } diff --git a/internal/web/templates/layout.templ b/internal/web/templates/layout.templ index 533c4121..fc8dd4ad 100644 --- a/internal/web/templates/layout.templ +++ b/internal/web/templates/layout.templ @@ -8,8 +8,6 @@ templ Layout(title string, activePage string) { { title } - msgvault - -
diff --git a/internal/web/templates/layout_templ.go b/internal/web/templates/layout_templ.go index 0184d754..53ce12a4 100644 --- a/internal/web/templates/layout_templ.go +++ b/internal/web/templates/layout_templ.go @@ -42,7 +42,7 @@ func Layout(title string, activePage string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - msgvault
msgvault
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } From a6b4270f2659f79728f6707ad80a0de2294773d0 Mon Sep 17 00:00:00 2001 From: dolatron Date: Sun, 8 Mar 2026 11:02:40 -0400 Subject: [PATCH 09/17] Add 'o' key to open messages for selected row Enter drills into a row (filter/drill-down), 'o' opens the messages list. On pages with only one link per row, both keys behave the same. --- internal/web/static/keys.js | 18 ++++++++++++++---- internal/web/templates/layout.templ | 6 +++++- internal/web/templates/layout_templ.go | 2 +- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/internal/web/static/keys.js b/internal/web/static/keys.js index 2e96e645..a22d2216 100644 --- a/internal/web/static/keys.js +++ b/internal/web/static/keys.js @@ -28,11 +28,13 @@ rows[activeRow].scrollIntoView({ block: 'nearest' }); } - function openActiveRow() { + function openActiveRow(linkIndex) { var rows = getRows(); if (activeRow < 0 || activeRow >= rows.length) return; - var link = rows[activeRow].querySelector('a'); - if (link) link.click(); + var links = rows[activeRow].querySelectorAll('a'); + if (links.length === 0) return; + var idx = (linkIndex !== undefined && linkIndex < links.length) ? linkIndex : 0; + links[idx].click(); } function isInputFocused() { @@ -119,7 +121,15 @@ case 'Enter': if (activeRow >= 0) { e.preventDefault(); - openActiveRow(); + openActiveRow(0); + } + break; + + case 'o': + // Open messages for active row (second link, or first if only one) + if (activeRow >= 0) { + e.preventDefault(); + openActiveRow(1); } break; diff --git a/internal/web/templates/layout.templ b/internal/web/templates/layout.templ index d45d0aa6..11a2419f 100644 --- a/internal/web/templates/layout.templ +++ b/internal/web/templates/layout.templ @@ -45,7 +45,11 @@ templ helpOverlay() { Enter - Open selected row + Drill into selected row + + + o + Open messages for row g G diff --git a/internal/web/templates/layout_templ.go b/internal/web/templates/layout_templ.go index 545d1723..78985813 100644 --- a/internal/web/templates/layout_templ.go +++ b/internal/web/templates/layout_templ.go @@ -153,7 +153,7 @@ func helpOverlay() templ.Component { templ_7745c5c3_Var9 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

Keyboard Shortcuts

j kNavigate rows up / down
EnterOpen selected row
g GJump to first / last row
n pNext / previous page
/Focus search (or go to search)
EscBlur search / close help
HGo to Dashboard
BGo to Browse
?Toggle this help
Press ? or Esc to close
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

Keyboard Shortcuts

j kNavigate rows up / down
EnterDrill into selected row
oOpen messages for row
g GJump to first / last row
n pNext / previous page
/Focus search (or go to search)
EscBlur search / close help
HGo to Dashboard
BGo to Browse
?Toggle this help
Press ? or Esc to close
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } From d0a95cde193a10cc4313e2c97a5baa8774bca872 Mon Sep 17 00:00:00 2001 From: dolatron Date: Sun, 8 Mar 2026 11:16:12 -0400 Subject: [PATCH 10/17] Add back navigation (Backspace) and account filter dropdown - Backspace navigates back via breadcrumb link, preventDefault unconditional to avoid accidental browser-back - Account filter dropdown on browse/drill pages using server-rendered URLs via accountFilterURL to preserve full page state - Accounts list passed from handlers to BrowseData --- internal/web/handlers.go | 12 ++ internal/web/static/keys.js | 9 + internal/web/static/style.css | 15 +- internal/web/templates/aggregates.templ | 28 ++++ internal/web/templates/aggregates_templ.go | 184 +++++++++++++++++---- internal/web/templates/layout.templ | 4 + internal/web/templates/layout_templ.go | 2 +- 7 files changed, 218 insertions(+), 36 deletions(-) diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 37d32c10..46f250b7 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -67,6 +67,11 @@ func (h *Handler) handleBrowse(w http.ResponseWriter, r *http.Request) { slog.Error("failed to get stats", "error", err) } + accounts, err := h.engine.ListAccounts(ctx) + if err != nil { + slog.Error("failed to list accounts", "error", err) + } + data := templates.BrowseData{ Stats: stats, Rows: rows, @@ -78,6 +83,7 @@ func (h *Handler) handleBrowse(w http.ResponseWriter, r *http.Request) { AccountID: r.URL.Query().Get("account"), Attachments: opts.WithAttachmentsOnly, HideDeleted: opts.HideDeletedFromSource, + Accounts: accounts, } var buf bytes.Buffer @@ -144,6 +150,11 @@ func (h *Handler) handleDrill(w http.ResponseWriter, r *http.Request) { } } + accounts, err := h.engine.ListAccounts(ctx) + if err != nil { + slog.Error("failed to list accounts", "error", err) + } + data := templates.BrowseData{ Stats: stats, Rows: rows, @@ -155,6 +166,7 @@ func (h *Handler) handleDrill(w http.ResponseWriter, r *http.Request) { AccountID: r.URL.Query().Get("account"), Attachments: opts.WithAttachmentsOnly, HideDeleted: opts.HideDeletedFromSource, + Accounts: accounts, DrillFilters: drillFilters, Breadcrumbs: breadcrumbs, } diff --git a/internal/web/static/keys.js b/internal/web/static/keys.js index a22d2216..d94ecff6 100644 --- a/internal/web/static/keys.js +++ b/internal/web/static/keys.js @@ -155,6 +155,15 @@ window.location.href = '/browse'; break; + case 'Backspace': + // Navigate back via breadcrumb link + e.preventDefault(); + var backLink = document.querySelector('.breadcrumb a'); + if (backLink) { + backLink.click(); + } + break; + case 'n': // Next page var nextLink = document.querySelector('.pagination a:last-of-type'); diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 0d3e1685..3f90d1fb 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -394,7 +394,20 @@ a:hover { color: var(--accent-hover); text-decoration: underline; } gap: 8px; } .toolbar-left { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } -.toolbar-right { display: flex; gap: 8px; align-items: center; font-size: 12px; } +.toolbar-right { display: flex; gap: 8px; align-items: center; font-size: 12px; flex-wrap: wrap; } +.account-select { + padding: 3px 8px; + font-size: 12px; + font-family: var(--font); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--fg); + cursor: pointer; + outline: none; +} +.account-select:hover { border-color: var(--fg-muted); } +.account-select:focus { border-color: var(--accent); } /* Message detail */ .msg-subject { diff --git a/internal/web/templates/aggregates.templ b/internal/web/templates/aggregates.templ index 9d58a9b1..7fe41b6a 100644 --- a/internal/web/templates/aggregates.templ +++ b/internal/web/templates/aggregates.templ @@ -18,6 +18,7 @@ type BrowseData struct { AccountID string Attachments bool HideDeleted bool + Accounts []query.AccountInfo // Drill-down context DrillFilters map[string]string Breadcrumbs []Breadcrumb @@ -190,6 +191,17 @@ func (d BrowseData) filterToggleURL(key string, currentlyOn bool) string { return addParam(base, key, "1") } +// accountFilterURL returns the URL for filtering by a specific account (or all). +func (d BrowseData) accountFilterURL(accountID string) string { + base := d.currentBase() + if accountID == "" { + return deleteParam(base, "account") + } + // Replace any existing account param + base = deleteParam(base, "account") + return addParam(base, "account", accountID) +} + func viewTypeToFilterParam(viewType string) string { switch viewType { case "senders": @@ -350,6 +362,9 @@ templ granularityTab(label string, granularity string, data BrowseData) { templ filterControls(data BrowseData) {
+ if len(data.Accounts) > 0 { + @accountSelector(data) + } if data.Attachments { Attachments Only } else { @@ -362,3 +377,16 @@ templ filterControls(data BrowseData) { }
} + +templ accountSelector(data BrowseData) { + +} diff --git a/internal/web/templates/aggregates_templ.go b/internal/web/templates/aggregates_templ.go index 1ccad516..55dad3a6 100644 --- a/internal/web/templates/aggregates_templ.go +++ b/internal/web/templates/aggregates_templ.go @@ -26,6 +26,7 @@ type BrowseData struct { AccountID string Attachments bool HideDeleted bool + Accounts []query.AccountInfo // Drill-down context DrillFilters map[string]string Breadcrumbs []Breadcrumb @@ -198,6 +199,17 @@ func (d BrowseData) filterToggleURL(key string, currentlyOn bool) string { return addParam(base, key, "1") } +// accountFilterURL returns the URL for filtering by a specific account (or all). +func (d BrowseData) accountFilterURL(accountID string) string { + base := d.currentBase() + if accountID == "" { + return deleteParam(base, "account") + } + // Replace any existing account param + base = deleteParam(base, "account") + return addParam(base, "account", accountID) +} + func viewTypeToFilterParam(viewType string) string { switch viewType { case "senders": @@ -308,7 +320,7 @@ func Aggregates(data BrowseData) templ.Component { var templ_7745c5c3_Var3 templ.SafeURL templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(bc.URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 235, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 247, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -321,7 +333,7 @@ func Aggregates(data BrowseData) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(bc.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 235, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 247, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -339,7 +351,7 @@ func Aggregates(data BrowseData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(bc.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 237, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 249, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -409,7 +421,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var7 templ.SafeURL templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(data.sortURL("name"))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 258, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 270, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -422,7 +434,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.ViewLabel) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 259, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 271, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -431,7 +443,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(data.sortIndicator("name")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 259, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 271, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -444,7 +456,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var10 templ.SafeURL templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(data.sortURL("count"))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 263, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 275, Col: 51} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -457,7 +469,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(data.sortIndicator("count")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 264, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 276, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -470,7 +482,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var12 templ.SafeURL templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(data.sortURL("size"))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 268, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 280, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -483,7 +495,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(data.sortIndicator("size")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 269, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 281, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -496,7 +508,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var14 templ.SafeURL templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(data.sortURL("attachments"))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 273, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 285, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -509,7 +521,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(data.sortIndicator("attachments")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 274, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 286, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -527,7 +539,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var16 templ.SafeURL templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(data.drillURL(row.Key))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 283, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 295, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -546,7 +558,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(row.Key) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 287, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 299, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -560,7 +572,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var18 templ.SafeURL templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(data.messagesURL(row.Key))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 292, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 304, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -573,7 +585,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(row.Count)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 293, Col: 31} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 305, Col: 31} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -586,7 +598,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var20 string templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(row.TotalSize)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 296, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 308, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { @@ -600,7 +612,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var21 string templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(row.AttachmentCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 299, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 311, Col: 41} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { @@ -613,7 +625,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var22 string templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(row.AttachmentSize)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 299, Col: 78} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 311, Col: 78} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { @@ -627,7 +639,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var23 string templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("-")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 301, Col: 25} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 313, Col: 25} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { @@ -651,7 +663,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var24 string templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(int64(len(data.Rows)))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 310, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 322, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { @@ -664,7 +676,7 @@ func AggregateTable(data BrowseData) templ.Component { var templ_7745c5c3_Var25 string templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(data.Rows[0].TotalUnique)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 310, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 322, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { @@ -769,7 +781,7 @@ func viewTab(label string, viewType string, data BrowseData) templ.Component { var templ_7745c5c3_Var28 templ.SafeURL templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(data.ViewTabURL(viewType))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 329, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 341, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { @@ -782,7 +794,7 @@ func viewTab(label string, viewType string, data BrowseData) templ.Component { var templ_7745c5c3_Var29 string templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 329, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 341, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { @@ -800,7 +812,7 @@ func viewTab(label string, viewType string, data BrowseData) templ.Component { var templ_7745c5c3_Var30 templ.SafeURL templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(data.ViewTabURL(viewType))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 331, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 343, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { @@ -813,7 +825,7 @@ func viewTab(label string, viewType string, data BrowseData) templ.Component { var templ_7745c5c3_Var31 string templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 331, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 343, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { @@ -902,7 +914,7 @@ func granularityTab(label string, granularity string, data BrowseData) templ.Com var templ_7745c5c3_Var34 templ.SafeURL templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(data.granularityTabURL(granularity))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 345, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 357, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) if templ_7745c5c3_Err != nil { @@ -915,7 +927,7 @@ func granularityTab(label string, granularity string, data BrowseData) templ.Com var templ_7745c5c3_Var35 string templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 345, Col: 101} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 357, Col: 101} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) if templ_7745c5c3_Err != nil { @@ -933,7 +945,7 @@ func granularityTab(label string, granularity string, data BrowseData) templ.Com var templ_7745c5c3_Var36 templ.SafeURL templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(data.granularityTabURL(granularity))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 347, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 359, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) if templ_7745c5c3_Err != nil { @@ -946,7 +958,7 @@ func granularityTab(label string, granularity string, data BrowseData) templ.Com var templ_7745c5c3_Var37 string templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 347, Col: 88} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 359, Col: 88} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) if templ_7745c5c3_Err != nil { @@ -986,6 +998,12 @@ func filterControls(data BrowseData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + if len(data.Accounts) > 0 { + templ_7745c5c3_Err = accountSelector(data).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } if data.Attachments { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, acct := range data.Accounts { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/layout.templ b/internal/web/templates/layout.templ index 11a2419f..85e045d2 100644 --- a/internal/web/templates/layout.templ +++ b/internal/web/templates/layout.templ @@ -67,6 +67,10 @@ templ helpOverlay() { Esc Blur search / close help + + Bksp + Go back (breadcrumb) + H Go to Dashboard diff --git a/internal/web/templates/layout_templ.go b/internal/web/templates/layout_templ.go index 78985813..f3f62373 100644 --- a/internal/web/templates/layout_templ.go +++ b/internal/web/templates/layout_templ.go @@ -153,7 +153,7 @@ func helpOverlay() templ.Component { templ_7745c5c3_Var9 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

Keyboard Shortcuts

j kNavigate rows up / down
EnterDrill into selected row
oOpen messages for row
g GJump to first / last row
n pNext / previous page
/Focus search (or go to search)
EscBlur search / close help
HGo to Dashboard
BGo to Browse
?Toggle this help
Press ? or Esc to close
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

Keyboard Shortcuts

j kNavigate rows up / down
EnterDrill into selected row
oOpen messages for row
g GJump to first / last row
n pNext / previous page
/Focus search (or go to search)
EscBlur search / close help
BkspGo back (breadcrumb)
HGo to Dashboard
BGo to Browse
?Toggle this help
Press ? or Esc to close
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } From 66ba941599ae4735d9acd346416343f1df479008 Mon Sep 17 00:00:00 2001 From: dolatron Date: Sun, 8 Mar 2026 11:56:30 -0400 Subject: [PATCH 11/17] Add AccountEmail to MessageDetail for deletion account tracking Join sources table in getMessageByQueryShared to populate the account email on every message detail lookup. Needed by web UI deletion staging to set the correct account in deletion manifests. --- internal/query/models.go | 5 +++-- internal/query/shared.go | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/query/models.go b/internal/query/models.go index 0ce4a813..d336a134 100644 --- a/internal/query/models.go +++ b/internal/query/models.go @@ -60,8 +60,9 @@ type MessageDetail struct { BodyHTML string // Metadata - Labels []string - Attachments []AttachmentInfo + Labels []string + Attachments []AttachmentInfo + AccountEmail string // Source account identifier (email) } // Address represents an email address with optional display name. diff --git a/internal/query/shared.go b/internal/query/shared.go index bfe1d157..f119841b 100644 --- a/internal/query/shared.go +++ b/internal/query/shared.go @@ -189,11 +189,13 @@ func getMessageByQueryShared(ctx context.Context, db *sql.DB, tablePrefix string m.sent_at, m.received_at, COALESCE(m.size_estimate, 0), - m.has_attachments + m.has_attachments, + COALESCE(s.identifier, '') FROM %smessages m LEFT JOIN %sconversations conv ON conv.id = m.conversation_id + LEFT JOIN %ssources s ON s.id = m.source_id WHERE %s - `, tablePrefix, tablePrefix, whereClause) + `, tablePrefix, tablePrefix, tablePrefix, whereClause) var msg MessageDetail var sentAt, receivedAt sql.NullTime @@ -208,6 +210,7 @@ func getMessageByQueryShared(ctx context.Context, db *sql.DB, tablePrefix string &receivedAt, &msg.SizeEstimate, &msg.HasAttachments, + &msg.AccountEmail, ) if err == sql.ErrNoRows { return nil, nil From 81e5f14a3b6459c8a7f9ddaf5986d8c5de414f9d Mon Sep 17 00:00:00 2001 From: dolatron Date: Sun, 8 Mar 2026 11:56:38 -0400 Subject: [PATCH 12/17] Add deletion staging to web UI with single-message and batch support - Single-message staging from detail page (POST /deletions/stage/{id}) - Batch staging from checkbox selection (POST /deletions/stage-batch) - Deletions list page showing manifests by status with cancel buttons - Wire deletion.Manager into web handler via API server - Validate manifest IDs to prevent path traversal in cancel handler - Use NewManifest directly to avoid double-save via CreateManifest - Hardcode redirect on empty batch to prevent open redirect via Referer --- internal/api/server.go | 12 +- internal/web/handlers_deletions.go | 157 ++++++++++ internal/web/server.go | 16 +- internal/web/templates/deletions.templ | 102 +++++++ internal/web/templates/deletions_templ.go | 271 ++++++++++++++++++ internal/web/templates/message_detail.templ | 7 + .../web/templates/message_detail_templ.go | 51 ++-- 7 files changed, 592 insertions(+), 24 deletions(-) create mode 100644 internal/web/handlers_deletions.go create mode 100644 internal/web/templates/deletions.templ create mode 100644 internal/web/templates/deletions_templ.go diff --git a/internal/api/server.go b/internal/api/server.go index f645ec10..490667f7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -7,6 +7,7 @@ import ( "log/slog" "net" "net/http" + "path/filepath" "strconv" "sync" "time" @@ -14,6 +15,7 @@ import ( "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" "github.com/wesm/msgvault/internal/config" + "github.com/wesm/msgvault/internal/deletion" "github.com/wesm/msgvault/internal/query" "github.com/wesm/msgvault/internal/scheduler" "github.com/wesm/msgvault/internal/store" @@ -141,7 +143,15 @@ func (s *Server) setupRouter() chi.Router { // Web UI (enabled when query engine is provided) if s.engine != nil { - webHandler := web.NewHandler(s.engine) + var delMgr *deletion.Manager + deletionsDir := filepath.Join(s.cfg.Data.DataDir, "deletions") + mgr, err := deletion.NewManager(deletionsDir) + if err != nil { + s.logger.Error("failed to create deletion manager", "error", err) + } else { + delMgr = mgr + } + webHandler := web.NewHandler(s.engine, delMgr) r.Group(func(r chi.Router) { r.Use(s.authMiddleware) r.Mount("/", webHandler.Routes()) diff --git a/internal/web/handlers_deletions.go b/internal/web/handlers_deletions.go new file mode 100644 index 00000000..bf5c635d --- /dev/null +++ b/internal/web/handlers_deletions.go @@ -0,0 +1,157 @@ +package web + +import ( + "bytes" + "fmt" + "log/slog" + "net/http" + "regexp" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/wesm/msgvault/internal/deletion" + "github.com/wesm/msgvault/internal/web/templates" +) + +// validManifestID matches the format produced by deletion.generateID: +// YYYYMMDD-HHMMSS- +var validManifestID = regexp.MustCompile(`^[0-9]{8}-[0-9]{6}-[a-zA-Z0-9_-]{1,20}$`) + +func (h *Handler) handleDeletions(w http.ResponseWriter, r *http.Request) { + if h.deletions == nil { + http.Error(w, "Deletion staging not available", http.StatusServiceUnavailable) + return + } + + pending, _ := h.deletions.ListPending() + inProgress, _ := h.deletions.ListInProgress() + completed, _ := h.deletions.ListCompleted() + failed, _ := h.deletions.ListFailed() + + data := templates.DeletionsData{ + Pending: pending, + InProgress: inProgress, + Completed: completed, + Failed: failed, + } + + var buf bytes.Buffer + if err := templates.DeletionsPage(data).Render(r.Context(), &buf); err != nil { + slog.Error("failed to render deletions", "error", err) + http.Error(w, "Failed to render page", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = buf.WriteTo(w) +} + +// handleStageBatch stages multiple messages for deletion from checkbox selection. +// Accepts gmail_id[] form values posted from message list checkboxes. +func (h *Handler) handleStageBatch(w http.ResponseWriter, r *http.Request) { + if h.deletions == nil { + http.Error(w, "Deletion staging not available", http.StatusServiceUnavailable) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "Invalid form data", http.StatusBadRequest) + return + } + + ctx := r.Context() + + gmailIDs := r.Form["gmail_id"] + if len(gmailIDs) == 0 { + http.Redirect(w, r, "/messages", http.StatusSeeOther) + return + } + + // Look up account from the first message; leave empty if lookup fails + // (the CLI will resolve per-ID at execution time) + var account string + msg, err := h.engine.GetMessageBySourceID(ctx, gmailIDs[0]) + if err == nil && msg != nil { + account = msg.AccountEmail + } + + description := fmt.Sprintf("Web selection (%d messages)", len(gmailIDs)) + + manifest := deletion.NewManifest(description, gmailIDs) + manifest.Filters = deletion.Filters{Account: account} + manifest.CreatedBy = "web" + + if err := h.deletions.SaveManifest(manifest); err != nil { + slog.Error("failed to save manifest", "error", err) + http.Error(w, "Failed to save manifest", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/deletions", http.StatusSeeOther) +} + +// handleStageMessage stages a single message for deletion by its database ID. +func (h *Handler) handleStageMessage(w http.ResponseWriter, r *http.Request) { + if h.deletions == nil { + http.Error(w, "Deletion staging not available", http.StatusServiceUnavailable) + return + } + + ctx := r.Context() + + msgIDStr := chi.URLParam(r, "id") + msgID, err := strconv.ParseInt(msgIDStr, 10, 64) + if err != nil { + http.Error(w, "Invalid message ID", http.StatusBadRequest) + return + } + + msg, err := h.engine.GetMessage(ctx, msgID) + if err != nil { + slog.Error("failed to load message for deletion", "error", err, "id", msgID) + http.Error(w, "Message not found", http.StatusNotFound) + return + } + + if msg.SourceMessageID == "" { + http.Error(w, "Message has no Gmail ID", http.StatusBadRequest) + return + } + + description := fmt.Sprintf("Message: %s", msg.Subject) + if description == "Message: " { + description = "Message: (no subject)" + } + + manifest := deletion.NewManifest(description, []string{msg.SourceMessageID}) + manifest.Filters = deletion.Filters{Account: msg.AccountEmail} + manifest.CreatedBy = "web" + + if err := h.deletions.SaveManifest(manifest); err != nil { + slog.Error("failed to save manifest", "error", err) + http.Error(w, "Failed to save manifest", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/deletions", http.StatusSeeOther) +} + +func (h *Handler) handleCancelDeletion(w http.ResponseWriter, r *http.Request) { + if h.deletions == nil { + http.Error(w, "Deletion staging not available", http.StatusServiceUnavailable) + return + } + + id := chi.URLParam(r, "id") + if !validManifestID.MatchString(id) { + http.Error(w, "Invalid batch ID", http.StatusBadRequest) + return + } + + if err := h.deletions.CancelManifest(id); err != nil { + slog.Error("failed to cancel manifest", "error", err, "id", id) + http.Error(w, fmt.Sprintf("Failed to cancel: %v", err), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/deletions", http.StatusSeeOther) +} diff --git a/internal/web/server.go b/internal/web/server.go index 808cc1d9..58e083db 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -8,6 +8,7 @@ import ( "net/http" "github.com/go-chi/chi/v5" + "github.com/wesm/msgvault/internal/deletion" "github.com/wesm/msgvault/internal/query" ) @@ -16,17 +17,18 @@ var staticFS embed.FS // Handler serves the web UI. type Handler struct { - engine query.Engine - staticFS fs.FS + engine query.Engine + deletions *deletion.Manager + staticFS fs.FS } // NewHandler creates a new web UI handler. -func NewHandler(engine query.Engine) *Handler { +func NewHandler(engine query.Engine, deletions *deletion.Manager) *Handler { staticSub, err := fs.Sub(staticFS, "static") if err != nil { panic(fmt.Sprintf("web: failed to sub static FS: %v", err)) } - return &Handler{engine: engine, staticFS: staticSub} + return &Handler{engine: engine, deletions: deletions, staticFS: staticSub} } // Routes returns a chi.Router with all web UI routes mounted. @@ -45,5 +47,11 @@ func (h *Handler) Routes() chi.Router { r.Get("/messages/{id}", h.handleMessageDetail) r.Get("/search", h.handleSearch) + // Deletion staging + r.Get("/deletions", h.handleDeletions) + r.Post("/deletions/stage-batch", h.handleStageBatch) + r.Post("/deletions/stage/{id}", h.handleStageMessage) + r.Post("/deletions/{id}/cancel", h.handleCancelDeletion) + return r } diff --git a/internal/web/templates/deletions.templ b/internal/web/templates/deletions.templ new file mode 100644 index 00000000..5ce8a556 --- /dev/null +++ b/internal/web/templates/deletions.templ @@ -0,0 +1,102 @@ +package templates + +import ( + "fmt" + "github.com/wesm/msgvault/internal/deletion" +) + +type DeletionsData struct { + Pending []*deletion.Manifest + InProgress []*deletion.Manifest + Completed []*deletion.Manifest + Failed []*deletion.Manifest +} + +func (d DeletionsData) totalCount() int { + return len(d.Pending) + len(d.InProgress) + len(d.Completed) + len(d.Failed) +} + +templ DeletionsPage(data DeletionsData) { + @Layout("Deletions", "deletions") { +

Deletion Batches

+ if data.totalCount() == 0 { +
+
No deletion batches
+

Stage individual messages for deletion from the message detail view, then execute with the CLI.

+
+ } else { + if len(data.Pending) > 0 { + @manifestSection("Pending", "pending", data.Pending, true) + } + if len(data.InProgress) > 0 { + @manifestSection("In Progress", "in_progress", data.InProgress, false) + } + if len(data.Completed) > 0 { + @manifestSection("Completed", "completed", data.Completed, false) + } + if len(data.Failed) > 0 { + @manifestSection("Failed", "failed", data.Failed, false) + } + } +
+
How to execute
+

+ Staged batches are executed from the command line: +

+ + + + + + + + + + + + + + + + + +
msgvault delete-stagedExecute all pending
msgvault delete-staged <batch-id>Execute specific batch
msgvault delete-staged --dry-runPreview without deleting
msgvault delete-staged --trashMove to trash (recoverable)
+
+ } +} + +templ manifestSection(title string, status string, manifests []*deletion.Manifest, showCancel bool) { +
+
{ title } ({ fmt.Sprintf("%d", len(manifests)) })
+ + + + + + + + if showCancel { + + } + + + + for _, m := range manifests { + + + + + + if showCancel { + + } + + } + +
Batch IDDescriptionMessagesCreated
{ m.ID }{ m.Description }{ formatCount(int64(len(m.GmailIDs))) }{ m.CreatedAt.Format("Jan 02 15:04") } + + + +
+
+} diff --git a/internal/web/templates/deletions_templ.go b/internal/web/templates/deletions_templ.go new file mode 100644 index 00000000..6b9bea4a --- /dev/null +++ b/internal/web/templates/deletions_templ.go @@ -0,0 +1,271 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "github.com/wesm/msgvault/internal/deletion" +) + +type DeletionsData struct { + Pending []*deletion.Manifest + InProgress []*deletion.Manifest + Completed []*deletion.Manifest + Failed []*deletion.Manifest +} + +func (d DeletionsData) totalCount() int { + return len(d.Pending) + len(d.InProgress) + len(d.Completed) + len(d.Failed) +} + +func DeletionsPage(data DeletionsData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Deletion Batches

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.totalCount() == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
No deletion batches

Stage individual messages for deletion from the message detail view, then execute with the CLI.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + if len(data.Pending) > 0 { + templ_7745c5c3_Err = manifestSection("Pending", "pending", data.Pending, true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data.InProgress) > 0 { + templ_7745c5c3_Err = manifestSection("In Progress", "in_progress", data.InProgress, false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data.Completed) > 0 { + templ_7745c5c3_Err = manifestSection("Completed", "completed", data.Completed, false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data.Failed) > 0 { + templ_7745c5c3_Err = manifestSection("Failed", "failed", data.Failed, false).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
How to execute

Staged batches are executed from the command line:

msgvault delete-stagedExecute all pending
msgvault delete-staged <batch-id>Execute specific batch
msgvault delete-staged --dry-runPreview without deleting
msgvault delete-staged --trashMove to trash (recoverable)
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout("Deletions", "deletions").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func manifestSection(title string, status string, manifests []*deletion.Manifest, showCancel bool) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 70, Col: 33} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(manifests))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 70, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ")
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if showCancel { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, m := range manifests { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if showCancel { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Batch IDDescriptionMessagesCreated
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(m.ID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 86, Col: 67} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(m.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 87, Col: 25} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(int64(len(m.GmailIDs)))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 88, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(m.CreatedAt.Format("Jan 02 15:04")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 89, Col: 64} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/message_detail.templ b/internal/web/templates/message_detail.templ index 60fb5329..15415afe 100644 --- a/internal/web/templates/message_detail.templ +++ b/internal/web/templates/message_detail.templ @@ -115,6 +115,13 @@ templ messageHeader(msg *query.MessageDetail) {
} +
+
+ +
+
} diff --git a/internal/web/templates/message_detail_templ.go b/internal/web/templates/message_detail_templ.go index 117f51a8..3282696d 100644 --- a/internal/web/templates/message_detail_templ.go +++ b/internal/web/templates/message_detail_templ.go @@ -355,7 +355,20 @@ func messageHeader(msg *query.MessageDetail) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -379,58 +392,58 @@ func messageBody(msg *query.MessageDetail) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var16 := templ.GetChildren(ctx) - if templ_7745c5c3_Var16 == nil { - templ_7745c5c3_Var16 = templ.NopComponent + templ_7745c5c3_Var17 := templ.GetChildren(ctx) + if templ_7745c5c3_Var17 == nil { + templ_7745c5c3_Var17 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if msg.BodyText != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
")
+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
")
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			var templ_7745c5c3_Var17 string
-			templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(msg.BodyText)
+			var templ_7745c5c3_Var18 string
+			templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(msg.BodyText)
 			if templ_7745c5c3_Err != nil {
-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 124, Col: 39}
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 131, Col: 39}
 			}
-			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if msg.BodyHTML != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
")
+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
")
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			var templ_7745c5c3_Var18 string
-			templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(htmlToPlainText(msg.BodyHTML))
+			var templ_7745c5c3_Var19 string
+			templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(htmlToPlainText(msg.BodyHTML))
 			if templ_7745c5c3_Err != nil {
-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 126, Col: 56}
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 133, Col: 56}
 			}
-			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

(No message content)

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "

(No message content)

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } From 63bb3ea8056999da24c598c564e6007023bbbc24 Mon Sep 17 00:00:00 2001 From: dolatron Date: Sun, 8 Mar 2026 11:56:45 -0400 Subject: [PATCH 13/17] Add delete mode UI with checkbox selection and keyboard shortcuts Press 'd' to enter delete mode, which reveals checkboxes on message rows and a selection bar. Space toggles selection, A selects all on the current page, x clears, Esc exits. Checkboxes are hidden by default to keep the browsing UI clean. CSS transitions animate the checkbox column in/out. --- internal/web/static/keys.js | 120 ++++++++++++++- internal/web/static/style.css | 80 ++++++++++ internal/web/templates/layout.templ | 17 ++ internal/web/templates/layout_templ.go | 36 ++++- internal/web/templates/messages.templ | 107 ++++++++----- internal/web/templates/messages_templ.go | 188 +++++++++++++++-------- 6 files changed, 433 insertions(+), 115 deletions(-) diff --git a/internal/web/static/keys.js b/internal/web/static/keys.js index d94ecff6..46f40eb4 100644 --- a/internal/web/static/keys.js +++ b/internal/web/static/keys.js @@ -68,7 +68,7 @@ } document.addEventListener('keydown', function (e) { - // Always allow Escape to close help + // Always allow Escape to close help / exit delete mode if (e.key === 'Escape') { var overlay = document.getElementById('help-overlay'); if (overlay && overlay.classList.contains('visible')) { @@ -76,6 +76,11 @@ e.preventDefault(); return; } + if (isDeleteMode()) { + exitDeleteMode(); + e.preventDefault(); + return; + } // Escape also blurs search input if (isInputFocused()) { document.activeElement.blur(); @@ -179,6 +184,41 @@ prevLink.click(); } break; + + case 'd': + // Enter delete mode + if (!isDeleteMode()) { + e.preventDefault(); + enterDeleteMode(); + } + break; + + case ' ': + // Toggle selection on active row (delete mode only) + if (isDeleteMode() && activeRow >= 0) { + e.preventDefault(); + toggleActiveRowCheckbox(); + } + break; + + case 'x': + // Clear selection (delete mode) + if (isDeleteMode()) { + var boxes = document.querySelectorAll('.msg-checkbox'); + for (var i = 0; i < boxes.length; i++) boxes[i].checked = false; + var selectAll = document.getElementById('select-all'); + if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; } + updateSelectionInfo(); + } + break; + + case 'A': + // Select all (delete mode) + if (isDeleteMode()) { + e.preventDefault(); + selectAllMessages(); + } + break; } }); @@ -221,8 +261,86 @@ } } + // Delete mode — toggled by 'd' key + var deleteMode = false; + + function isDeleteMode() { + return deleteMode; + } + + function enterDeleteMode() { + if (!document.querySelector('.msg-checkbox')) return; // no checkboxes on page + deleteMode = true; + document.body.classList.add('delete-mode'); + updateSelectionInfo(); + } + + window.exitDeleteMode = function () { + deleteMode = false; + document.body.classList.remove('delete-mode'); + // Uncheck everything + var boxes = document.querySelectorAll('.msg-checkbox'); + for (var i = 0; i < boxes.length; i++) boxes[i].checked = false; + var selectAll = document.getElementById('select-all'); + if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; } + updateSelectionInfo(); + }; + + window.selectAllMessages = function () { + var boxes = document.querySelectorAll('.msg-checkbox'); + for (var i = 0; i < boxes.length; i++) boxes[i].checked = true; + var selectAll = document.getElementById('select-all'); + if (selectAll) { selectAll.checked = true; selectAll.indeterminate = false; } + updateSelectionInfo(); + }; + + function updateSelectionInfo() { + var info = document.getElementById('sel-info'); + var submit = document.getElementById('sel-submit'); + if (!info) return; + var checked = document.querySelectorAll('.msg-checkbox:checked'); + var total = document.querySelectorAll('.msg-checkbox'); + if (checked.length === 0) { + info.textContent = 'Select messages to stage for deletion'; + if (submit) { submit.disabled = true; submit.textContent = 'Stage for Deletion'; } + } else { + info.textContent = checked.length + ' of ' + total.length + ' selected'; + if (submit) { submit.disabled = false; submit.textContent = 'Stage ' + checked.length + ' for Deletion'; } + } + // Update select-all checkbox state + var selectAll = document.getElementById('select-all'); + if (selectAll) { + selectAll.checked = total.length > 0 && checked.length === total.length; + selectAll.indeterminate = checked.length > 0 && checked.length < total.length; + } + } + + function setupSelection() { + document.addEventListener('change', function (e) { + if (e.target.classList.contains('msg-checkbox') || e.target.id === 'select-all') { + if (e.target.id === 'select-all') { + var boxes = document.querySelectorAll('.msg-checkbox'); + for (var i = 0; i < boxes.length; i++) boxes[i].checked = e.target.checked; + } + updateSelectionInfo(); + } + }); + } + + function toggleActiveRowCheckbox() { + if (!isDeleteMode()) return; + var rows = getRows(); + if (activeRow < 0 || activeRow >= rows.length) return; + var cb = rows[activeRow].querySelector('.msg-checkbox'); + if (cb) { + cb.checked = !cb.checked; + updateSelectionInfo(); + } + } + // Reset active row on page load activeRow = -1; setupSearchLoading(); setupThemeToggle(); + setupSelection(); })(); diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 3f90d1fb..a2968b55 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -548,6 +548,86 @@ a:hover { color: var(--accent-hover); text-decoration: underline; } color: var(--fg-muted); } +/* Danger button (small) */ +.btn-danger-sm { + padding: 4px 10px; + font-size: 12px; + font-weight: 600; + border: 1px solid var(--danger); + border-radius: var(--radius); + background: none; + color: var(--danger); + cursor: pointer; + text-decoration: none; +} +.btn-danger-sm:hover { + background: var(--danger); + color: #fff; + text-decoration: none; +} + +/* Command reference table */ +.cmd-table { + width: 100%; + font-size: 13px; + border-collapse: collapse; +} +.cmd-table td { + padding: 4px 0; +} +.cmd-table td:first-child { + padding-right: 16px; + white-space: nowrap; +} +.cmd-table td:last-child { + color: var(--fg-muted); +} +.cmd-table code { + font-family: var(--font); + font-size: 12px; + padding: 2px 6px; + background: var(--bg-alt); + border-radius: var(--radius); +} + +/* Selection / delete mode */ +.sel-cell { + width: 0; + padding: 0 !important; + overflow: hidden; + transition: width 0.15s, padding 0.15s; +} +.sel-cell input[type="checkbox"] { + display: none; +} +.delete-mode .sel-cell { + width: 28px; + padding: 4px 6px !important; +} +.delete-mode .sel-cell input[type="checkbox"] { + display: inline; + cursor: pointer; + margin: 0; +} +.selection-bar { + display: none; + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 10px 24px; + background: var(--bg-alt); + border-top: 2px solid var(--danger); + font-size: 13px; + font-weight: 600; + align-items: center; + gap: 12px; + z-index: 100; +} +.delete-mode .selection-bar { + display: flex; +} + /* Footer */ .footer { padding: 16px 0; diff --git a/internal/web/templates/layout.templ b/internal/web/templates/layout.templ index 85e045d2..dc903ad3 100644 --- a/internal/web/templates/layout.templ +++ b/internal/web/templates/layout.templ @@ -18,6 +18,7 @@ templ Layout(title string, activePage string) { Dashboard Browse Search + Deletions @@ -67,6 +68,22 @@ templ helpOverlay() { Esc Blur search / close help + + d + Enter delete mode + + + Space + Toggle selection (delete mode) + + + A + Select all (delete mode) + + + x + Clear selection (delete mode) + Bksp Go back (breadcrumb) diff --git a/internal/web/templates/layout_templ.go b/internal/web/templates/layout_templ.go index f3f62373..b84741ea 100644 --- a/internal/web/templates/layout_templ.go +++ b/internal/web/templates/layout_templ.go @@ -108,7 +108,29 @@ func Layout(title string, activePage string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">Search") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">Search ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 = []any{templ.KV("active", activePage == "deletions")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Deletions") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -116,7 +138,7 @@ func Layout(title string, activePage string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
msgvault ? shortcuts
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
msgvault ? shortcuts
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -124,7 +146,7 @@ func Layout(title string, activePage string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -148,12 +170,12 @@ func helpOverlay() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var9 := templ.GetChildren(ctx) - if templ_7745c5c3_Var9 == nil { - templ_7745c5c3_Var9 = templ.NopComponent + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

Keyboard Shortcuts

j kNavigate rows up / down
EnterDrill into selected row
oOpen messages for row
g GJump to first / last row
n pNext / previous page
/Focus search (or go to search)
EscBlur search / close help
BkspGo back (breadcrumb)
HGo to Dashboard
BGo to Browse
?Toggle this help
Press ? or Esc to close
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

Keyboard Shortcuts

j kNavigate rows up / down
EnterDrill into selected row
oOpen messages for row
g GJump to first / last row
n pNext / previous page
/Focus search (or go to search)
EscBlur search / close help
dEnter delete mode
SpaceToggle selection (delete mode)
ASelect all (delete mode)
xClear selection (delete mode)
BkspGo back (breadcrumb)
HGo to Dashboard
BGo to Browse
?Toggle this help
Press ? or Esc to close
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/web/templates/messages.templ b/internal/web/templates/messages.templ index 011c19ee..e431e94c 100644 --- a/internal/web/templates/messages.templ +++ b/internal/web/templates/messages.templ @@ -156,33 +156,37 @@ templ Messages(data MessagesData) { } templ MessageTable(data MessagesData) { - - - - - - - - - - - for _, msg := range data.Messages { - @messageRow(msg) - } - -
- - Date{ data.sortIndicator("date") } - - From - - Subject{ data.sortIndicator("subject") } - - - - Size{ data.sortIndicator("size") } - -
+
+ + + + + + + + + + + + for _, msg := range data.Messages { + @messageRow(msg) + } + +
+ + Date{ data.sortIndicator("date") } + + From + + Subject{ data.sortIndicator("subject") } + + + + Size{ data.sortIndicator("size") } + +
+ @selectionBar() +
} templ pagination(data MessagesData) { @@ -204,26 +208,45 @@ templ pagination(data MessagesData) { // messageListTable renders a simple (non-sortable) message table. // Shared between search results and other simple message lists. templ messageListTable(messages []query.MessageSummary) { - - - - - - - - - - - for _, msg := range messages { - @messageRow(msg) - } - -
DateFromSubjectSize
+
+ + + + + + + + + + + + for _, msg := range messages { + @messageRow(msg) + } + +
DateFromSubjectSize
+ @selectionBar() +
+} + +templ selectionBar() { +
+ Select messages to stage for deletion + + + + +
} // messageRow renders a single message row, shared across all message tables. templ messageRow(msg query.MessageSummary) { + + + { formatMessageDate(msg.SentAt) } diff --git a/internal/web/templates/messages_templ.go b/internal/web/templates/messages_templ.go index 57803aae..10507f21 100644 --- a/internal/web/templates/messages_templ.go +++ b/internal/web/templates/messages_templ.go @@ -291,14 +291,14 @@ func MessageTable(data MessagesData) templ.Component { templ_7745c5c3_Var7 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } From 7b0f3b9b219a21dcd02117d0bbc55c69061304e4 Mon Sep 17 00:00:00 2001 From: dolatron Date: Sun, 8 Mar 2026 14:16:09 -0400 Subject: [PATCH 14/17] Add attachment downloads, search sorting, prev/next nav, and deletion UX improvements Bug fixes: - Distinguish 500 vs 404 in handleStageMessage - Extract parseBaseMessageFilter to eliminate duplication - Fix addParam to use url.Parse/url.Values instead of string concatenation - Fix back URL to skip referers from message detail pages (prev/next loop) Features: - Attachment download handler with hash validation, filename sanitization, Content-Type safety, and X-Content-Type-Options: nosniff - Search result sorting (Date, Subject, Size) with dynamic ORDER BY in searchPageFromCache; sort controls hidden in deep search mode - Prev/next message navigation via sessionStorage with arrow key shortcuts - Flash messages on deletion staging with auto-dismiss - Batch size limit (10k) and MaxBytesReader guard on stage-batch endpoint - Help overlay updated with new keyboard shortcuts --- internal/api/server.go | 2 +- internal/query/duckdb.go | 28 ++- internal/web/handlers.go | 121 ++++++++- internal/web/handlers_deletions.go | 21 +- internal/web/params.go | 59 ++--- internal/web/server.go | 12 +- internal/web/static/keys.js | 87 ++++++- internal/web/static/style.css | 34 +++ internal/web/templates/deletions.templ | 7 + internal/web/templates/deletions_templ.go | 123 +++++---- internal/web/templates/helpers.go | 12 +- internal/web/templates/layout.templ | 4 + internal/web/templates/layout_templ.go | 2 +- internal/web/templates/message_detail.templ | 8 +- .../web/templates/message_detail_templ.go | 127 ++++++---- internal/web/templates/search.templ | 73 +++++- internal/web/templates/search_templ.go | 238 +++++++++++++++--- 17 files changed, 761 insertions(+), 197 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 490667f7..582921e4 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -151,7 +151,7 @@ func (s *Server) setupRouter() chi.Router { } else { delMgr = mgr } - webHandler := web.NewHandler(s.engine, delMgr) + webHandler := web.NewHandler(s.engine, delMgr, s.cfg.AttachmentsDir()) r.Group(func(r chi.Router) { r.Use(s.authMiddleware) r.Mount("/", webHandler.Routes()) diff --git a/internal/query/duckdb.go b/internal/query/duckdb.go index d8465195..4bc12b13 100644 --- a/internal/query/duckdb.go +++ b/internal/query/duckdb.go @@ -1755,12 +1755,28 @@ func (e *DuckDBEngine) dropSearchCache() { // searchPageFromCache executes Phase 3 (paginated results) from the cached temp table. // Returns a SearchFastResult with cached count and stats. -func (e *DuckDBEngine) searchPageFromCache(ctx context.Context, limit, offset int) (*SearchFastResult, error) { +func (e *DuckDBEngine) searchPageFromCache(ctx context.Context, sorting MessageSorting, limit, offset int) (*SearchFastResult, error) { + // Build ORDER BY clause from sorting parameters. + var orderCol string + switch sorting.Field { + case MessageSortBySize: + orderCol = "sm.size_estimate" + case MessageSortBySubject: + orderCol = "sm.subject" + default: + orderCol = "sm.sent_at" + } + orderDir := "DESC" + if sorting.Direction == SortAsc { + orderDir = "ASC" + } + orderBy := orderCol + " " + orderDir + pageQuery := fmt.Sprintf(` WITH %s, page AS ( SELECT sm.id FROM %s sm - ORDER BY sm.sent_at DESC + ORDER BY %s LIMIT ? OFFSET ? ), msg_labels AS ( @@ -1790,8 +1806,8 @@ func (e *DuckDBEngine) searchPageFromCache(ctx context.Context, limit, offset in LEFT JOIN att ON att.message_id = sm.id LEFT JOIN msg_labels mlbl ON mlbl.message_id = sm.id LEFT JOIN conv c ON c.id = sm.conversation_id - ORDER BY sm.sent_at DESC - `, e.parquetCTEs(), e.searchCacheTable, e.searchCacheTable) + ORDER BY %s + `, e.parquetCTEs(), e.searchCacheTable, orderBy, e.searchCacheTable, orderBy) rows, err := e.db.QueryContext(ctx, pageQuery, limit, offset) if err != nil { @@ -1927,7 +1943,7 @@ func (e *DuckDBEngine) SearchFastWithStats(ctx context.Context, q *search.Query, if e.searchCacheStats == nil { e.searchCacheStats = e.computeSearchStats(ctx) } - return e.searchPageFromCache(ctx, limit, offset) + return e.searchPageFromCache(ctx, filter.Sorting, limit, offset) } // Cache miss — drop old cache and materialize fresh. @@ -1993,7 +2009,7 @@ func (e *DuckDBEngine) SearchFastWithStats(ctx context.Context, q *search.Query, e.searchCacheKey = cacheKey // Phase 3: Paginated results from cached temp table. - return e.searchPageFromCache(ctx, limit, offset) + return e.searchPageFromCache(ctx, filter.Sorting, limit, offset) } // buildSearchConditions builds WHERE conditions for search queries. diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 46f250b7..b7530c45 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -2,9 +2,13 @@ package web import ( "bytes" + "fmt" "log/slog" "net/http" "net/url" + "os" + "path/filepath" + "regexp" "strconv" "strings" @@ -257,12 +261,19 @@ func (h *Handler) handleMessageDetail(w http.ResponseWriter, r *http.Request) { // Build back URL from referer, restricted to same-origin paths only. // Only allow relative paths (starting with /) or same-host URLs to prevent // javascript: URI injection via templ.SafeURL. + // Skip referers that point to another message detail page (e.g. from prev/next + // navigation) — those would make "Back to messages" loop between messages. backURL := "/messages" if ref := r.Header.Get("Referer"); ref != "" { if u, err := url.Parse(ref); err == nil { + var refPath string if u.Scheme == "" && u.Host == "" && strings.HasPrefix(u.Path, "/") { - backURL = u.RequestURI() + refPath = u.Path } else if u.Host == r.Host && (u.Scheme == "http" || u.Scheme == "https") { + refPath = u.Path + } + // Only use referer if it's not another message detail page + if refPath != "" && !strings.HasPrefix(refPath, "/messages/") { backURL = u.RequestURI() } } @@ -295,6 +306,9 @@ func (h *Handler) handleSearch(w http.ResponseWriter, r *http.Request) { hideDeleted := parseBool(r, "hide_deleted") attachments := parseBool(r, "attachments") + sortField := parseMessageSortField(r) + sortDir := parseSortDirection(r) + data := templates.SearchData{ Query: queryStr, Mode: mode, @@ -302,6 +316,8 @@ func (h *Handler) handleSearch(w http.ResponseWriter, r *http.Request) { PageSize: pageSize, HideDeleted: hideDeleted, Attachments: attachments, + SortField: messageSortFieldToString(sortField), + SortDir: sortDirToString(sortDir), } if queryStr != "" { @@ -319,6 +335,10 @@ func (h *Handler) handleSearch(w http.ResponseWriter, r *http.Request) { filter := query.MessageFilter{ HideDeletedFromSource: hideDeleted, WithAttachmentsOnly: attachments, + Sorting: query.MessageSorting{ + Field: sortField, + Direction: sortDir, + }, } var messages []query.MessageSummary @@ -373,3 +393,102 @@ func (h *Handler) handleSearch(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = buf.WriteTo(w) } + +// validContentHash matches a SHA-256 hex string (64 lowercase hex chars). +var validContentHash = regexp.MustCompile(`^[0-9a-f]{64}$`) + +func (h *Handler) handleAttachmentDownload(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + http.Error(w, "Invalid attachment ID", http.StatusBadRequest) + return + } + + att, err := h.engine.GetAttachment(ctx, id) + if err != nil { + slog.Error("failed to get attachment", "error", err, "id", id) + http.Error(w, "Failed to load attachment", http.StatusInternalServerError) + return + } + if att == nil { + http.Error(w, "Attachment not found", http.StatusNotFound) + return + } + + if att.ContentHash == "" || !validContentHash.MatchString(att.ContentHash) { + http.Error(w, "Attachment not available for download", http.StatusNotFound) + return + } + + if h.attachmentsDir == "" { + http.Error(w, "Attachment storage not configured", http.StatusServiceUnavailable) + return + } + + filePath := filepath.Join(h.attachmentsDir, att.ContentHash[:2], att.ContentHash) + + f, err := os.Open(filePath) + if err != nil { + slog.Error("failed to open attachment file", "error", err, "path", filePath) + http.Error(w, "Attachment file not found", http.StatusNotFound) + return + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + http.Error(w, "Failed to read attachment", http.StatusInternalServerError) + return + } + + filename := sanitizeFilename(att.Filename) + if filename == "" { + filename = "attachment" + } + + // Determine content type: use stored MIME type if valid, otherwise + // fall back to application/octet-stream. Never let the browser sniff. + contentType := "application/octet-stream" + if att.MimeType != "" && isValidMimeType(att.MimeType) { + contentType = att.MimeType + } + + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + w.Header().Set("Content-Type", contentType) + w.Header().Set("X-Content-Type-Options", "nosniff") + + http.ServeContent(w, r, "", fi.ModTime(), f) +} + +// isValidMimeType checks that a MIME type string is safe to use as a +// Content-Type header value (no control chars, reasonable format). +func isValidMimeType(mt string) bool { + for _, c := range mt { + if c < 0x20 || c == 0x7f { + return false + } + } + return strings.Contains(mt, "/") +} + +// sanitizeFilename removes path separators, quotes, and control characters +// from a filename for use in Content-Disposition headers. +func sanitizeFilename(name string) string { + var b strings.Builder + b.Grow(len(name)) + for _, c := range name { + switch { + case c < 0x20 || c == 0x7f: // control characters + continue + case c == '/' || c == '\\': + b.WriteRune('_') + case c == '"': + b.WriteRune('\'') + default: + b.WriteRune(c) + } + } + return b.String() +} diff --git a/internal/web/handlers_deletions.go b/internal/web/handlers_deletions.go index bf5c635d..c5370368 100644 --- a/internal/web/handlers_deletions.go +++ b/internal/web/handlers_deletions.go @@ -28,11 +28,16 @@ func (h *Handler) handleDeletions(w http.ResponseWriter, r *http.Request) { completed, _ := h.deletions.ListCompleted() failed, _ := h.deletions.ListFailed() + flash := r.URL.Query().Get("flash") + flashCount, _ := strconv.Atoi(r.URL.Query().Get("count")) + data := templates.DeletionsData{ Pending: pending, InProgress: inProgress, Completed: completed, Failed: failed, + Flash: flash, + FlashCount: flashCount, } var buf bytes.Buffer @@ -53,6 +58,8 @@ func (h *Handler) handleStageBatch(w http.ResponseWriter, r *http.Request) { return } + // Limit form body size before parsing to prevent memory exhaustion. + r.Body = http.MaxBytesReader(w, r.Body, 2<<20) // 2 MB if err := r.ParseForm(); err != nil { http.Error(w, "Invalid form data", http.StatusBadRequest) return @@ -66,6 +73,12 @@ func (h *Handler) handleStageBatch(w http.ResponseWriter, r *http.Request) { return } + const maxBatchSize = 10000 + if len(gmailIDs) > maxBatchSize { + http.Error(w, fmt.Sprintf("Too many messages (max %d)", maxBatchSize), http.StatusBadRequest) + return + } + // Look up account from the first message; leave empty if lookup fails // (the CLI will resolve per-ID at execution time) var account string @@ -86,7 +99,7 @@ func (h *Handler) handleStageBatch(w http.ResponseWriter, r *http.Request) { return } - http.Redirect(w, r, "/deletions", http.StatusSeeOther) + http.Redirect(w, r, fmt.Sprintf("/deletions?flash=staged&count=%d", len(gmailIDs)), http.StatusSeeOther) } // handleStageMessage stages a single message for deletion by its database ID. @@ -108,6 +121,10 @@ func (h *Handler) handleStageMessage(w http.ResponseWriter, r *http.Request) { msg, err := h.engine.GetMessage(ctx, msgID) if err != nil { slog.Error("failed to load message for deletion", "error", err, "id", msgID) + http.Error(w, "Failed to load message", http.StatusInternalServerError) + return + } + if msg == nil { http.Error(w, "Message not found", http.StatusNotFound) return } @@ -132,7 +149,7 @@ func (h *Handler) handleStageMessage(w http.ResponseWriter, r *http.Request) { return } - http.Redirect(w, r, "/deletions", http.StatusSeeOther) + http.Redirect(w, r, "/deletions?flash=staged&count=1", http.StatusSeeOther) } func (h *Handler) handleCancelDeletion(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/params.go b/internal/web/params.go index a871e930..f821dc9d 100644 --- a/internal/web/params.go +++ b/internal/web/params.go @@ -187,7 +187,9 @@ func parsePage(r *http.Request) int { return p } -func parseMessageFilter(r *http.Request) query.MessageFilter { +// parseBaseMessageFilter extracts the shared filter fields from a request. +// Used by parseMessageFilter, parseDrillFilter, and search handlers. +func parseBaseMessageFilter(r *http.Request) query.MessageFilter { q := r.URL.Query() f := query.MessageFilter{ Sender: q.Get("sender"), @@ -199,13 +201,10 @@ func parseMessageFilter(r *http.Request) query.MessageFilter { SourceID: parseOptionalInt64(r, "account"), WithAttachmentsOnly: parseBool(r, "attachments"), HideDeletedFromSource: parseBool(r, "hide_deleted"), - Sorting: query.MessageSorting{ - Field: parseMessageSortField(r), - Direction: parseSortDirection(r), - }, } - // Handle empty-key filters + // Handle empty-key filters: when a filter param is present but empty, + // set EmptyValueTargets so the query engine filters for NULL/empty values. emptyTargets := map[string]query.ViewType{ "sender": query.ViewSenders, "sender_name": query.ViewSenderNames, @@ -228,6 +227,16 @@ func parseMessageFilter(r *http.Request) query.MessageFilter { } } + return f +} + +func parseMessageFilter(r *http.Request) query.MessageFilter { + f := parseBaseMessageFilter(r) + f.Sorting = query.MessageSorting{ + Field: parseMessageSortField(r), + Direction: parseSortDirection(r), + } + convID := parseOptionalInt64(r, "conversation") if convID != nil { f.ConversationID = convID @@ -243,41 +252,5 @@ func parseMessageFilter(r *http.Request) query.MessageFilter { } func parseDrillFilter(r *http.Request) query.MessageFilter { - q := r.URL.Query() - f := query.MessageFilter{ - Sender: q.Get("sender"), - SenderName: q.Get("sender_name"), - Recipient: q.Get("recipient"), - RecipientName: q.Get("recipient_name"), - Domain: q.Get("domain"), - Label: q.Get("label"), - SourceID: parseOptionalInt64(r, "account"), - WithAttachmentsOnly: parseBool(r, "attachments"), - HideDeletedFromSource: parseBool(r, "hide_deleted"), - } - - // Handle empty-key drill-down: when a filter param is present but empty, - // set EmptyValueTargets so the query engine filters for NULL/empty values. - emptyTargets := map[string]query.ViewType{ - "sender": query.ViewSenders, - "sender_name": query.ViewSenderNames, - "recipient": query.ViewRecipients, - "recipient_name": query.ViewRecipientNames, - "domain": query.ViewDomains, - "label": query.ViewLabels, - } - for param, viewType := range emptyTargets { - if _, ok := q[param]; ok && q.Get(param) == "" { - f.SetEmptyTarget(viewType) - } - } - - timePeriod := q.Get("time_period") - if timePeriod != "" { - f.TimeRange = query.TimeRange{ - Period: timePeriod, - Granularity: parseTimeGranularity(r), - } - } - return f + return parseBaseMessageFilter(r) } diff --git a/internal/web/server.go b/internal/web/server.go index 58e083db..c785a21c 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -17,18 +17,19 @@ var staticFS embed.FS // Handler serves the web UI. type Handler struct { - engine query.Engine - deletions *deletion.Manager - staticFS fs.FS + engine query.Engine + deletions *deletion.Manager + staticFS fs.FS + attachmentsDir string } // NewHandler creates a new web UI handler. -func NewHandler(engine query.Engine, deletions *deletion.Manager) *Handler { +func NewHandler(engine query.Engine, deletions *deletion.Manager, attachmentsDir string) *Handler { staticSub, err := fs.Sub(staticFS, "static") if err != nil { panic(fmt.Sprintf("web: failed to sub static FS: %v", err)) } - return &Handler{engine: engine, deletions: deletions, staticFS: staticSub} + return &Handler{engine: engine, deletions: deletions, staticFS: staticSub, attachmentsDir: attachmentsDir} } // Routes returns a chi.Router with all web UI routes mounted. @@ -45,6 +46,7 @@ func (h *Handler) Routes() chi.Router { r.Get("/browse/drill", h.handleDrill) r.Get("/messages", h.handleMessages) r.Get("/messages/{id}", h.handleMessageDetail) + r.Get("/attachments/{id}/download", h.handleAttachmentDownload) r.Get("/search", h.handleSearch) // Deletion staging diff --git a/internal/web/static/keys.js b/internal/web/static/keys.js index 46f40eb4..784d7ae0 100644 --- a/internal/web/static/keys.js +++ b/internal/web/static/keys.js @@ -67,6 +67,64 @@ }); } + // Store message IDs for prev/next navigation + function storeMessageList() { + var rows = getRows(); + var ids = []; + for (var i = 0; i < rows.length; i++) { + var link = rows[i].querySelector('a[href^="/messages/"]'); + if (link) { + var match = link.getAttribute('href').match(/\/messages\/(\d+)/); + if (match) ids.push(match[1]); + } + } + if (ids.length > 0) { + sessionStorage.setItem('msgvault-msg-list', JSON.stringify(ids)); + } + } + + // On message detail page, add prev/next navigation links + function setupMessageNav() { + var path = window.location.pathname; + var match = path.match(/^\/messages\/(\d+)$/); + if (!match) return; + + var currentId = match[1]; + var ids = JSON.parse(sessionStorage.getItem('msgvault-msg-list') || '[]'); + var idx = ids.indexOf(currentId); + if (idx < 0) return; + + var nav = document.querySelector('.breadcrumb'); + if (!nav) return; + + var navSpan = document.createElement('span'); + navSpan.className = 'msg-nav'; + if (idx > 0) { + var prev = document.createElement('a'); + prev.href = '/messages/' + ids[idx - 1]; + prev.innerHTML = '← Prev'; + prev.className = 'msg-nav-link'; + prev.id = 'msg-prev'; + navSpan.appendChild(prev); + } + if (idx < ids.length - 1) { + var next = document.createElement('a'); + next.href = '/messages/' + ids[idx + 1]; + next.innerHTML = 'Next →'; + next.className = 'msg-nav-link'; + next.id = 'msg-next'; + navSpan.appendChild(next); + } + if (navSpan.children.length > 0) { + // Add position indicator + var pos = document.createElement('span'); + pos.className = 'msg-nav-pos'; + pos.textContent = (idx + 1) + ' / ' + ids.length; + navSpan.appendChild(pos); + nav.appendChild(navSpan); + } + } + document.addEventListener('keydown', function (e) { // Always allow Escape to close help / exit delete mode if (e.key === 'Escape') { @@ -139,7 +197,7 @@ break; case 'g': - // gg = go to top (first row) + // Go to first row e.preventDefault(); setActive(0); break; @@ -185,6 +243,18 @@ } break; + case 'ArrowLeft': + // Previous message (detail view) + var prevMsg = document.getElementById('msg-prev'); + if (prevMsg) { prevMsg.click(); e.preventDefault(); } + break; + + case 'ArrowRight': + // Next message (detail view) + var nextMsg = document.getElementById('msg-next'); + if (nextMsg) { nextMsg.click(); e.preventDefault(); } + break; + case 'd': // Enter delete mode if (!isDeleteMode()) { @@ -338,9 +408,24 @@ } } + // Auto-dismiss flash notices + function setupFlashDismiss() { + var flash = document.querySelector('.flash-notice'); + if (flash) { + setTimeout(function () { + flash.style.transition = 'opacity 0.3s'; + flash.style.opacity = '0'; + setTimeout(function () { flash.remove(); }, 300); + }, 4000); + } + } + // Reset active row on page load activeRow = -1; setupSearchLoading(); setupThemeToggle(); setupSelection(); + storeMessageList(); + setupMessageNav(); + setupFlashDismiss(); })(); diff --git a/internal/web/static/style.css b/internal/web/static/style.css index a2968b55..2d049ee9 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -628,6 +628,40 @@ a:hover { color: var(--accent-hover); text-decoration: underline; } display: flex; } +/* Flash notices */ +.flash-notice { + padding: 8px 16px; + margin-bottom: 16px; + background: rgba(42, 161, 152, 0.12); + border: 1px solid #2aa198; + border-radius: var(--radius); + color: var(--fg); + font-size: 13px; +} + +/* Message navigation (prev/next) */ +.msg-nav { + float: right; + display: flex; + gap: 12px; + align-items: center; +} +.msg-nav-link { + font-size: 13px; +} +.msg-nav-pos { + font-size: 12px; + color: var(--fg-muted); +} + +/* Attachment download links */ +.msg-attachment-link { + color: var(--accent); +} +.msg-attachment-link:hover { + color: var(--accent-hover); +} + /* Footer */ .footer { padding: 16px 0; diff --git a/internal/web/templates/deletions.templ b/internal/web/templates/deletions.templ index 5ce8a556..febf8e02 100644 --- a/internal/web/templates/deletions.templ +++ b/internal/web/templates/deletions.templ @@ -10,6 +10,8 @@ type DeletionsData struct { InProgress []*deletion.Manifest Completed []*deletion.Manifest Failed []*deletion.Manifest + Flash string + FlashCount int } func (d DeletionsData) totalCount() int { @@ -18,6 +20,11 @@ func (d DeletionsData) totalCount() int { templ DeletionsPage(data DeletionsData) { @Layout("Deletions", "deletions") { + if data.Flash == "staged" { +
+ Successfully staged { fmt.Sprintf("%d", data.FlashCount) } message(s) for deletion. +
+ }

Deletion Batches

if data.totalCount() == 0 {
diff --git a/internal/web/templates/deletions_templ.go b/internal/web/templates/deletions_templ.go index 6b9bea4a..7e5dc910 100644 --- a/internal/web/templates/deletions_templ.go +++ b/internal/web/templates/deletions_templ.go @@ -18,6 +18,8 @@ type DeletionsData struct { InProgress []*deletion.Manifest Completed []*deletion.Manifest Failed []*deletion.Manifest + Flash string + FlashCount int } func (d DeletionsData) totalCount() int { @@ -57,12 +59,31 @@ func DeletionsPage(data DeletionsData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Deletion Batches

") + if data.Flash == "staged" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
Successfully staged ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.FlashCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 25, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " message(s) for deletion.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Deletion Batches

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.totalCount() == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
No deletion batches

Stage individual messages for deletion from the message detail view, then execute with the CLI.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
No deletion batches

Stage individual messages for deletion from the message detail view, then execute with the CLI.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -73,7 +94,7 @@ func DeletionsPage(data DeletionsData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -83,7 +104,7 @@ func DeletionsPage(data DeletionsData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -93,7 +114,7 @@ func DeletionsPage(data DeletionsData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -104,7 +125,7 @@ func DeletionsPage(data DeletionsData) templ.Component { } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
How to execute

Staged batches are executed from the command line:

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } return nil }) } @@ -409,66 +417,66 @@ func pagination(data MessagesData) templ.Component { } ctx = templ.ClearChildren(ctx) if data.Page > 1 || data.HasMore { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.Page > 1 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Prev ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">Prev ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "Page ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "Page ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Page)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 195, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 199, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.HasMore { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "Next") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">Next") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -500,7 +508,7 @@ func messageListTable(messages []query.MessageSummary) templ.Component { templ_7745c5c3_Var18 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
DateFromSubjectSize
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -510,7 +518,15 @@ func messageListTable(messages []query.MessageSummary) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
DateFromSubjectSize
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = selectionBar().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -518,8 +534,7 @@ func messageListTable(messages []query.MessageSummary) templ.Component { }) } -// messageRow renders a single message row, shared across all message tables. -func messageRow(msg query.MessageSummary) templ.Component { +func selectionBar() templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -540,127 +555,170 @@ func messageRow(msg query.MessageSummary) templ.Component { templ_7745c5c3_Var19 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
Select messages to stage for deletion
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// messageRow renders a single message row, shared across all message tables. +func messageRow(msg query.MessageSummary) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var20 := templ.GetChildren(ctx) + if templ_7745c5c3_Var20 == nil { + templ_7745c5c3_Var20 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 228, Col: 34} + return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(formatMessageDate(msg.SentAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 251, Col: 34} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if msg.FromName != "" { - var templ_7745c5c3_Var21 string - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(msg.FromName) + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(msg.FromName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 232, Col: 18} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 255, Col: 18} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(msg.FromEmail) + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(msg.FromEmail) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 234, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 257, Col: 19} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if msg.Subject != "" { - var templ_7745c5c3_Var24 string - templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(msg.Subject) + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(msg.Subject) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 240, Col: 18} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 263, Col: 18} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "(no subject)") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "(no subject)") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if msg.HasAttachments { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" class=\"attachment-badge\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var26 string - templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("[%d]", msg.AttachmentCount)) + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("[%d]", msg.AttachmentCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 247, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 270, Col: 47} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(msg.SizeEstimate)) + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(msg.SizeEstimate)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 251, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 274, Col: 49} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
msgvault delete-stagedExecute all pending
msgvault delete-staged <batch-id>Execute specific batch
msgvault delete-staged --dry-runPreview without deleting
msgvault delete-staged --trashMove to trash (recoverable)
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
How to execute

Staged batches are executed from the command line:

msgvault delete-stagedExecute all pending
msgvault delete-staged <batch-id>Execute specific batch
msgvault delete-staged --dry-runPreview without deleting
msgvault delete-staged --trashMove to trash (recoverable)
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -134,133 +155,133 @@ func manifestSection(title string, status string, manifests []*deletion.Manifest }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var3 := templ.GetChildren(ctx) - if templ_7745c5c3_Var3 == nil { - templ_7745c5c3_Var3 = templ.NopComponent + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title) + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 70, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 77, Col: 33} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " (") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " (") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(manifests))) + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(manifests))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 70, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 77, Col: 72} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ")
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ")
Batch IDDescriptionMessagesCreated
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if showCancel { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, m := range manifests { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if showCancel { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" style=\"display: inline;\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Batch IDDescriptionMessagesCreated
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(m.ID) + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(m.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 86, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 93, Col: 67} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(m.Description) + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(m.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 87, Col: 25} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 94, Col: 25} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(int64(len(m.GmailIDs)))) + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(int64(len(m.GmailIDs)))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 88, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 95, Col: 59} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(m.CreatedAt.Format("Jan 02 15:04")) + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(m.CreatedAt.Format("Jan 02 15:04")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 89, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 96, Col: 64} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/web/templates/helpers.go b/internal/web/templates/helpers.go index 13022783..c46de1be 100644 --- a/internal/web/templates/helpers.go +++ b/internal/web/templates/helpers.go @@ -5,7 +5,6 @@ import ( "html" "net/url" "regexp" - "strings" "time" ) @@ -56,11 +55,14 @@ func addParam(base, key, value string) string { if value == "" { return base } - sep := "&" - if !strings.Contains(base, "?") { - sep = "?" + u, err := url.Parse(base) + if err != nil { + return base } - return base + sep + url.QueryEscape(key) + "=" + url.QueryEscape(value) + q := u.Query() + q.Set(key, value) + u.RawQuery = q.Encode() + return u.String() } // deleteParam removes a query parameter from a URL string. diff --git a/internal/web/templates/layout.templ b/internal/web/templates/layout.templ index dc903ad3..d14b0039 100644 --- a/internal/web/templates/layout.templ +++ b/internal/web/templates/layout.templ @@ -84,6 +84,10 @@ templ helpOverlay() { x Clear selection (delete mode) + + + Previous / next message + Bksp Go back (breadcrumb) diff --git a/internal/web/templates/layout_templ.go b/internal/web/templates/layout_templ.go index b84741ea..7994b781 100644 --- a/internal/web/templates/layout_templ.go +++ b/internal/web/templates/layout_templ.go @@ -175,7 +175,7 @@ func helpOverlay() templ.Component { templ_7745c5c3_Var11 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

Keyboard Shortcuts

j kNavigate rows up / down
EnterDrill into selected row
oOpen messages for row
g GJump to first / last row
n pNext / previous page
/Focus search (or go to search)
EscBlur search / close help
dEnter delete mode
SpaceToggle selection (delete mode)
ASelect all (delete mode)
xClear selection (delete mode)
BkspGo back (breadcrumb)
HGo to Dashboard
BGo to Browse
?Toggle this help
Press ? or Esc to close
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

Keyboard Shortcuts

j kNavigate rows up / down
EnterDrill into selected row
oOpen messages for row
g GJump to first / last row
n pNext / previous page
/Focus search (or go to search)
EscBlur search / close help
dEnter delete mode
SpaceToggle selection (delete mode)
ASelect all (delete mode)
xClear selection (delete mode)
Previous / next message
BkspGo back (breadcrumb)
HGo to Dashboard
BGo to Browse
?Toggle this help
Press ? or Esc to close
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/web/templates/message_detail.templ b/internal/web/templates/message_detail.templ index 15415afe..0b9a1a74 100644 --- a/internal/web/templates/message_detail.templ +++ b/internal/web/templates/message_detail.templ @@ -102,7 +102,13 @@ templ messageHeader(msg *query.MessageDetail) {
for _, att := range msg.Attachments {
- { att.Filename } + if att.ContentHash != "" { + + { att.Filename } + + } else { + { att.Filename } + } ({ formatBytes(att.Size) })
} diff --git a/internal/web/templates/message_detail_templ.go b/internal/web/templates/message_detail_templ.go index 3282696d..ca40b0fa 100644 --- a/internal/web/templates/message_detail_templ.go +++ b/internal/web/templates/message_detail_templ.go @@ -304,71 +304,108 @@ func messageHeader(msg *query.MessageDetail) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(att.Filename) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 105, Col: 20} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + if att.ContentHash != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(att.Filename) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 107, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(att.Filename) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 110, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " (") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "(") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(att.Size)) + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(att.Size)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 106, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 112, Col: 64} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, ")") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, ")") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if msg.ConversationID > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">View thread") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" style=\"display: inline;\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -392,58 +429,58 @@ func messageBody(msg *query.MessageDetail) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var17 := templ.GetChildren(ctx) - if templ_7745c5c3_Var17 == nil { - templ_7745c5c3_Var17 = templ.NopComponent + templ_7745c5c3_Var19 := templ.GetChildren(ctx) + if templ_7745c5c3_Var19 == nil { + templ_7745c5c3_Var19 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if msg.BodyText != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
")
+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
")
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			var templ_7745c5c3_Var18 string
-			templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(msg.BodyText)
+			var templ_7745c5c3_Var20 string
+			templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(msg.BodyText)
 			if templ_7745c5c3_Err != nil {
-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 131, Col: 39}
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 137, Col: 39}
 			}
-			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if msg.BodyHTML != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
")
+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
")
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			var templ_7745c5c3_Var19 string
-			templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(htmlToPlainText(msg.BodyHTML))
+			var templ_7745c5c3_Var21 string
+			templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(htmlToPlainText(msg.BodyHTML))
 			if templ_7745c5c3_Err != nil {
-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 133, Col: 56}
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 139, Col: 56}
 			}
-			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "

(No message content)

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "

(No message content)

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/web/templates/search.templ b/internal/web/templates/search.templ index c8c46293..043c8cce 100644 --- a/internal/web/templates/search.templ +++ b/internal/web/templates/search.templ @@ -16,12 +16,20 @@ type SearchData struct { Stats *query.TotalStats HideDeleted bool Attachments bool + SortField string + SortDir string } func (d SearchData) baseQuery() url.Values { q := url.Values{} q.Set("q", d.Query) q.Set("mode", d.Mode) + if d.SortField != "" && d.SortField != "date" { + q.Set("sort", d.SortField) + } + if d.SortDir != "" && d.SortDir != "desc" { + q.Set("dir", d.SortDir) + } if d.HideDeleted { q.Set("hide_deleted", "1") } @@ -61,6 +69,28 @@ func (d SearchData) filterToggleURL(key string, currentlyOn bool) string { return "/search?" + q.Encode() } +func (d SearchData) searchSortURL(field string) string { + dir := "desc" + if d.SortField == field && d.SortDir == "desc" { + dir = "asc" + } + q := d.baseQuery() + q.Set("sort", field) + q.Set("dir", dir) + q.Del("page") + return "/search?" + q.Encode() +} + +func (d SearchData) searchSortIndicator(field string) string { + if d.SortField != field { + return "" + } + if d.SortDir == "asc" { + return " ↑" + } + return " ↓" +} + templ Search(data SearchData) { @Layout("Search", "search") { if data.Stats != nil { @@ -146,12 +176,53 @@ templ Search(data SearchData) { (page { fmt.Sprintf("%d", data.Page) }) } - @messageListTable(data.Messages) + @searchMessageTable(data) @searchPagination(data) } } } +templ searchMessageTable(data SearchData) { +
+ + + + + if data.Mode == "fast" { + + + + + } else { + + + + + } + + + + for _, msg := range data.Messages { + @messageRow(msg) + } + +
+ + Date{ data.searchSortIndicator("date") } + + From + + Subject{ data.searchSortIndicator("subject") } + + + + Size{ data.searchSortIndicator("size") } + + DateFromSubjectSize
+ @selectionBar() +
+} + templ searchPagination(data SearchData) { if data.Page > 1 || data.HasMore {