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 bbe55336..b15b40b1 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..3183137d --- /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 inline attachment preview (download only) +- 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/docs/web-ui.md b/docs/web-ui.md new file mode 100644 index 00000000..1e68312a --- /dev/null +++ b/docs/web-ui.md @@ -0,0 +1,152 @@ +# Web UI + +The web UI provides a browser-based interface for browsing, searching, and managing your msgvault email archive. It runs as part of the HTTP API server and requires no separate process. + +## Starting the Web UI + +```bash +# Start the server (web UI + API) +msgvault serve + +# Custom port and bind address +msgvault serve --port 9090 --bind 0.0.0.0 +``` + +The web UI is available at `http://127.0.0.1:8080/` by default. + +**Prerequisites:** The Parquet analytics cache must be built before using the web UI: + +```bash +msgvault build-cache +``` + +## Pages + +### Dashboard (`/`) + +Overview of your archive with message counts, total size, date range, and per-account statistics. + +### Browse (`/browse`) + +Aggregate view of messages grouped by sender, sender name, recipient, domain, label, or time period. Click any row to drill down into sub-groups, or click the message count to view individual messages. + +**Controls:** +- View tabs: Senders, Names, Recipients, Domains, Labels, Time +- Sort: Name, Count, Size, Attachment Size +- Filters: Account selector, Attachments Only, Hide Deleted +- Time granularity: Year, Month, Day (in Time view) + +### Search (`/search`) + +Full-text search with Gmail-like query syntax. + +**Search modes:** +- **Fast** (default): Searches subject and sender metadata. Returns results quickly with aggregate stats. +- **Deep**: Searches full message body text via FTS5. Slower but more thorough. + +**Query syntax examples:** +- `from:user@example.com` — messages from a specific sender +- `subject:invoice` — subject line contains "invoice" +- `has:attachment` — messages with attachments +- `before:2024/01/01` — messages before a date +- `after:2023/06/01 from:boss@company.com` — combined filters + +Search results support sorting by date, subject, or size. + +### Message Detail (`/messages/{id}`) + +Full message view showing headers (From, To, Cc, Bcc), labels, attachments with download links, and message body. + +**Features:** +- Attachment download (click filename to download) +- Thread view link (View thread) +- Stage for deletion button +- Prev/next navigation between messages (when navigating from a list) + +### Deletions (`/deletions`) + +Manage deletion batches. Messages staged for deletion from the web UI appear here as pending batches. Execute deletions from the CLI: + +```bash +msgvault delete-staged # Execute all pending +msgvault delete-staged # Execute specific batch +msgvault delete-staged --dry-run # Preview without deleting +msgvault delete-staged --trash # Move to trash (recoverable) +``` + +## Keyboard Shortcuts + +The web UI supports vim-style keyboard navigation: + +| Key | Action | +|-----|--------| +| `j` / `k` | Navigate rows up / down | +| `Enter` | Drill into selected row | +| `o` | Open messages for row | +| `g` / `G` | Jump to first / last row | +| `n` / `p` | Next / previous page | +| `←` / `→` | Previous / next message (detail view) | +| `/` | Focus search input (or go to search) | +| `Esc` | Blur search / close help / exit delete mode | +| `Backspace` | Go back (breadcrumb) | +| `H` | Go to Dashboard | +| `B` | Go to Browse | +| `?` | Toggle keyboard shortcuts help | + +### Delete Mode + +Press `d` to enter delete mode, which reveals checkboxes on message lists: + +| Key | Action | +|-----|--------| +| `d` | Enter delete mode | +| `Space` | Toggle selection on current row | +| `A` | Select all messages on page | +| `x` | Clear selection | +| `Esc` | Exit delete mode | + +## Theme + +The UI uses the Solarized color scheme with light and dark modes. Click the theme toggle button (top right) to cycle between auto (follows OS preference), dark, and light modes. The preference is saved in localStorage. + +## Architecture + +The web UI is built with: + +- **[Templ](https://templ.guide/)** — Type-safe Go HTML templates (compiled to Go) +- **[chi](https://github.com/go-chi/chi)** — HTTP router +- **Vanilla JS** — Keyboard shortcuts and interactions (no framework) +- **go:embed** — Static assets (CSS, JS) embedded in the binary + +All pages are server-rendered. The query engine (DuckDB over Parquet) provides fast aggregate queries for the browse and search views. + +### File Structure + +``` +internal/web/ +├── server.go # Handler struct, route registration +├── handlers.go # Page handlers (dashboard, browse, search, etc.) +├── handlers_deletions.go # Deletion staging handlers +├── params.go # Query parameter parsing +├── static/ +│ ├── style.css # Solarized theme, all styles +│ └── keys.js # Keyboard shortcuts, delete mode, theme toggle +└── templates/ + ├── layout.templ # Base layout, nav, help overlay + ├── dashboard.templ # Dashboard page + ├── aggregates.templ # Browse/drill-down views + ├── messages.templ # Message list with sort controls + ├── message_detail.templ # Single message view + ├── search.templ # Search page with sort controls + ├── deletions.templ # Deletion batch management + ├── stats.templ # Stats bar component + └── helpers.go # Template helper functions +``` + +### Security + +- API key authentication (shared with REST API, configured in `config.toml`) +- Attachment downloads validate content hashes (SHA-256, 64 hex chars) to prevent path traversal +- Deletion manifest IDs are validated against a strict regex pattern +- Back-link URLs are restricted to same-origin paths +- Filenames in Content-Disposition headers are sanitized diff --git a/go.mod b/go.mod index e128bd96..1023079a 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.8 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 08144fe2..e752e177 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..582921e4 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,8 +15,11 @@ 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" + "github.com/wesm/msgvault/internal/web" ) // MessageStore defines the store operations the API needs. @@ -46,6 +50,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 +58,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 +141,23 @@ 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 { + 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, s.cfg.AttachmentsDir()) + r.Group(func(r chi.Router) { + r.Use(s.authMiddleware) + r.Mount("/", webHandler.Routes()) + }) + } + return r } 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/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 diff --git a/internal/web/handlers.go b/internal/web/handlers.go new file mode 100644 index 00000000..bcdee893 --- /dev/null +++ b/internal/web/handlers.go @@ -0,0 +1,494 @@ +package web + +import ( + "bytes" + "fmt" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "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" +) + +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) +} + +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) + } + + accounts, err := h.engine.ListAccounts(ctx) + if err != nil { + slog.Error("failed to list accounts", "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, + Accounts: accounts, + } + + 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}) + } + } + + accounts, err := h.engine.ListAccounts(ctx) + if err != nil { + slog.Error("failed to list accounts", "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, + Accounts: accounts, + 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) +} + +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", "granularity", "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. + // 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, "/") { + 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() + } + } + } + + 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) +} + +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 := defaultPageSize + hideDeleted := parseBool(r, "hide_deleted") + attachments := parseBool(r, "attachments") + + sortField := parseMessageSortField(r) + sortDir := parseSortDirection(r) + + data := templates.SearchData{ + Query: queryStr, + Mode: mode, + Page: page, + PageSize: pageSize, + HideDeleted: hideDeleted, + Attachments: attachments, + SortField: messageSortFieldToString(sortField), + SortDir: sortDirToString(sortDir), + } + + 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, + Sorting: query.MessageSorting{ + Field: sortField, + Direction: sortDir, + }, + } + + 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, filter, + 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{ + HideDeletedFromSource: hideDeleted, + WithAttachmentsOnly: attachments, + }) + 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) +} + +// 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 new file mode 100644 index 00000000..3d162b1d --- /dev/null +++ b/internal/web/handlers_deletions.go @@ -0,0 +1,206 @@ +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, err := h.deletions.ListPending() + if err != nil { + slog.Error("failed to list pending deletions", "error", err) + } + inProgress, err := h.deletions.ListInProgress() + if err != nil { + slog.Error("failed to list in-progress deletions", "error", err) + } + completed, err := h.deletions.ListCompleted() + if err != nil { + slog.Error("failed to list completed deletions", "error", err) + } + failed, err := h.deletions.ListFailed() + if err != nil { + slog.Error("failed to list failed deletions", "error", err) + } + + 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 + 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 + } + + // 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 + } + + ctx := r.Context() + + gmailIDs := r.Form["gmail_id"] + if len(gmailIDs) == 0 { + http.Redirect(w, r, "/messages", http.StatusSeeOther) + return + } + + // Filter out empty Gmail IDs (messages without a source ID). + filtered := gmailIDs[:0] + for _, id := range gmailIDs { + if id != "" { + filtered = append(filtered, id) + } + } + gmailIDs = filtered + if len(gmailIDs) == 0 { + http.Redirect(w, r, "/messages", http.StatusSeeOther) + return + } + + const maxBatchSize = 10000 + if len(gmailIDs) > maxBatchSize { + http.Error(w, fmt.Sprintf("Too many messages (max %d)", maxBatchSize), http.StatusBadRequest) + return + } + + // Determine account from selected messages. Sample first and last to + // detect mixed-account selections (which cannot be executed correctly). + var account string + msg, err := h.engine.GetMessageBySourceID(ctx, gmailIDs[0]) + if err == nil && msg != nil { + account = msg.AccountEmail + } + if len(gmailIDs) > 1 && account != "" { + lastMsg, err := h.engine.GetMessageBySourceID(ctx, gmailIDs[len(gmailIDs)-1]) + if err == nil && lastMsg != nil && lastMsg.AccountEmail != account { + http.Error(w, "Selection contains messages from multiple accounts. Stage each account separately.", http.StatusBadRequest) + return + } + } + + 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, fmt.Sprintf("/deletions?flash=staged&count=%d", len(gmailIDs)), 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, "Failed to load message", http.StatusInternalServerError) + return + } + if msg == nil { + 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?flash=staged&count=1", 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/params.go b/internal/web/params.go new file mode 100644 index 00000000..0c561c4b --- /dev/null +++ b/internal/web/params.go @@ -0,0 +1,256 @@ +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.TimeYear + } +} + +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 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" + } +} + +const defaultPageSize = 100 + +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 +} + +// 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"), + 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 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, + "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 +} + +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 + } + + page := parsePage(r) + f.Pagination = query.Pagination{ + Limit: defaultPageSize, + Offset: (page - 1) * defaultPageSize, + } + + return f +} + +func parseDrillFilter(r *http.Request) query.MessageFilter { + return parseBaseMessageFilter(r) +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 00000000..c785a21c --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,59 @@ +// 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/deletion" + "github.com/wesm/msgvault/internal/query" +) + +//go:embed static +var staticFS embed.FS + +// Handler serves the web UI. +type Handler struct { + 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, 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, attachmentsDir: attachmentsDir} +} + +// 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.handleBrowse) + 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 + 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/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/keys.js b/internal/web/static/keys.js new file mode 100644 index 00000000..784d7ae0 --- /dev/null +++ b/internal/web/static/keys.js @@ -0,0 +1,431 @@ +// Keyboard shortcuts for msgvault web UI +// Mirrors TUI bindings: j/k navigation, / search, ? help +(function () { + 'use strict'; + + var activeRow = -1; + + function getRows() { + var table = document.querySelector('.data-table tbody'); + return table ? table.querySelectorAll('tr') : []; + } + + function clearActive() { + var rows = getRows(); + for (var i = 0; i < rows.length; i++) { + rows[i].classList.remove('kb-active'); + } + } + + function setActive(index) { + var rows = getRows(); + if (rows.length === 0) return; + if (index < 0) index = 0; + if (index >= rows.length) index = rows.length - 1; + clearActive(); + activeRow = index; + rows[activeRow].classList.add('kb-active'); + rows[activeRow].scrollIntoView({ block: 'nearest' }); + } + + function openActiveRow(linkIndex) { + var rows = getRows(); + if (activeRow < 0 || activeRow >= rows.length) return; + 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() { + var el = document.activeElement; + if (!el) return false; + var tag = el.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable; + } + + // Help overlay + function toggleHelp() { + var overlay = document.getElementById('help-overlay'); + if (overlay) overlay.classList.toggle('visible'); + } + + function hideHelp() { + var overlay = document.getElementById('help-overlay'); + if (overlay) overlay.classList.remove('visible'); + } + + // Search form loading state + function setupSearchLoading() { + var form = document.querySelector('.search-form'); + if (!form) return; + form.addEventListener('submit', function () { + var btn = form.querySelector('.search-btn'); + if (!btn) return; + btn.disabled = true; + btn.innerHTML = ' Searching\u2026'; + }); + } + + // 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') { + var overlay = document.getElementById('help-overlay'); + if (overlay && overlay.classList.contains('visible')) { + hideHelp(); + e.preventDefault(); + return; + } + if (isDeleteMode()) { + exitDeleteMode(); + e.preventDefault(); + return; + } + // Escape also blurs search input + if (isInputFocused()) { + document.activeElement.blur(); + e.preventDefault(); + return; + } + } + + // Don't capture shortcuts when typing in inputs + if (isInputFocused()) return; + + switch (e.key) { + case '/': + e.preventDefault(); + // If on search page, focus the input + var searchInput = document.querySelector('.search-input'); + if (searchInput) { + searchInput.focus(); + searchInput.select(); + } else { + // Navigate to search page + window.location.href = '/search'; + } + break; + + case '?': + e.preventDefault(); + toggleHelp(); + break; + + case 'j': + case 'ArrowDown': + e.preventDefault(); + setActive(activeRow + 1); + break; + + case 'k': + case 'ArrowUp': + e.preventDefault(); + setActive(activeRow - 1); + break; + + case 'Enter': + if (activeRow >= 0) { + e.preventDefault(); + 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; + + case 'g': + // Go to first row + e.preventDefault(); + setActive(0); + break; + + case 'G': + e.preventDefault(); + var rows = getRows(); + setActive(rows.length - 1); + break; + + case 'H': + // Go home (dashboard) + window.location.href = '/'; + break; + + case 'B': + // Go to browse + 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'); + if (nextLink && nextLink.textContent.trim() === 'Next') { + nextLink.click(); + } + break; + + case 'p': + // Previous page + var prevLink = document.querySelector('.pagination a:first-of-type'); + if (prevLink && prevLink.textContent.trim() === 'Prev') { + prevLink.click(); + } + 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()) { + 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; + } + }); + + // Click on help overlay backdrop to close + document.addEventListener('click', function (e) { + var overlay = document.getElementById('help-overlay'); + if (overlay && e.target === overlay) { + hideHelp(); + } + }); + + // Theme toggle: cycles auto → dark → light → auto + function setupThemeToggle() { + var btn = document.getElementById('theme-toggle'); + if (!btn) return; + + var saved = localStorage.getItem('msgvault-theme') || 'auto'; + applyTheme(saved); + + btn.addEventListener('click', function () { + var current = localStorage.getItem('msgvault-theme') || 'auto'; + var next = current === 'auto' ? 'dark' : current === 'dark' ? 'light' : 'auto'; + localStorage.setItem('msgvault-theme', next); + applyTheme(next); + }); + } + + function applyTheme(theme) { + var root = document.documentElement; + var btn = document.getElementById('theme-toggle'); + if (theme === 'dark') { + root.setAttribute('data-theme', 'dark'); + if (btn) btn.textContent = '\u263E'; // moon + } else if (theme === 'light') { + root.setAttribute('data-theme', 'light'); + if (btn) btn.textContent = '\u2600'; // sun + } else { + root.removeAttribute('data-theme'); + if (btn) btn.textContent = '\u25D1'; // half circle (auto) + } + } + + // 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(); + } + } + + // 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 new file mode 100644 index 00000000..2d049ee9 --- /dev/null +++ b/internal/web/static/style.css @@ -0,0 +1,673 @@ +/* Solarized light */ +:root { + --bg: #f5f3ef; + --bg-alt: #eae8e3; + --bg-hover: #dfddd7; + --fg: #657b83; + --fg-muted: #93a1a1; + --border: #ccc9c0; + --accent: #268bd2; + --accent-hover: #2aa198; + --danger: #dc322f; + --font: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace; + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --radius: 4px; +} + +/* Solarized dark — auto (OS preference) */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #002b36; + --bg-alt: #073642; + --bg-hover: #0a4050; + --fg: #93a1a1; + --fg-muted: #657b83; + --border: #2a4a53; + --accent: #268bd2; + --accent-hover: #2aa198; + --danger: #dc322f; + } +} + +/* Solarized dark — forced via toggle */ +:root[data-theme="dark"] { + --bg: #002b36; + --bg-alt: #073642; + --bg-hover: #0a4050; + --fg: #93a1a1; + --fg-muted: #657b83; + --border: #2a4a53; + --accent: #268bd2; + --accent-hover: #2aa198; + --danger: #dc322f; +} + +*, *::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-right { + display: flex; + gap: 16px; + align-items: center; +} + +.header-nav { + display: flex; + gap: 16px; + align-items: center; +} + +/* Theme toggle */ +.theme-toggle { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 3px 8px; + cursor: pointer; + font-size: 14px; + line-height: 1; + color: var(--fg-muted); +} +.theme-toggle:hover { + background: var(--bg-hover); + color: var(--fg); +} + +.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 tbody tr.kb-active { background: var(--bg-hover); outline: 2px solid var(--accent); outline-offset: -2px; } + +.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); +} + +/* Pill buttons (toggle tabs, filter controls) */ +.pill-group { display: flex; gap: 4px; } +.pill { + padding: 4px 10px; + border-radius: var(--radius); + font-size: 12px; + text-decoration: none; + border: 1px solid var(--border); + color: var(--fg-muted); +} +.pill:hover { color: var(--fg); text-decoration: none; background: var(--bg-hover); } +.pill.active { + font-weight: 600; + background: var(--accent); + color: #fff; + border-color: var(--accent); +} +.pill.active:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; } + +.pill-sm { + padding: 2px 8px; + border-radius: 3px; + font-size: 11px; + text-decoration: none; + border: 1px solid var(--border); + color: var(--fg-muted); +} +.pill-sm:hover { color: var(--fg); text-decoration: none; background: var(--bg-hover); } +.pill-sm.active { + font-weight: 600; + background: var(--accent); + color: #fff; + border-color: var(--accent); +} +.pill-sm.active:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; } +.pill-sm.active-muted { + font-weight: 600; + background: var(--fg-muted); + color: var(--bg); + border-color: var(--fg-muted); +} + +/* Breadcrumb navigation */ +.breadcrumb { + margin-bottom: 12px; + font-size: 13px; + color: var(--fg-muted); +} + +/* Result count */ +.result-count { + margin-bottom: 8px; + font-size: 12px; + color: var(--fg-muted); +} + +/* Pagination */ +.pagination { + margin-top: 12px; + display: flex; + justify-content: center; + gap: 4px; + align-items: center; + font-size: 13px; +} +.pagination a { + padding: 4px 10px; + border-radius: var(--radius); + border: 1px solid var(--border); + color: var(--fg-muted); + text-decoration: none; +} +.pagination a:hover { background: var(--bg-hover); color: var(--fg); text-decoration: none; } +.pagination .page-info { padding: 4px 8px; color: var(--fg-muted); } + +/* Search form */ +.search-form { margin-bottom: 16px; } +.search-row { display: flex; gap: 8px; align-items: stretch; } +.search-input { + 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; +} +.search-input:focus { border-color: var(--accent); } +.search-btn { + padding: 8px 16px; + font-size: 14px; + font-weight: 600; + border: 1px solid var(--accent); + border-radius: var(--radius); + background: var(--accent); + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; +} +.search-btn:hover { background: var(--accent-hover); border-color: var(--accent-hover); } +.search-btn:disabled { opacity: 0.7; cursor: not-allowed; } +.search-controls { + margin-top: 8px; + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + font-size: 12px; +} +.search-hint { color: var(--fg-muted); margin-right: 8px; } +.search-divider { border-left: 1px solid var(--border); padding-left: 8px; } + +/* Toolbar (view selector + filters row) */ +.toolbar { + margin-bottom: 16px; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + 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; 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 { + font-size: 18px; + font-weight: 600; + margin-bottom: 12px; + line-height: 1.3; +} +.msg-headers { + font-size: 13px; + border-collapse: collapse; +} +.msg-header-label { + padding: 2px 12px 2px 0; + color: var(--fg-muted); + white-space: nowrap; + vertical-align: top; +} +.msg-header-value { padding: 2px 0; } +.msg-label { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: 11px; + background: var(--bg-alt); + border: 1px solid var(--border); + margin-right: 4px; +} +.msg-section { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} +.msg-section-title { + font-size: 12px; + color: var(--fg-muted); + font-weight: 600; + margin-bottom: 6px; +} +.msg-attachment { + font-size: 13px; + padding: 2px 0; + color: var(--fg-muted); +} +.msg-attachment-size { + font-size: 11px; + color: var(--fg-muted); + margin-left: 4px; +} +.msg-body { + white-space: pre-wrap; + word-wrap: break-word; + font-family: var(--font); + font-size: 13px; + line-height: 1.6; + margin: 0; +} + +/* Attachment badge in message lists */ +.attachment-badge { + margin-left: 4px; + font-size: 11px; + color: var(--fg-muted); +} + +/* Date cells in message tables */ +.date-cell { + white-space: nowrap; + font-size: 12px; + color: var(--fg-muted); +} + +/* Loading spinner */ +.spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +/* Help overlay */ +.help-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 100; + align-items: center; + justify-content: center; +} +.help-overlay.visible { display: flex; } +.help-dialog { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 24px; + max-width: 420px; + width: 90%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} +.help-dialog h3 { + font-size: 15px; + font-weight: 600; + margin-bottom: 16px; + color: var(--fg); +} +.help-dialog table { + width: 100%; + font-size: 13px; + border-collapse: collapse; +} +.help-dialog td { + padding: 4px 0; + color: var(--fg); +} +.help-dialog td:first-child { + width: 80px; + color: var(--fg-muted); +} +.help-dialog kbd { + display: inline-block; + padding: 1px 6px; + font-family: var(--font); + font-size: 12px; + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: 3px; +} +.help-dialog .help-close { + margin-top: 16px; + text-align: center; + font-size: 12px; + 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; +} + +/* 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; + 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/aggregates.templ b/internal/web/templates/aggregates.templ new file mode 100644 index 00000000..536ba8e3 --- /dev/null +++ b/internal/web/templates/aggregates.templ @@ -0,0 +1,397 @@ +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 + Accounts []query.AccountInfo + // 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, "") + } else { + q.Set(filterKey, key) + } + q.Set("sort", d.SortField) + q.Set("dir", d.SortDir) + 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, "") + } else { + q.Set(filterKey, key) + } + d.addDrillParams(q) + // Include granularity for time views so the messages query uses the + // correct time precision (year/month/day) instead of defaulting to month. + if d.ViewType == "time" && d.Granularity != "" { + q.Set("granularity", d.Granularity) + } + 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") +} + +// 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": + 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 { + { "-" } + } +
+ 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("Rcpt Names", "recipient_names", 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 len(data.Accounts) > 0 { + @accountSelector(data) + } + if data.Attachments { + Attachments Only + } else { + Attachments Only + } + if data.HideDeleted { + Hide Deleted + } else { + Hide Deleted + } +
+} + +templ accountSelector(data BrowseData) { + +} diff --git a/internal/web/templates/aggregates_templ.go b/internal/web/templates/aggregates_templ.go new file mode 100644 index 00000000..3b166c66 --- /dev/null +++ b/internal/web/templates/aggregates_templ.go @@ -0,0 +1,1192 @@ +// 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 + Accounts []query.AccountInfo + // 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, "") + } else { + q.Set(filterKey, key) + } + q.Set("sort", d.SortField) + q.Set("dir", d.SortDir) + 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, "") + } else { + q.Set(filterKey, key) + } + d.addDrillParams(q) + // Include granularity for time views so the messages query uses the + // correct time precision (year/month/day) instead of defaulting to month. + if d.ViewType == "time" && d.Granularity != "" { + q.Set("granularity", d.Granularity) + } + 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") +} + +// 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": + 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: 276, 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: 276, 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: 281, 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: 286, 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: 291, 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: 304, 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: 310, 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: 313, 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: 316, 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: 316, 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("-") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 318, Col: 12} + } + _, 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: 327, 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: 327, 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("Rcpt Names", "recipient_names", 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: 346, Col: 82} + } + _, 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: 348, Col: 75} + } + _, 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: 362, Col: 101} + } + _, 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: 364, Col: 88} + } + _, 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 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, "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 + }) +} + +func accountSelector(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_Var43 := templ.GetChildren(ctx) + if templ_7745c5c3_Var43 == nil { + templ_7745c5c3_Var43 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate 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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
Quick Links
") + 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/deletions.templ b/internal/web/templates/deletions.templ new file mode 100644 index 00000000..febf8e02 --- /dev/null +++ b/internal/web/templates/deletions.templ @@ -0,0 +1,109 @@ +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 + Flash string + FlashCount int +} + +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") { + if data.Flash == "staged" { +
+ Successfully staged { fmt.Sprintf("%d", data.FlashCount) } message(s) for deletion. +
+ } +

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..7e5dc910 --- /dev/null +++ b/internal/web/templates/deletions_templ.go @@ -0,0 +1,292 @@ +// 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 + Flash string + FlashCount int +} + +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) + 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, 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 + } + } 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, 5, " ") + 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, 6, " ") + 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, 7, " ") + 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, 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 + } + 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_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, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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: 77, Col: 33} + } + _, 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, 10, " (") + 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", len(manifests))) + if templ_7745c5c3_Err != nil { + 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_Var6)) + 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 showCancel { + 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, 13, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, m := range manifests { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if showCancel { + 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, 21, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
Batch IDDescriptionMessagesCreated
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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: 93, Col: 67} + } + _, 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, 15, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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: 94, Col: 25} + } + _, 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, 16, "") + 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(m.GmailIDs)))) + if templ_7745c5c3_Err != nil { + 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_Var9)) + 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_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: 96, Col: 64} + } + _, 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, 18, "
") + 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..c46de1be --- /dev/null +++ b/internal/web/templates/helpers.go @@ -0,0 +1,108 @@ +package templates + +import ( + "fmt" + "html" + "net/url" + "regexp" + "time" +) + +// 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) +} + +// addParam appends a query parameter to a URL string. +func addParam(base, key, value string) string { + if value == "" { + return base + } + u, err := url.Parse(base) + if err != nil { + return base + } + q := u.Query() + q.Set(key, value) + u.RawQuery = q.Encode() + return u.String() +} + +// 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() +} + +// 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") +} + +// Regexes for HTML-to-text conversion. +var ( + // styleRe and scriptRe 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 style/script blocks and all HTML tags, returning plain text. +// Used to extract readable content from HTML email bodies. +func htmlToPlainText(s string) string { + // 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 new file mode 100644 index 00000000..d14b0039 --- /dev/null +++ b/internal/web/templates/layout.templ @@ -0,0 +1,111 @@ +package templates + +templ Layout(title string, activePage string) { + + + + + + { title } - msgvault + + + +
+
+ msgvault +
+ + +
+
+ { children... } +
+ msgvault + ? shortcuts +
+
+ @helpOverlay() + + + +} + +templ helpOverlay() { +
+
+

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
+
+
+} diff --git a/internal/web/templates/layout_templ.go b/internal/web/templates/layout_templ.go new file mode 100644 index 00000000..7994b781 --- /dev/null +++ b/internal/web/templates/layout_templ.go @@ -0,0 +1,186 @@ +// 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 ? shortcuts
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = helpOverlay().Render(ctx, templ_7745c5c3_Buffer) + 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 + } + return nil + }) +} + +func helpOverlay() 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, 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 + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/message_detail.templ b/internal/web/templates/message_detail.templ new file mode 100644 index 00000000..0b9a1a74 --- /dev/null +++ b/internal/web/templates/message_detail.templ @@ -0,0 +1,144 @@ +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 { +
+ if att.ContentHash != "" { + + { att.Filename } + + } else { + { att.Filename } + } + ({ formatBytes(att.Size) }) +
+ } +
+ } + if msg.ConversationID > 0 { + + } +
+
+ +
+
+
+} + +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..ca40b0fa --- /dev/null +++ b/internal/web/templates/message_detail_templ.go @@ -0,0 +1,491 @@ +// 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: 86} + } + _, 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: 64} + } + _, 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: 62} + } + _, 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: 62} + } + _, 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: 63} + } + _, 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: 91, Col: 39} + } + _, 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: 101, 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 + } + 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, 29, "(") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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: 112, Col: 64} + } + _, 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, 30, ")
") + 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 + } + } + if msg.ConversationID > 0 { + 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, 34, "
") + 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_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, 36, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if msg.BodyText != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			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: 137, Col: 39}
+			}
+			_, 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, 38, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if msg.BodyHTML != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			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: 139, Col: 56}
+			}
+			_, 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 + } + } else { + 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, 42, "
") + 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..f150ec2c --- /dev/null +++ b/internal/web/templates/messages.templ @@ -0,0 +1,255 @@ +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 { + @messageRow(msg) + } + +
+ + Date{ data.sortIndicator("date") } + + From + + Subject{ data.sortIndicator("subject") } + + + + Size{ data.sortIndicator("size") } + +
+ @selectionBar() +
+} + +templ pagination(data MessagesData) { + if data.Page > 1 || data.HasMore { + + } +} + + +templ selectionBar() { +
+ Select messages to stage for deletion + + + + +
+} + +// messageRow renders a single message row, shared across all message tables. +templ messageRow(msg query.MessageSummary) { + + + if msg.SourceMessageID != "" { + + } + + + { 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) } + +} diff --git a/internal/web/templates/messages_templ.go b/internal/web/templates/messages_templ.go new file mode 100644 index 00000000..62588e2b --- /dev/null +++ b/internal/web/templates/messages_templ.go @@ -0,0 +1,690 @@ +// 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 = messageRow(msg).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
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: 166, Col: 39} + } + _, 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: 172, Col: 45} + } + _, 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: 177, Col: 39} + } + _, 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 + } + 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, 19, "
") + 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_Var14 := templ.GetChildren(ctx) + if templ_7745c5c3_Var14 == nil { + templ_7745c5c3_Var14 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if data.Page > 1 || data.HasMore { + 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, 21, "Prev ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + 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: 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, 24, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.HasMore { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Next") + 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 + } + } + return nil + }) +} + +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 { + 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_Var18 := templ.GetChildren(ctx) + if templ_7745c5c3_Var18 == nil { + templ_7745c5c3_Var18 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
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_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, 29, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if msg.SourceMessageID != "" { + 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, 32, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, 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: 230, Col: 34} + } + _, 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, 33, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if msg.FromName != "" { + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, 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: 234, Col: 18} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, 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: 236, Col: 19} + } + _, 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, 34, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if msg.Subject != "" { + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, 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: 242, Col: 18} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "(no subject)") + 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 + } + if msg.HasAttachments { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, 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: 249, Col: 47} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + 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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, 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: 253, Col: 49} + } + _, 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, 42, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/web/templates/search.templ b/internal/web/templates/search.templ new file mode 100644 index 00000000..043c8cce --- /dev/null +++ b/internal/web/templates/search.templ @@ -0,0 +1,240 @@ +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 + 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") + } + 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)) + } + return "/search?" + q.Encode() +} + +func (d SearchData) modeToggleURL() string { + 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) filterToggleURL(key string, currentlyOn bool) string { + q := d.baseQuery() + if currentlyOn { + q.Del(key) + } else { + q.Set(key, "1") + } + q.Del("page") + 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 { + @StatsBar(data.Stats) + } +
+
+ + + if data.HideDeleted { + + } + if data.Attachments { + + } + +
+
+ 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.HideDeleted { + + Hide Deleted + + } else { + + Hide Deleted + + } + if data.Attachments { + + Attachments Only + + } else { + + Attachments Only + + } +
+
+ 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) }) + } +
+ @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 { + + } +} diff --git a/internal/web/templates/search_templ.go b/internal/web/templates/search_templ.go new file mode 100644 index 00000000..240d3dd3 --- /dev/null +++ b/internal/web/templates/search_templ.go @@ -0,0 +1,661 @@ +// 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 + 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") + } + 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)) + } + return "/search?" + q.Encode() +} + +func (d SearchData) modeToggleURL() string { + 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) filterToggleURL(key string, currentlyOn bool) string { + q := d.baseQuery() + if currentlyOn { + q.Del(key) + } else { + q.Set(key, "1") + } + q.Del("page") + 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 " ↓" +} + +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.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, 7, "Fast Deep ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Fast Deep ") + 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.Mode == "fast" { + 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, 13, "Searches full message body (slower)") + 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.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, 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, 25, "
No results

No messages match \"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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: 166, Col: 36} + } + _, 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, 26, "\" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Mode == "fast" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "— try deep search to include message bodies") + 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 + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
Showing ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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: 174, Col: 52} + } + _, 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, 31, " results ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Page > 1 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "(page ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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: 176, Col: 41} + } + _, 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, 33, ")") + 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 + } + templ_7745c5c3_Err = searchMessageTable(data).Render(ctx, templ_7745c5c3_Buffer) + 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 + } + 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 searchMessageTable(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_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, 36, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Mode == "fast" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + 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, 45, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, msg := range data.Messages { + templ_7745c5c3_Err = messageRow(msg).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
Date") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(data.searchSortIndicator("date")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 194, Col: 46} + } + _, 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, 39, "FromSubject") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(data.searchSortIndicator("subject")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 200, Col: 52} + } + _, 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, 41, "Size") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(data.searchSortIndicator("size")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 205, Col: 46} + } + _, 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, 43, "DateFromSubjectSize
") + 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, 47, "
") + 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_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, 48, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Page > 1 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "Prev ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "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/search.templ`, Line: 233, 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, 52, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.HasMore { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "Next") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate