summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-12 14:28:50 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-12 14:28:50 -1000
commit06c7485a7d05de86f9898e388161e8d932d5f3e6 (patch)
tree376083a75278c9758f53c0062742062dedb75633
parent9ef5b7f37883f846f105da9dc5d2ba1415e594e3 (diff)
Modernize frontend with tabs, HTMX, and Tailwind build pipeline
Complete UI overhaul implementing modern design patterns with HTMX for dynamic updates, proper Tailwind build pipeline, and improved UX. Build Pipeline: - Add npm + PostCSS + Tailwind CSS configuration - Custom design system with brand colors - Compiled CSS: 27KB (vs 3MB CDN), 99% reduction - Makefile for unified build commands - Inter font for improved typography Tab Interface: - Separate Tasks tab from Notes tab using HTMX - Partial page updates without full refreshes - Tab state management with proper refresh handling - New endpoints: /tabs/tasks, /tabs/notes, /tabs/refresh Template Architecture: - Modular partials system (7 reusable components) - Cleaner separation of concerns Empty Board Management: - Active boards in main 3-column grid - Empty boards in collapsible section - Reduces visual clutter Visual Design Enhancements: - Inter font, brand color accents - Improved typography hierarchy and spacing - Enhanced card styling with hover effects Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
-rw-r--r--.gitignore7
-rw-r--r--Makefile36
-rw-r--r--SECURITY_CHECKLIST.md250
-rw-r--r--SESSION_STATE.md38
-rw-r--r--internal/handlers/handlers.go67
-rw-r--r--package.json20
-rw-r--r--postcss.config.js6
-rw-r--r--tailwind.config.js38
-rw-r--r--web/static/css/input.css123
-rw-r--r--web/static/css/styles.css70
-rw-r--r--web/static/js/app.js190
-rw-r--r--web/static/js/htmx.min.js1
-rw-r--r--web/templates/index.html193
-rw-r--r--web/templates/partials/error-banner.html12
-rw-r--r--web/templates/partials/notes-tab.html22
-rw-r--r--web/templates/partials/obsidian-notes.html30
-rw-r--r--web/templates/partials/plantoeat-meals.html35
-rw-r--r--web/templates/partials/tasks-tab.html22
-rw-r--r--web/templates/partials/todoist-tasks.html58
-rw-r--r--web/templates/partials/trello-boards.html72
20 files changed, 1013 insertions, 277 deletions
diff --git a/.gitignore b/.gitignore
index dd105b6..3d7b311 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,3 +44,10 @@ go.work
# Temporary files
tmp/
temp/
+
+# Node.js / npm
+node_modules/
+package-lock.json
+
+# Build artifacts
+web/static/css/output.css
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..bee9968
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,36 @@
+.PHONY: help build run dev test css css-watch install clean
+
+help: ## Show this help message
+ @echo 'Usage: make [target]'
+ @echo ''
+ @echo 'Targets:'
+ @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
+
+install: ## Install all dependencies (Go + npm)
+ go mod download
+ npm install
+
+css: ## Build CSS once
+ npm run build
+
+css-watch: ## Watch CSS for changes
+ npm run dev
+
+build: css ## Build Go binary with CSS
+ go build -o dashboard cmd/dashboard/main.go
+
+run: css ## Build CSS and run server
+ go run cmd/dashboard/main.go
+
+dev: ## Run in development mode (CSS watch + Go server in separate terminals)
+ @echo "Run these in separate terminals:"
+ @echo " Terminal 1: make css-watch"
+ @echo " Terminal 2: go run cmd/dashboard/main.go"
+
+test: ## Run tests
+ go test ./...
+
+clean: ## Clean build artifacts
+ rm -f dashboard
+ rm -f web/static/css/output.css
+ rm -rf node_modules
diff --git a/SECURITY_CHECKLIST.md b/SECURITY_CHECKLIST.md
new file mode 100644
index 0000000..4e63174
--- /dev/null
+++ b/SECURITY_CHECKLIST.md
@@ -0,0 +1,250 @@
+# Security & Quality Checklist
+
+## Critical Security Issues (Must Fix Before Production)
+
+### Authentication & Authorization
+- [ ] **Timing Attack in AI Auth** (15 min)
+ - File: `internal/middleware/ai_auth.go:31`
+ - Change: Use `crypto/subtle.ConstantTimeCompare()` instead of `!=`
+ - Impact: Prevents token brute-forcing
+
+### Database Security
+- [ ] **SQL Injection in GetNotes()** (15 min)
+ - File: `internal/store/sqlite.go:208`
+ - Change: Use parameterized query for LIMIT clause
+ - Impact: Prevents SQL injection attacks
+
+- [ ] **SQLite Concurrency Configuration** (30 min)
+ - File: `internal/store/sqlite.go:22-30`
+ - Change: Set `MaxOpenConns(1)`, enable WAL mode
+ - Impact: Prevents "database is locked" errors under concurrent load
+
+- [ ] **Database File Permissions** (15 min)
+ - File: `internal/store/sqlite.go:22-24`
+ - Change: Set file to 0600, create dir with 0700
+ - Impact: Prevents unauthorized access to cached data
+
+### Input/Output Security
+- [ ] **Path Traversal in Obsidian** (1 hour)
+ - File: `internal/api/obsidian.go:49-70`
+ - Change: Validate paths stay within vault, skip symlinks
+ - Impact: Prevents arbitrary file read attacks
+
+- [ ] **JSON Injection in Error Responses** (15 min)
+ - File: `internal/middleware/ai_auth.go:42-45`
+ - Change: Use `json.Encoder` instead of string concatenation
+ - Impact: Prevents JSON structure manipulation
+
+### Network Security
+- [ ] **HTTPS Support** (1 hour)
+ - File: `cmd/dashboard/main.go:86-94`
+ - Change: Add TLS configuration and ListenAndServeTLS
+ - Impact: Prevents credential theft via network sniffing
+
+---
+
+## High Priority Issues (Should Fix Soon)
+
+### Concurrency & Performance
+- [ ] **Context Cancellation in Goroutines** (30 min)
+ - File: `internal/handlers/handlers.go:151-207`
+ - Change: Check `ctx.Done()` before locking mutex in each goroutine
+ - Impact: Prevents goroutine leaks and resource exhaustion
+
+- [ ] **Parallelize Trello Card Fetching** (1 hour)
+ - File: `internal/api/trello.go:196-204`
+ - Change: Use goroutines with bounded concurrency for card fetching
+ - Impact: Reduces API call time from N+1 sequential to parallel
+
+- [ ] **Reduce Mutex Contention in aggregateData** (45 min)
+ - File: `internal/handlers/handlers.go:154-205`
+ - Change: Store results locally, lock only for final assignment
+ - Impact: Better parallelism, faster page loads
+
+- [ ] **HTTP Client Connection Pooling** (30 min)
+ - File: `internal/api/*.go` (all clients)
+ - Change: Configure Transport with MaxIdleConns, MaxIdleConnsPerHost
+ - Impact: Prevents port exhaustion and API rate limiting
+
+### Security Hardening
+- [ ] **Rate Limiting on Endpoints** (1 hour)
+ - File: `cmd/dashboard/main.go:67, 73`
+ - Change: Add rate limiting middleware
+ - Impact: Prevents DoS attacks and API quota exhaustion
+
+- [ ] **CSRF Protection** (2 hours)
+ - File: `cmd/dashboard/main.go:67`
+ - Change: Add CSRF middleware for POST endpoints
+ - Impact: Prevents cross-site request forgery (needed for Phase 2)
+
+- [ ] **Sanitize API Keys in Logs** (30 min)
+ - File: `internal/api/*.go` (all clients)
+ - Change: Redact keys/tokens in error messages
+ - Impact: Prevents credential leaks via log files
+
+### Error Handling
+- [ ] **Check JSON Unmarshal Errors** (30 min)
+ - File: `internal/store/sqlite.go:155, 234`
+ - Change: Log errors, provide defaults
+ - Impact: Prevents silent data loss
+
+- [ ] **Sanitize Error Messages** (1 hour)
+ - File: `internal/handlers/handlers.go` (multiple locations)
+ - Change: Return generic errors to users, log details internally
+ - Impact: Prevents information disclosure
+
+---
+
+## Medium Priority Issues (Nice to Have)
+
+### Code Quality
+- [ ] **Database Connection Health Check** (15 min)
+ - File: `internal/store/sqlite.go:22-30`
+ - Change: Add `db.Ping()` after opening connection
+ - Impact: Fail fast on database issues
+
+- [ ] **Null Object Pattern for Optional Clients** (1 hour)
+ - File: `internal/handlers/handlers.go`, `cmd/dashboard/main.go`
+ - Change: Implement null objects instead of nil checks
+ - Impact: Eliminates nil pointer risks
+
+- [ ] **Context Timeouts for Database Operations** (2 hours)
+ - File: `internal/store/sqlite.go` (all methods)
+ - Change: Use `QueryContext`, `ExecContext`, add context parameters
+ - Impact: Prevents indefinite blocking
+
+- [ ] **Validate API Response Data** (2 hours)
+ - File: `internal/api/*.go` (all clients)
+ - Change: Add validation functions for API responses
+ - Impact: Protection against malicious API servers
+
+### Testing
+- [ ] **Add AI Handler Tests** (2 hours)
+ - File: `internal/handlers/ai_handlers_test.go` (new)
+ - Tests: Task categorization, meal grouping, response size
+ - Impact: Better test coverage
+
+- [ ] **Add Middleware Tests** (1 hour)
+ - File: `internal/middleware/ai_auth_test.go` (new)
+ - Tests: Valid/invalid tokens, missing headers
+ - Impact: Better test coverage
+
+- [ ] **Add Edge Case Tests** (2 hours)
+ - Files: Various test files
+ - Tests: Empty responses, malformed JSON, network errors
+ - Impact: More robust error handling
+
+### Security Headers
+- [ ] **Add Security Headers Middleware** (30 min)
+ - File: `cmd/dashboard/main.go`
+ - Change: Add X-Frame-Options, CSP, X-Content-Type-Options, etc.
+ - Impact: Defense in depth
+
+- [ ] **Content Security Policy** (1 hour)
+ - File: `cmd/dashboard/main.go`
+ - Change: Add CSP header with appropriate directives
+ - Impact: XSS protection
+
+### Configuration
+- [ ] **Validate Config at Startup** (30 min)
+ - File: `internal/config/config.go:61-76`
+ - Change: Add token strength validation, file path checks
+ - Impact: Fail fast on misconfiguration
+
+- [ ] **Make HTTP Timeouts Configurable** (30 min)
+ - File: `internal/api/*.go` (all clients)
+ - Change: Add `APITimeoutSeconds` to config
+ - Impact: Flexibility for different environments
+
+---
+
+## Low Priority / Future Enhancements
+
+### Monitoring & Observability
+- [ ] **Structured Logging** (4 hours)
+ - Change: Replace log.Printf with structured logger (zap/zerolog)
+ - Impact: Better log analysis and debugging
+
+- [ ] **Health Check Endpoint** (30 min)
+ - File: `cmd/dashboard/main.go`
+ - Change: Add `/health` endpoint checking DB, API connectivity
+ - Impact: Better monitoring
+
+- [ ] **Metrics Collection** (4 hours)
+ - Change: Add Prometheus metrics for API calls, cache hits, errors
+ - Impact: Performance monitoring
+
+### Code Organization
+- [ ] **Extract Constants** (1 hour)
+ - Files: Various
+ - Change: Move magic numbers to constants
+ - Impact: Better maintainability
+
+- [ ] **Standardize Error Messages** (1 hour)
+ - Files: Various
+ - Change: Consistent capitalization and formatting
+ - Impact: Better UX
+
+### Database
+- [ ] **Database Encryption at Rest** (2 hours)
+ - File: `internal/store/sqlite.go`
+ - Change: Use SQLCipher
+ - Impact: Data protection
+
+- [ ] **Migration Versioning Table** (1 hour)
+ - File: `internal/store/sqlite.go:41-68`
+ - Change: Track which migrations have run
+ - Impact: Better migration management
+
+---
+
+## Estimated Time Summary
+
+| Priority | Count | Estimated Time |
+|----------|-------|----------------|
+| Critical | 6 items | ~4 hours |
+| High | 7 items | ~6.5 hours |
+| Medium | 11 items | ~13.5 hours |
+| Low | 8 items | ~14 hours |
+| **Total** | **32 items** | **~38 hours** |
+
+### Recommended Sprint 1 (Critical + High Priority)
+- **Duration**: 1-2 weeks part-time
+- **Items**: 13 items
+- **Time**: ~10.5 hours
+- **Focus**: Security hardening and performance
+
+### Recommended Sprint 2 (Medium Priority)
+- **Duration**: 1-2 weeks part-time
+- **Items**: 11 items
+- **Time**: ~13.5 hours
+- **Focus**: Code quality and testing
+
+---
+
+## Quick Wins (< 30 minutes each)
+
+These can be done in small chunks:
+
+1. ✓ Timing attack fix (15 min)
+2. ✓ SQL injection fix (15 min)
+3. ✓ JSON injection fix (15 min)
+4. ✓ Database permissions (15 min)
+5. ✓ Health check endpoint (15 min)
+6. ✓ Security headers middleware (30 min)
+7. ✓ Database ping check (15 min)
+8. ✓ Check JSON unmarshal errors (30 min)
+9. ✓ Extract constants (30 min)
+10. ✓ Config validation (30 min)
+
+**Total Quick Wins**: ~4 hours, addresses 10 issues
+
+---
+
+## Notes
+
+- Priority order considers both security impact and implementation effort
+- Times are estimates for an experienced Go developer
+- Some items may reveal additional issues during implementation
+- Testing time not included (add ~30% for comprehensive testing)
+- Code review time not included (add ~20% for peer review)
diff --git a/SESSION_STATE.md b/SESSION_STATE.md
index 55138c9..f4b76f0 100644
--- a/SESSION_STATE.md
+++ b/SESSION_STATE.md
@@ -1,7 +1,7 @@
# Current Session State
## 🎯 Active Goal
-Board sorting implementation complete.
+Frontend modernization with tabs, HTMX, and Tailwind build pipeline complete.
## ✅ Completed
- Initial Phase 1 feature set (Trello, Todoist, Obsidian, PlanToEat)
@@ -30,12 +30,48 @@ Board sorting implementation complete.
- internal/api/trello.go:220-228: Added sort logic to GetBoardsWithCards
- internal/store/sqlite.go:428-433: Updated SQL query to sort cached boards consistently
- Empty boards now pushed to bottom, active boards at top
+ - **Commit:** 9ef5b7f "Sort Trello boards with active boards first"
+- **Frontend Modernization:** Complete UI overhaul with tabs, HTMX, and Tailwind build pipeline
+ - **Build Pipeline:** npm + PostCSS + Tailwind configuration (replaced CDN)
+ - package.json, tailwind.config.js, postcss.config.js, Makefile
+ - Custom design system with brand colors (Trello, Todoist, Obsidian, PlanToEat)
+ - Compiled CSS: 27KB (vs 3MB CDN), Inter font, custom components
+ - **Tab Interface:** Separate "Tasks" (Trello/Todoist/PlanToEat) from "Notes" (Obsidian)
+ - HTMX for partial page updates (no full refreshes)
+ - Tab switching with proper state management
+ - Auto-refresh maintains current tab context
+ - **Template Restructuring:** Modular partials architecture
+ - web/templates/partials/: 7 reusable template components
+ - tasks-tab.html, notes-tab.html, trello-boards.html, todoist-tasks.html, etc.
+ - Cleaner separation of concerns
+ - **Empty Board Collapsible:** Native `<details>` accordion for empty Trello boards
+ - Active boards displayed prominently in 3-column grid
+ - Empty boards hidden in expandable section
+ - Reduces visual clutter, scales well
+ - **Backend Tab Endpoints:** HTMX-compatible handlers
+ - /tabs/tasks, /tabs/notes, /tabs/refresh routes
+ - HandleTasksTab, HandleNotesTab, HandleRefreshTab methods
+ - Selective rendering for faster tab switches
+ - **JavaScript Enhancements:** app.js rewritten for HTMX integration
+ - HTMX event listeners for loading states
+ - Current tab tracking for refresh/auto-refresh
+ - Improved error handling
+ - **Visual Design:** Modern aesthetic with brand colors
+ - Section headers with color-coded accents
+ - Improved typography hierarchy (Inter font)
+ - Enhanced spacing (10-unit sections, 6-unit cards)
+ - Card hover effects with smooth transitions
+ - Custom scrollbar styling
## 🏗️ Architecture & Decisions
- **Decision:** Use SQLite for caching with a 5-minute TTL.
- **Decision:** Trello is the primary task system, requiring Key+Token auth.
- **Decision:** Limit Trello concurrent requests to 5 to prevent API rate limiting.
- **Decision:** Removed AI agent endpoint - dashboard is human-facing only.
+- **Decision:** HTMX over React/Vue for simpler state management and server-side rendering.
+- **Decision:** Compiled Tailwind over CDN for 99% smaller CSS and custom design tokens.
+- **Decision:** Template partials for HTMX-friendly swap targets and reusability.
+- **Decision:** Native `<details>` element for empty board collapsible (no JS required).
## 📋 Next Steps
1. **Future:** Consider Phase 2 features (write operations, user management).
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index 6872ba7..f31fc56 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -28,12 +28,18 @@ type Handler struct {
// New creates a new Handler instance
func New(store *store.Store, todoist api.TodoistAPI, trello api.TrelloAPI, obsidian api.ObsidianAPI, planToEat api.PlanToEatAPI, cfg *config.Config) *Handler {
- // Parse templates
+ // Parse templates including partials
tmpl, err := template.ParseGlob("web/templates/*.html")
if err != nil {
log.Printf("Warning: failed to parse templates: %v", err)
}
+ // Also parse partials
+ tmpl, err = tmpl.ParseGlob("web/templates/partials/*.html")
+ if err != nil {
+ log.Printf("Warning: failed to parse partial templates: %v", err)
+ }
+
return &Handler{
store: store,
todoistClient: todoist,
@@ -136,6 +142,65 @@ func (h *Handler) HandleGetBoards(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(boards)
}
+// HandleTasksTab renders the tasks tab content (Trello + Todoist + PlanToEat)
+func (h *Handler) HandleTasksTab(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ data, err := h.aggregateData(ctx, false)
+ if err != nil {
+ http.Error(w, "Failed to load tasks", http.StatusInternalServerError)
+ log.Printf("Error loading tasks tab: %v", err)
+ return
+ }
+
+ if err := h.templates.ExecuteTemplate(w, "tasks-tab", data); err != nil {
+ http.Error(w, "Failed to render template", http.StatusInternalServerError)
+ log.Printf("Error rendering tasks tab: %v", err)
+ }
+}
+
+// HandleNotesTab renders the notes tab content (Obsidian)
+func (h *Handler) HandleNotesTab(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ data, err := h.aggregateData(ctx, false)
+ if err != nil {
+ http.Error(w, "Failed to load notes", http.StatusInternalServerError)
+ log.Printf("Error loading notes tab: %v", err)
+ return
+ }
+
+ if err := h.templates.ExecuteTemplate(w, "notes-tab", data); err != nil {
+ http.Error(w, "Failed to render template", http.StatusInternalServerError)
+ log.Printf("Error rendering notes tab: %v", err)
+ }
+}
+
+// HandleRefreshTab refreshes and re-renders the specified tab
+func (h *Handler) HandleRefreshTab(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ tab := r.URL.Query().Get("tab") // "tasks" or "notes"
+
+ // Force refresh
+ data, err := h.aggregateData(ctx, true)
+ if err != nil {
+ http.Error(w, "Failed to refresh", http.StatusInternalServerError)
+ log.Printf("Error refreshing tab: %v", err)
+ return
+ }
+
+ // Determine template to render
+ templateName := "tasks-tab"
+ if tab == "notes" {
+ templateName = "notes-tab"
+ }
+
+ if err := h.templates.ExecuteTemplate(w, templateName, data); err != nil {
+ http.Error(w, "Failed to render template", http.StatusInternalServerError)
+ log.Printf("Error rendering refreshed tab: %v", err)
+ }
+}
+
// aggregateData fetches and caches data from all sources
func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models.DashboardData, error) {
data := &models.DashboardData{
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..ca0d4e2
--- /dev/null
+++ b/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "task-dashboard-frontend",
+ "version": "1.0.0",
+ "description": "Frontend build tools for task dashboard",
+ "scripts": {
+ "dev": "npm run css:watch",
+ "build": "npm run css:build",
+ "css:build": "postcss web/static/css/input.css -o web/static/css/output.css --verbose",
+ "css:watch": "postcss web/static/css/input.css -o web/static/css/output.css --watch"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "devDependencies": {
+ "autoprefixer": "^10.4.16",
+ "postcss": "^8.4.32",
+ "postcss-cli": "^11.0.0",
+ "tailwindcss": "^3.4.0"
+ }
+}
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..33ad091
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..05f25ec
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,38 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: [
+ "./web/templates/**/*.html",
+ "./web/static/js/**/*.js",
+ ],
+ theme: {
+ extend: {
+ colors: {
+ primary: {
+ 50: '#eff6ff',
+ 100: '#dbeafe',
+ 500: '#3b82f6',
+ 600: '#2563eb',
+ 700: '#1d4ed8',
+ },
+ accent: {
+ 50: '#f0f9ff',
+ 100: '#e0f2fe',
+ 500: '#06b6d4',
+ 600: '#0891b2',
+ },
+ trello: '#0079bf',
+ todoist: '#e44332',
+ obsidian: '#7c3aed',
+ plantoeat: '#10b981',
+ },
+ spacing: {
+ '18': '4.5rem',
+ '88': '22rem',
+ },
+ fontFamily: {
+ sans: ['Inter', 'system-ui', 'sans-serif'],
+ },
+ },
+ },
+ plugins: [],
+}
diff --git a/web/static/css/input.css b/web/static/css/input.css
new file mode 100644
index 0000000..16e7d2e
--- /dev/null
+++ b/web/static/css/input.css
@@ -0,0 +1,123 @@
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* Custom base styles */
+@layer base {
+ body {
+ @apply antialiased text-gray-900 bg-gradient-to-br from-gray-50 to-gray-100;
+ font-family: 'Inter', system-ui, sans-serif;
+ }
+
+ h1 {
+ @apply text-4xl font-bold tracking-tight;
+ }
+
+ h2 {
+ @apply text-2xl font-semibold tracking-tight;
+ }
+
+ h3 {
+ @apply text-lg font-semibold;
+ }
+}
+
+/* Custom components */
+@layer components {
+ .card {
+ @apply bg-white rounded-xl shadow-sm border border-gray-200 p-6 transition-all duration-200;
+ }
+
+ .card-hover {
+ @apply hover:shadow-lg hover:border-gray-300 hover:-translate-y-0.5;
+ }
+
+ .section-header {
+ @apply text-2xl font-bold text-gray-900 mb-6;
+ }
+
+ .tab-button {
+ @apply px-6 py-3 font-medium text-gray-600 border-b-2 border-transparent
+ hover:text-gray-900 hover:border-gray-300 transition-colors cursor-pointer;
+ }
+
+ .tab-button-active {
+ @apply text-primary-600 border-primary-600;
+ }
+
+ .badge {
+ @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
+ }
+
+ .board-card {
+ @apply card card-hover;
+ }
+
+ .trello-card-item {
+ @apply bg-white border border-gray-200 rounded-lg p-4
+ hover:shadow-md hover:border-trello/30 transition-all;
+ }
+
+ .task-item {
+ @apply flex items-start gap-3 p-4 rounded-lg
+ hover:bg-gray-50 transition-colors;
+ }
+
+ .note-card {
+ @apply card card-hover border-l-4 border-l-obsidian;
+ }
+
+ .line-clamp-3 {
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+}
+
+/* Custom utilities */
+@layer utilities {
+ .section-spacing {
+ @apply mb-10;
+ }
+
+ .content-max-width {
+ @apply max-w-7xl mx-auto px-6 lg:px-8;
+ }
+
+ .card-grid {
+ @apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6;
+ }
+
+ .scrollbar-thin {
+ scrollbar-width: thin;
+ scrollbar-color: rgb(203 213 225) rgb(241 245 249);
+ }
+
+ .scrollbar-thin::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-track {
+ background: rgb(241 245 249);
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-thumb {
+ background: rgb(203 213 225);
+ border-radius: 4px;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-thumb:hover {
+ background: rgb(148 163 184);
+ }
+}
+
+/* Print styles */
+@media print {
+ .no-print {
+ display: none !important;
+ }
+}
diff --git a/web/static/css/styles.css b/web/static/css/styles.css
deleted file mode 100644
index aee6ee3..0000000
--- a/web/static/css/styles.css
+++ /dev/null
@@ -1,70 +0,0 @@
-/* Custom styles for Personal Dashboard */
-
-/* Line clamp utility for truncating text */
-.line-clamp-3 {
- display: -webkit-box;
- -webkit-line-clamp: 3;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-/* Loading spinner */
-.spinner {
- border: 3px solid #f3f3f3;
- border-top: 3px solid #3b82f6;
- border-radius: 50%;
- width: 20px;
- height: 20px;
- animation: spin 1s linear infinite;
- display: inline-block;
- margin-left: 8px;
-}
-
-@keyframes spin {
- 0% { transform: rotate(0deg); }
- 100% { transform: rotate(360deg); }
-}
-
-/* Smooth transitions */
-* {
- transition-property: background-color, border-color, color, fill, stroke;
- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
- transition-duration: 150ms;
-}
-
-/* Custom scrollbar */
-::-webkit-scrollbar {
- width: 8px;
- height: 8px;
-}
-
-::-webkit-scrollbar-track {
- background: #f1f1f1;
-}
-
-::-webkit-scrollbar-thumb {
- background: #888;
- border-radius: 4px;
-}
-
-::-webkit-scrollbar-thumb:hover {
- background: #555;
-}
-
-/* Print styles */
-@media print {
- .no-print {
- display: none;
- }
-}
-
-/* Dark mode support (optional) */
-@media (prefers-color-scheme: dark) {
- /* Uncomment to enable dark mode */
- /*
- body {
- background-color: #1a202c;
- color: #e2e8f0;
- }
- */
-}
diff --git a/web/static/js/app.js b/web/static/js/app.js
index a96c05d..b68b12a 100644
--- a/web/static/js/app.js
+++ b/web/static/js/app.js
@@ -1,77 +1,179 @@
-// Personal Dashboard JavaScript
+// Personal Dashboard JavaScript with HTMX Integration
-// Auto-refresh every 5 minutes
+// Constants
const AUTO_REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes in milliseconds
-// Initialize auto-refresh on page load
+// Track current active tab
+let currentTab = 'tasks';
+let autoRefreshTimer = null;
+
+// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
- // Set up auto-refresh
- setInterval(autoRefresh, AUTO_REFRESH_INTERVAL);
+ console.log('Dashboard initialized');
+
+ // Set up HTMX event listeners
+ setupHtmxListeners();
+
+ // Start auto-refresh
+ startAutoRefresh();
});
-// Auto-refresh function
-async function autoRefresh() {
- console.log('Auto-refreshing data...');
- try {
- const response = await fetch('/api/refresh', {
- method: 'POST'
- });
+// HTMX Event Listeners
+function setupHtmxListeners() {
+ // Before HTMX request
+ document.body.addEventListener('htmx:beforeRequest', function(evt) {
+ const target = evt.detail.target;
+ if (target.id === 'tab-content') {
+ // Show loading state
+ target.classList.add('opacity-50', 'pointer-events-none');
+ }
+ });
- if (response.ok) {
- // Reload the page to show updated data
- window.location.reload();
+ // After HTMX request completes
+ document.body.addEventListener('htmx:afterRequest', function(evt) {
+ const target = evt.detail.target;
+ if (target.id === 'tab-content') {
+ // Hide loading state
+ target.classList.remove('opacity-50', 'pointer-events-none');
+
+ // Update timestamp
+ updateLastUpdatedTime();
}
- } catch (error) {
- console.error('Auto-refresh failed:', error);
- }
+ });
+
+ // Handle HTMX errors
+ document.body.addEventListener('htmx:responseError', function(evt) {
+ console.error('HTMX request failed:', evt.detail);
+ alert('Failed to load content. Please try again.');
+
+ // Remove loading state
+ const target = evt.detail.target;
+ if (target) {
+ target.classList.remove('opacity-50', 'pointer-events-none');
+ }
+ });
+}
+
+// Tab Management
+function setActiveTab(button) {
+ // Remove active class from all tabs
+ document.querySelectorAll('.tab-button').forEach(tab => {
+ tab.classList.remove('tab-button-active');
+ });
+
+ // Add active class to clicked tab
+ button.classList.add('tab-button-active');
+
+ // Extract tab name from hx-get attribute
+ const endpoint = button.getAttribute('hx-get');
+ currentTab = endpoint.split('/').pop(); // "tasks" or "notes"
+
+ console.log('Switched to tab:', currentTab);
+
+ // Reset auto-refresh timer when switching tabs
+ resetAutoRefresh();
}
-// Manual refresh function
+// Manual Refresh
async function refreshData() {
- const button = event.target;
- const originalText = button.textContent;
+ const button = event.target.closest('button');
+ const refreshText = document.getElementById('refresh-text');
+ const originalText = refreshText.textContent;
// Show loading state
button.disabled = true;
- button.innerHTML = 'Refreshing...<span class="spinner"></span>';
+ refreshText.innerHTML = `
+ <svg class="animate-spin h-5 w-5 inline mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+ </svg>
+ Refreshing...
+ `;
try {
- const response = await fetch('/api/refresh', {
+ // Refresh current tab
+ const response = await fetch(`/tabs/refresh?tab=${currentTab}`, {
method: 'POST'
});
- if (response.ok) {
- // Update last updated time
- const now = new Date();
- const timeString = now.toLocaleTimeString('en-US', {
- hour: 'numeric',
- minute: '2-digit'
- });
- document.getElementById('last-updated').textContent = timeString;
-
- // Reload the page to show updated data
- setTimeout(() => {
- window.location.reload();
- }, 500);
- } else {
- throw new Error('Refresh failed');
- }
+ if (!response.ok) throw new Error('Refresh failed');
+
+ // Get HTML response and update tab content
+ const html = await response.text();
+ document.getElementById('tab-content').innerHTML = html;
+
+ // Update timestamp
+ updateLastUpdatedTime();
+
+ // Reset auto-refresh timer
+ resetAutoRefresh();
+
+ console.log('Manual refresh successful');
+
} catch (error) {
console.error('Refresh failed:', error);
alert('Failed to refresh data. Please try again.');
+ } finally {
+ // Restore button state
button.disabled = false;
- button.textContent = originalText;
+ refreshText.textContent = originalText;
+ }
+}
+
+// Auto-refresh Functions
+function startAutoRefresh() {
+ if (autoRefreshTimer) {
+ clearInterval(autoRefreshTimer);
+ }
+ autoRefreshTimer = setInterval(autoRefresh, AUTO_REFRESH_INTERVAL);
+ console.log('Auto-refresh started (5 min interval)');
+}
+
+function resetAutoRefresh() {
+ clearInterval(autoRefreshTimer);
+ startAutoRefresh();
+}
+
+async function autoRefresh() {
+ console.log(`Auto-refreshing ${currentTab} tab...`);
+
+ try {
+ const response = await fetch(`/tabs/refresh?tab=${currentTab}`, {
+ method: 'POST'
+ });
+
+ if (response.ok) {
+ const html = await response.text();
+ document.getElementById('tab-content').innerHTML = html;
+ updateLastUpdatedTime();
+ console.log('Auto-refresh successful');
+ }
+ } catch (error) {
+ console.error('Auto-refresh failed:', error);
+ }
+}
+
+// Update Last Updated Time
+function updateLastUpdatedTime() {
+ const now = new Date();
+ const timeString = now.toLocaleTimeString('en-US', {
+ hour: 'numeric',
+ minute: '2-digit'
+ });
+ const element = document.getElementById('last-updated');
+ if (element) {
+ element.textContent = timeString;
}
}
-// Filter tasks by status
+// Filter tasks by status (Phase 2 feature)
function filterTasks(status) {
- // This will be implemented in Phase 2
console.log('Filter tasks:', status);
+ // To be implemented in Phase 2
}
-// Toggle task completion
+// Toggle task completion (Phase 2 feature)
function toggleTask(taskId) {
- // This will be implemented in Phase 2
console.log('Toggle task:', taskId);
+ // To be implemented in Phase 2
}
diff --git a/web/static/js/htmx.min.js b/web/static/js/htmx.min.js
new file mode 100644
index 0000000..47eb70f
--- /dev/null
+++ b/web/static/js/htmx.min.js
@@ -0,0 +1 @@
+(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,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:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.10"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t=false){return new RegExp(`<${e}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${e}>`,t?"gim":"im")}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.getAttribute&&e.getAttribute(t)}function o(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 u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function a(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/<body/.test(e)}function l(e){var t=!N(e);var r=A(e);var n=e;if(r==="head"){n=n.replace(S,"")}if(Q.config.useTemplateFragments&&t){var i=a("<body><template>"+n+"</template></body>",0);return i.querySelector("template").content}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return a("<table>"+n+"</table>",1);case"col":return a("<table><colgroup>"+n+"</colgroup></table>",2);case"tr":return a("<table><tbody>"+n+"</tbody></table>",2);case"td":case"th":return a("<table><tbody><tr>"+n+"</tr></tbody></table>",3);case"script":case"style":return a("<div>"+n+"</div>",1);default:return a(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r<e.length;r++){t.push(e[r])}}return t}function oe(e,t){if(e){for(var r=0;r<e.length;r++){t(e[r])}}}function X(e){var t=e.getBoundingClientRect();var r=t.top;var n=t.bottom;return r<window.innerHeight&&n>=0}function se(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return re().body.contains(e.getRootNode().host)}else{return re().body.contains(e)}}function D(e){return e.trim().split(/\s+/)}function le(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function E(e){try{return JSON.parse(e)}catch(e){b(e);return null}}function U(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function B(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function t(e){return Tr(re().body,function(){return eval(e)})}function F(t){var e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function j(){Q.logger=null}function C(e,t){if(t){return e.querySelector(t)}else{return C(re(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(re(),e)}}function _(e,t){e=g(e);if(t){setTimeout(function(){_(e);e=null},t)}else{e.parentElement.removeChild(e)}}function z(e,t,r){e=g(e);if(r){setTimeout(function(){z(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=g(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function $(e,t){e=g(e);e.classList.toggle(t)}function W(e,t){e=g(e);oe(e.parentElement.children,function(e){n(e,t)});z(e,t)}function v(e,t){e=g(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function s(e,t){return e.substring(0,t.length)===t}function G(e,t){return e.substring(e.length-t.length)===t}function J(e){var t=e.trim();if(s(t,"<")&&G(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function Z(e,t){if(t.indexOf("closest ")===0){return[v(e,J(t.substr(8)))]}else if(t.indexOf("find ")===0){return[C(e,J(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[K(e,J(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[Y(e,J(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return re().querySelectorAll(J(t))}}var K=function(e,t){var r=re().querySelectorAll(t);for(var n=0;n<r.length;n++){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_PRECEDING){return i}}};var Y=function(e,t){var r=re().querySelectorAll(t);for(var n=r.length-1;n>=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ue(e,t){if(t){return Z(e,t)[0]}else{return Z(re().body,e)[0]}}function g(e){if(I(e,"String")){return C(e)}else{return e}}function ve(e,t,r){if(k(t)){return{target:re().body,event:e,listener:t}}else{return{target:g(e),event:t,listener:r}}}function de(t,r,n){jr(function(){var e=ve(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=k(r);return e?r:n}function ge(t,r,n){jr(function(){var e=ve(t,r,n);e.target.removeEventListener(e.event,e.listener)});return k(r)?r:n}var me=re().createElement("output");function pe(e,t){var r=ne(e,t);if(r){if(r==="this"){return[xe(e,t)]}else{var n=Z(e,r);if(n.length===0){b('The selector "'+r+'" on '+t+" returned no matches!");return[me]}else{return n}}}}function xe(e,t){return c(e,function(e){return te(e,t)!=null})}function ye(e){var t=ne(e,"hx-target");if(t){if(t==="this"){return xe(e,"hx-target")}else{return ue(e,t)}}else{var r=ae(e);if(r.boosted){return re().body}else{return e}}}function be(e){var t=Q.config.attributesToSettle;for(var r=0;r<t.length;r++){if(e===t[r]){return true}}return false}function we(t,r){oe(t.attributes,function(e){if(!r.hasAttribute(e.name)&&be(e.name)){t.removeAttribute(e.name)}});oe(r.attributes,function(e){if(be(e.name)){t.setAttribute(e.name,e.value)}})}function Se(e,t){var r=Fr(t);for(var n=0;n<r.length;n++){var i=r[n];try{if(i.isInlineSwap(e)){return true}}catch(e){b(e)}}return e==="outerHTML"}function Ee(e,i,a){var t="#"+ee(i,"id");var o="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=re().querySelectorAll(t);if(r){oe(r,function(e){var t;var r=i.cloneNode(true);t=re().createDocumentFragment();t.appendChild(r);if(!Se(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!ce(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Fe(o,e,e,t,a)}oe(a.elts,function(e){ce(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);fe(re().body,"htmx:oobErrorNoTarget",{content:i})}return e}function Ce(e,t,r){var n=ne(e,"hx-select-oob");if(n){var i=n.split(",");for(var a=0;a<i.length;a++){var o=i[a].split(":",2);var s=o[0].trim();if(s.indexOf("#")===0){s=s.substring(1)}var l=o[1]||"true";var u=t.querySelector("#"+s);if(u){Ee(l,u,r)}}}oe(f(t,"[hx-swap-oob], [data-hx-swap-oob]"),function(e){var t=te(e,"hx-swap-oob");if(t!=null){Ee(t,e,r)}})}function Re(e){oe(f(e,"[hx-preserve], [data-hx-preserve]"),function(e){var t=te(e,"id");var r=re().getElementById(t);if(r!=null){e.parentNode.replaceChild(r,e)}})}function Te(o,e,s){oe(e.querySelectorAll("[id]"),function(e){var t=ee(e,"id");if(t&&t.length>0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();we(e,i);s.tasks.push(function(){we(e,a)})}}})}function Oe(e){return function(){n(e,Q.config.addedClass);zt(e);Nt(e);qe(e);ce(e,"htmx:load")}}function qe(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function m(e,t,r,n){Te(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;z(i,Q.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Oe(i))}}}function He(e,t){var r=0;while(r<e.length){t=(t<<5)-t+e.charCodeAt(r++)|0}return t}function Le(e){var t=0;if(e.attributes){for(var r=0;r<e.attributes.length;r++){var n=e.attributes[r];if(n.value){t=He(n.name,t);t=He(n.value,t)}}}return t}function Ae(e){var t=ae(e);if(t.onHandlers){for(var r=0;r<t.onHandlers.length;r++){const n=t.onHandlers[r];e.removeEventListener(n.event,n.listener)}delete t.onHandlers}}function Ne(e){var t=ae(e);if(t.timeout){clearTimeout(t.timeout)}if(t.webSocket){t.webSocket.close()}if(t.sseEventSource){t.sseEventSource.close()}if(t.listenerInfos){oe(t.listenerInfos,function(e){if(e.on){e.on.removeEventListener(e.trigger,e.listener)}})}Ae(e);oe(Object.keys(t),function(e){delete t[e]})}function p(e){ce(e,"htmx:beforeCleanupElement");Ne(e);if(e.children){oe(e.children,function(e){p(e)})}}function Ie(t,e,r){if(t.tagName==="BODY"){return Ue(t,e,r)}else{var n;var i=t.previousSibling;m(u(t),t,e,r);if(i==null){n=u(t).firstChild}else{n=i.nextSibling}r.elts=r.elts.filter(function(e){return e!=t});while(n&&n!==t){if(n.nodeType===Node.ELEMENT_NODE){r.elts.push(n)}n=n.nextElementSibling}p(t);u(t).removeChild(t)}}function ke(e,t,r){return m(e,e.firstChild,t,r)}function Pe(e,t,r){return m(u(e),e,t,r)}function Me(e,t,r){return m(e,null,t,r)}function Xe(e,t,r){return m(u(e),e.nextSibling,t,r)}function De(e,t,r){p(e);return u(e).removeChild(e)}function Ue(e,t,r){var n=e.firstChild;m(e,n,t,r);if(n){while(n.nextSibling){p(n.nextSibling);e.removeChild(n.nextSibling)}p(n);e.removeChild(n)}}function Be(e,t,r){var n=r||ne(e,"hx-select");if(n){var i=re().createDocumentFragment();oe(t.querySelectorAll(n),function(e){i.appendChild(e)});t=i}return t}function Fe(e,t,r,n,i){switch(e){case"none":return;case"outerHTML":Ie(r,n,i);return;case"afterbegin":ke(r,n,i);return;case"beforebegin":Pe(r,n,i);return;case"beforeend":Me(r,n,i);return;case"afterend":Xe(r,n,i);return;case"delete":De(r,n,i);return;default:var a=Fr(t);for(var o=0;o<a.length;o++){var s=a[o];try{var l=s.handleSwap(e,r,n,i);if(l){if(typeof l.length!=="undefined"){for(var u=0;u<l.length;u++){var f=l[u];if(f.nodeType!==Node.TEXT_NODE&&f.nodeType!==Node.COMMENT_NODE){i.tasks.push(Oe(f))}}}return}}catch(e){b(e)}}if(e==="innerHTML"){Ue(r,n,i)}else{Fe(Q.config.defaultSwapStyle,t,r,n,i)}}}function Ve(e){if(e.indexOf("<title")>-1){var t=e.replace(H,"");var r=t.match(q);if(r){return r[2]}}}function je(e,t,r,n,i,a){i.title=Ve(n);var o=l(n);if(o){Ce(r,o,i);o=Be(r,o,a);Re(o);return Fe(e,r,t,o,i)}}function _e(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=E(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!P(o)){o={value:o}}ce(r,a,o)}}}else{var s=n.split(",");for(var l=0;l<s.length;l++){ce(r,s[l].trim(),[])}}}var ze=/\s/;var x=/[\s,]/;var $e=/[_$a-zA-Z]/;var We=/[_$a-zA-Z0-9]/;var Ge=['"',"'","/"];var Je=/[^\s]/;var Ze=/[{(]/;var Ke=/[})]/;function Ye(e){var t=[];var r=0;while(r<e.length){if($e.exec(e.charAt(r))){var n=r;while(We.exec(e.charAt(r+1))){r++}t.push(e.substr(n,r-n+1))}else if(Ge.indexOf(e.charAt(r))!==-1){var i=e.charAt(r);var n=r;r++;while(r<e.length&&e.charAt(r)!==i){if(e.charAt(r)==="\\"){r++}r++}t.push(e.substr(n,r-n+1))}else{var a=e.charAt(r);t.push(a)}r++}return t}function Qe(e,t,r){return $e.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==r&&t!=="."}function et(e,t,r){if(t[0]==="["){t.shift();var n=1;var i=" return (function("+r+"){ return (";var a=null;while(t.length>0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=Tr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){fe(re().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(Qe(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function y(e,t){var r="";while(e.length>0&&!t.test(e[0])){r+=e.shift()}return r}function tt(e){var t;if(e.length>0&&Ze.test(e[0])){e.shift();t=y(e,Ke).trim();e.shift()}else{t=y(e,x)}return t}var rt="input, textarea, select";function nt(e,t,r){var n=[];var i=Ye(t);do{y(i,Je);var a=i.length;var o=y(i,/[,\[\s]/);if(o!==""){if(o==="every"){var s={trigger:"every"};y(i,Je);s.pollInterval=d(y(i,/[,\[\s]/));y(i,Je);var l=et(e,i,"event");if(l){s.eventFilter=l}n.push(s)}else if(o.indexOf("sse:")===0){n.push({trigger:"sse",sseEvent:o.substr(4)})}else{var u={trigger:o};var l=et(e,i,"event");if(l){u.eventFilter=l}while(i.length>0&&i[0]!==","){y(i,Je);var f=i.shift();if(f==="changed"){u.changed=true}else if(f==="once"){u.once=true}else if(f==="consume"){u.consume=true}else if(f==="delay"&&i[0]===":"){i.shift();u.delay=d(y(i,x))}else if(f==="from"&&i[0]===":"){i.shift();if(Ze.test(i[0])){var c=tt(i)}else{var c=y(i,x);if(c==="closest"||c==="find"||c==="next"||c==="previous"){i.shift();var h=tt(i);if(h.length>0){c+=" "+h}}}u.from=c}else if(f==="target"&&i[0]===":"){i.shift();u.target=tt(i)}else if(f==="throttle"&&i[0]===":"){i.shift();u.throttle=d(y(i,x))}else if(f==="queue"&&i[0]===":"){i.shift();u.queue=y(i,x)}else if(f==="root"&&i[0]===":"){i.shift();u[f]=tt(i)}else if(f==="threshold"&&i[0]===":"){i.shift();u[f]=y(i,x)}else{fe(e,"htmx:syntax:error",{token:i.shift()})}}n.push(u)}}if(i.length===a){fe(e,"htmx:syntax:error",{token:i.shift()})}y(i,Je)}while(i[0]===","&&i.shift());if(r){r[t]=n}return n}function it(e){var t=te(e,"hx-trigger");var r=[];if(t){var n=Q.config.triggerSpecsCache;r=n&&n[t]||nt(e,t,n)}if(r.length>0){return r}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,rt)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function at(e){ae(e).cancelled=true}function ot(e,t,r){var n=ae(e);n.timeout=setTimeout(function(){if(se(e)&&n.cancelled!==true){if(!ct(r,e,Wt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}ot(e,t,r)}},r.pollInterval)}function st(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function lt(t,r,e){if(t.tagName==="A"&&st(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=ee(t,"href")}else{var a=ee(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=ee(t,"action")}e.forEach(function(e){ht(t,function(e,t){if(v(e,Q.config.disableSelector)){p(e);return}he(n,i,e,t)},r,e,true)})}}function ut(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&v(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ft(e,t){return ae(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function ct(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){fe(re().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function ht(a,o,e,s,l){var u=ae(a);var t;if(s.from){t=Z(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ae(e);t.lastValue=e.value})}oe(t,function(n){var i=function(e){if(!se(a)){n.removeEventListener(s.trigger,i);return}if(ft(a,e)){return}if(l||ut(e,a)){e.preventDefault()}if(ct(s,a,e)){return}var t=ae(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ae(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle>0){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay>0){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{ce(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var vt=false;var dt=null;function gt(){if(!dt){dt=function(){vt=true};window.addEventListener("scroll",dt);setInterval(function(){if(vt){vt=false;oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){mt(e)})}},200)}}function mt(t){if(!o(t,"data-hx-revealed")&&X(t)){t.setAttribute("data-hx-revealed","true");var e=ae(t);if(e.initHash){ce(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){ce(t,"revealed")},{once:true})}}}function pt(e,t,r){var n=D(r);for(var i=0;i<n.length;i++){var a=n[i].split(/:(.+)/);if(a[0]==="connect"){xt(e,a[1],0)}if(a[0]==="send"){bt(e)}}}function xt(s,r,n){if(!se(s)){return}if(r.indexOf("/")==0){var e=location.hostname+(location.port?":"+location.port:"");if(location.protocol=="https:"){r="wss://"+e+r}else if(location.protocol=="http:"){r="ws://"+e+r}}var t=Q.createWebSocket(r);t.onerror=function(e){fe(s,"htmx:wsError",{error:e,socket:t});yt(s)};t.onclose=function(e){if([1006,1012,1013].indexOf(e.code)>=0){var t=wt(n);setTimeout(function(){xt(s,r,n+1)},t)}};t.onopen=function(e){n=0};ae(s).webSocket=t;t.addEventListener("message",function(e){if(yt(s)){return}var t=e.data;R(s,function(e){t=e.transformResponse(t,null,s)});var r=T(s);var n=l(t);var i=M(n.children);for(var a=0;a<i.length;a++){var o=i[a];Ee(te(o,"hx-swap-oob")||"true",o,r)}nr(r.tasks)})}function yt(e){if(!se(e)){ae(e).webSocket.close();return true}}function bt(u){var f=c(u,function(e){return ae(e).webSocket!=null});if(f){u.addEventListener(it(u)[0].trigger,function(e){var t=ae(f).webSocket;var r=xr(u,f);var n=dr(u,"post");var i=n.errors;var a=n.values;var o=Hr(u);var s=le(a,o);var l=yr(s,u);l["HEADERS"]=r;if(i&&i.length>0){ce(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(ut(e,u)){e.preventDefault()}})}else{fe(u,"htmx:noWebSocketSourceError")}}function wt(e){var t=Q.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}b('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function St(e,t,r){var n=D(r);for(var i=0;i<n.length;i++){var a=n[i].split(/:(.+)/);if(a[0]==="connect"){Et(e,a[1])}if(a[0]==="swap"){Ct(e,a[1])}}}function Et(t,e){var r=Q.createEventSource(e);r.onerror=function(e){fe(t,"htmx:sseError",{error:e,source:r});Tt(t)};ae(t).sseEventSource=r}function Ct(a,o){var s=c(a,Ot);if(s){var l=ae(s).sseEventSource;var u=function(e){if(Tt(s)){return}if(!se(a)){l.removeEventListener(o,u);return}var t=e.data;R(a,function(e){t=e.transformResponse(t,null,a)});var r=wr(a);var n=ye(a);var i=T(a);je(r.swapStyle,n,a,t,i);nr(i.tasks);ce(a,"htmx:sseMessage",e)};ae(a).sseListener=u;l.addEventListener(o,u)}else{fe(a,"htmx:noSSESourceError")}}function Rt(e,t,r){var n=c(e,Ot);if(n){var i=ae(n).sseEventSource;var a=function(){if(!Tt(n)){if(se(e)){t(e)}else{i.removeEventListener(r,a)}}};ae(e).sseListener=a;i.addEventListener(r,a)}else{fe(e,"htmx:noSSESourceError")}}function Tt(e){if(!se(e)){ae(e).sseEventSource.close();return true}}function Ot(e){return ae(e).sseEventSource!=null}function qt(e,t,r,n){var i=function(){if(!r.loaded){r.loaded=true;t(e)}};if(n>0){setTimeout(i,n)}else{i()}}function Ht(t,i,e){var a=false;oe(w,function(r){if(o(t,"hx-"+r)){var n=te(t,"hx-"+r);a=true;i.path=n;i.verb=r;e.forEach(function(e){Lt(t,e,i,function(e,t){if(v(e,Q.config.disableSelector)){p(e);return}he(r,n,e,t)})})}});return a}function Lt(n,e,t,r){if(e.sseEvent){Rt(n,r,e.sseEvent)}else if(e.trigger==="revealed"){gt();ht(n,r,t,e);mt(n)}else if(e.trigger==="intersect"){var i={};if(e.root){i.root=ue(n,e.root)}if(e.threshold){i.threshold=parseFloat(e.threshold)}var a=new IntersectionObserver(function(e){for(var t=0;t<e.length;t++){var r=e[t];if(r.isIntersecting){ce(n,"intersect");break}}},i);a.observe(n);ht(n,r,t,e)}else if(e.trigger==="load"){if(!ct(e,n,Wt("load",{elt:n}))){qt(n,r,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ot(n,r,e)}else{ht(n,r,t,e)}}function At(e){if(Q.config.allowScriptTags&&(e.type==="text/javascript"||e.type==="module"||e.type==="")){var t=re().createElement("script");oe(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}var r=e.parentElement;try{r.insertBefore(t,e)}catch(e){b(e)}finally{if(e.parentElement){e.parentElement.removeChild(e)}}}}function Nt(e){if(h(e,"script")){At(e)}oe(f(e,"script"),function(e){At(e)})}function It(e){var t=e.attributes;for(var r=0;r<t.length;r++){var n=t[r].name;if(s(n,"hx-on:")||s(n,"data-hx-on:")||s(n,"hx-on-")||s(n,"data-hx-on-")){return true}}return false}function kt(e){var t=null;var r=[];if(It(e)){r.push(e)}if(document.evaluate){var n=document.evaluate('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]',e);while(t=n.iterateNext())r.push(t)}else{var i=e.getElementsByTagName("*");for(var a=0;a<i.length;a++){if(It(i[a])){r.push(i[a])}}}return r}function Pt(e){if(e.querySelectorAll){var t=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";var r=e.querySelectorAll(i+t+", form, [type='submit'], [hx-sse], [data-hx-sse], [hx-ws],"+" [data-hx-ws], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger], [hx-on], [data-hx-on]");return r}else{return[]}}function Mt(e){var t=v(e.target,"button, input[type='submit']");var r=Dt(e);if(r){r.lastButtonClicked=t}}function Xt(e){var t=Dt(e);if(t){t.lastButtonClicked=null}}function Dt(e){var t=v(e.target,"button, input[type='submit']");if(!t){return}var r=g("#"+ee(t,"form"))||v(t,"form");if(!r){return}return ae(r)}function Ut(e){e.addEventListener("click",Mt);e.addEventListener("focusin",Mt);e.addEventListener("focusout",Xt)}function Bt(e){var t=Ye(e);var r=0;for(var n=0;n<t.length;n++){const i=t[n];if(i==="{"){r++}else if(i==="}"){r--}}return r}function Ft(t,e,r){var n=ae(t);if(!Array.isArray(n.onHandlers)){n.onHandlers=[]}var i;var a=function(e){return Tr(t,function(){if(!i){i=new Function("event",r)}i.call(t,e)})};t.addEventListener(e,a);n.onHandlers.push({event:e,listener:a})}function Vt(e){var t=te(e,"hx-on");if(t){var r={};var n=t.split("\n");var i=null;var a=0;while(n.length>0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Bt(o)}for(var l in r){Ft(e,l,r[l])}}}function jt(e){Ae(e);for(var t=0;t<e.attributes.length;t++){var r=e.attributes[t].name;var n=e.attributes[t].value;if(s(r,"hx-on")||s(r,"data-hx-on")){var i=r.indexOf("-on")+3;var a=r.slice(i,i+1);if(a==="-"||a===":"){var o=r.slice(i+1);if(s(o,":")){o="htmx"+o}else if(s(o,"-")){o="htmx:"+o.slice(1)}else if(s(o,"htmx-")){o="htmx:"+o.slice(5)}Ft(e,o,n)}}}}function _t(t){if(v(t,Q.config.disableSelector)){p(t);return}var r=ae(t);if(r.initHash!==Le(t)){Ne(t);r.initHash=Le(t);Vt(t);ce(t,"htmx:beforeProcessNode");if(t.value){r.lastValue=t.value}var e=it(t);var n=Ht(t,r,e);if(!n){if(ne(t,"hx-boost")==="true"){lt(t,r,e)}else if(o(t,"hx-trigger")){e.forEach(function(e){Lt(t,e,r,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&o(t,"form")){Ut(t)}var i=te(t,"hx-sse");if(i){St(t,r,i)}var a=te(t,"hx-ws");if(a){pt(t,r,a)}ce(t,"htmx:afterProcessNode")}}function zt(e){e=g(e);if(v(e,Q.config.disableSelector)){p(e);return}_t(e);oe(Pt(e),function(e){_t(e)});oe(kt(e),jt)}function $t(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Wt(e,t){var r;if(window.CustomEvent&&typeof window.CustomEvent==="function"){r=new CustomEvent(e,{bubbles:true,cancelable:true,detail:t})}else{r=re().createEvent("CustomEvent");r.initCustomEvent(e,true,true,t)}return r}function fe(e,t,r){ce(e,t,le({error:t},r))}function Gt(e){return e==="htmx:afterProcessNode"}function R(e,t){oe(Fr(e),function(e){try{t(e)}catch(e){b(e)}})}function b(e){if(console.error){console.error(e)}else if(console.log){console.log("ERROR: ",e)}}function ce(e,t,r){e=g(e);if(r==null){r={}}r["elt"]=e;var n=Wt(t,r);if(Q.logger&&!Gt(t)){Q.logger(e,t,r)}if(r.error){b(r.error);ce(e,"htmx:error",{errorInfo:r})}var i=e.dispatchEvent(n);var a=$t(t);if(i&&a!==t){var o=Wt(a,n.detail);i=i&&e.dispatchEvent(o)}R(e,function(e){i=i&&(e.onEvent(t,n)!==false&&!n.defaultPrevented)});return i}var Jt=location.pathname+location.search;function Zt(){var e=re().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||re().body}function Kt(e,t,r,n){if(!U()){return}if(Q.config.historyCacheSize<=0){localStorage.removeItem("htmx-history-cache");return}e=B(e);var i=E(localStorage.getItem("htmx-history-cache"))||[];for(var a=0;a<i.length;a++){if(i[a].url===e){i.splice(a,1);break}}var o={url:e,content:t,title:r,scroll:n};ce(re().body,"htmx:historyItemCreated",{item:o,cache:i});i.push(o);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(re().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Yt(e){if(!U()){return null}e=B(e);var t=E(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r<t.length;r++){if(t[r].url===e){return t[r]}}return null}function Qt(e){var t=Q.config.requestClass;var r=e.cloneNode(true);oe(f(r,"."+t),function(e){n(e,t)});return r.innerHTML}function er(){var e=Zt();var t=Jt||location.pathname+location.search;var r;try{r=re().querySelector('[hx-history="false" i],[data-hx-history="false" i]')}catch(e){r=re().querySelector('[hx-history="false"],[data-hx-history="false"]')}if(!r){ce(re().body,"htmx:beforeHistorySave",{path:t,historyElt:e});Kt(t,Qt(e),re().title,window.scrollY)}if(Q.config.historyEnabled)history.replaceState({htmx:true},re().title,window.location.href)}function tr(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(G(e,"&")||G(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}Jt=e}function rr(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);Jt=e}function nr(e){oe(e,function(e){e.call()})}function ir(a){var e=new XMLHttpRequest;var o={path:a,xhr:e};ce(re().body,"htmx:historyCacheMiss",o);e.open("GET",a,true);e.setRequestHeader("HX-Request","true");e.setRequestHeader("HX-History-Restore-Request","true");e.setRequestHeader("HX-Current-URL",re().location.href);e.onload=function(){if(this.status>=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Zt();var r=T(t);var n=Ve(this.response);if(n){var i=C("title");if(i){i.innerHTML=n}else{window.document.title=n}}Ue(t,e,r);nr(r.tasks);Jt=a;ce(re().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{fe(re().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function ar(e){er();e=e||location.pathname+location.search;var t=Yt(e);if(t){var r=l(t.content);var n=Zt();var i=T(n);Ue(n,r,i);nr(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Jt=e;ce(re().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{ir(e)}}}function or(e){var t=pe(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Q.config.requestClass)});return t}function sr(e){var t=pe(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function lr(e,t){oe(e,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Q.config.requestClass)}});oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function ur(e,t){for(var r=0;r<e.length;r++){var n=e[r];if(n.isSameNode(t)){return true}}return false}function fr(e){if(e.name===""||e.name==null||e.disabled||v(e,"fieldset[disabled]")){return false}if(e.type==="button"||e.type==="submit"||e.tagName==="image"||e.tagName==="reset"||e.tagName==="file"){return false}if(e.type==="checkbox"||e.type==="radio"){return e.checked}return true}function cr(e,t,r){if(e!=null&&t!=null){var n=r[e];if(n===undefined){r[e]=t}else if(Array.isArray(n)){if(Array.isArray(t)){r[e]=n.concat(t)}else{n.push(t)}}else{if(Array.isArray(t)){r[e]=[n].concat(t)}else{r[e]=[n,t]}}}}function hr(t,r,n,e,i){if(e==null||ur(t,e)){return}else{t.push(e)}if(fr(e)){var a=ee(e,"name");var o=e.value;if(e.multiple&&e.tagName==="SELECT"){o=M(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e.files){o=M(e.files)}cr(a,o,r);if(i){vr(e,n)}}if(h(e,"form")){var s=e.elements;oe(s,function(e){hr(t,r,n,e,i)})}}function vr(e,t){if(e.willValidate){ce(e,"htmx:validation:validate");if(!e.checkValidity()){t.push({elt:e,message:e.validationMessage,validity:e.validity});ce(e,"htmx:validation:failed",{message:e.validationMessage,validity:e.validity})}}}function dr(e,t){var r=[];var n={};var i={};var a=[];var o=ae(e);if(o.lastButtonClicked&&!se(o.lastButtonClicked)){o.lastButtonClicked=null}var s=h(e,"form")&&e.noValidate!==true||te(e,"hx-validate")==="true";if(o.lastButtonClicked){s=s&&o.lastButtonClicked.formNoValidate!==true}if(t!=="get"){hr(r,i,a,v(e,"form"),s)}hr(r,n,a,e,s);if(o.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){var l=o.lastButtonClicked||e;var u=ee(l,"name");cr(u,l.value,i)}var f=pe(e,"hx-include");oe(f,function(e){hr(r,n,a,e,s);if(!h(e,"form")){oe(e.querySelectorAll(rt),function(e){hr(r,n,a,e,s)})}});n=le(n,i);return{errors:a,values:n}}function gr(e,t,r){if(e!==""){e+="&"}if(String(r)==="[object Object]"){r=JSON.stringify(r)}var n=encodeURIComponent(r);e+=encodeURIComponent(t)+"="+n;return e}function mr(e){var t="";for(var r in e){if(e.hasOwnProperty(r)){var n=e[r];if(Array.isArray(n)){oe(n,function(e){t=gr(t,r,e)})}else{t=gr(t,r,n)}}}return t}function pr(e){var t=new FormData;for(var r in e){if(e.hasOwnProperty(r)){var n=e[r];if(Array.isArray(n)){oe(n,function(e){t.append(r,e)})}else{t.append(r,n)}}}return t}function xr(e,t,r){var n={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":re().location.href};Rr(e,"hx-headers",false,n);if(r!==undefined){n["HX-Prompt"]=r}if(ae(e).boosted){n["HX-Boosted"]="true"}return n}function yr(t,e){var r=ne(e,"hx-params");if(r){if(r==="none"){return{}}else if(r==="*"){return t}else if(r.indexOf("not ")===0){oe(r.substr(4).split(","),function(e){e=e.trim();delete t[e]});return t}else{var n={};oe(r.split(","),function(e){e=e.trim();n[e]=t[e]});return n}}else{return t}}function br(e){return ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function wr(e,t){var r=t?t:ne(e,"hx-swap");var n={swapStyle:ae(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(e).boosted&&!br(e)){n["show"]="top"}if(r){var i=D(r);if(i.length>0){for(var a=0;a<i.length;a++){var o=i[a];if(o.indexOf("swap:")===0){n["swapDelay"]=d(o.substr(5))}else if(o.indexOf("settle:")===0){n["settleDelay"]=d(o.substr(7))}else if(o.indexOf("transition:")===0){n["transition"]=o.substr(11)==="true"}else if(o.indexOf("ignoreTitle:")===0){n["ignoreTitle"]=o.substr(12)==="true"}else if(o.indexOf("scroll:")===0){var s=o.substr(7);var l=s.split(":");var u=l.pop();var f=l.length>0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var v=o.substr("focus-scroll:".length);n["focusScroll"]=v=="true"}else if(a==0){n["swapStyle"]=o}else{b("Unknown modifier in hx-swap: "+o)}}}}return n}function Sr(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function Er(t,r,n){var i=null;R(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(Sr(r)){return pr(n)}else{return mr(n)}}}function T(e){return{tasks:[],elts:[e]}}function Cr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ue(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ue(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Rr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=te(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=Tr(e,function(){return Function("return ("+a+")")()},{})}else{s=E(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return Rr(u(e),t,r,n)}function Tr(e,t,r){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return r}}function Or(e,t){return Rr(e,"hx-vars",true,t)}function qr(e,t){return Rr(e,"hx-vals",false,t)}function Hr(e){return le(Or(e),qr(e))}function Lr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function Ar(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(re().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Nr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||I(r,"String")){return he(e,t,null,null,{targetOverride:g(r),returnPromise:true})}else{return he(e,t,g(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:g(r.target),swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Ir(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function kr(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=s(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!n){return false}}return ce(e,"htmx:validateUrl",le({url:i,sameHost:n},r))}function he(t,r,n,i,a,e){var o=null;var s=null;a=a!=null?a:{};if(a.returnPromise&&typeof Promise!=="undefined"){var l=new Promise(function(e,t){o=e;s=t})}if(n==null){n=re().body}var M=a.handler||Mr;var X=a.select||null;if(!se(n)){ie(o);return l}var u=a.targetOverride||ye(n);if(u==null||u==me){fe(n,"htmx:targetError",{target:te(n,"hx-target")});ie(s);return l}var f=ae(n);var c=f.lastButtonClicked;if(c){var h=ee(c,"formaction");if(h!=null){r=h}var v=ee(c,"formmethod");if(v!=null){if(v.toLowerCase()!=="dialog"){t=v}}}var d=ne(n,"hx-confirm");if(e===undefined){var D=function(e){return he(t,r,n,i,a,!!e)};var U={target:u,elt:n,path:r,verb:t,triggeringEvent:i,etc:a,issueRequest:D,question:d};if(ce(n,"htmx:confirm",U)===false){ie(o);return l}}var g=n;var m=ne(n,"hx-sync");var p=null;var x=false;if(m){var B=m.split(":");var F=B[0].trim();if(F==="this"){g=xe(n,"hx-sync")}else{g=ue(n,F)}m=(B[1]||"drop").trim();f=ae(g);if(m==="drop"&&f.xhr&&f.abortable!==true){ie(o);return l}else if(m==="abort"){if(f.xhr){ie(o);return l}else{x=true}}else if(m==="replace"){ce(g,"htmx:abort")}else if(m.indexOf("queue")===0){var V=m.split(" ");p=(V[1]||"last").trim()}}if(f.xhr){if(f.abortable){ce(g,"htmx:abort")}else{if(p==null){if(i){var y=ae(i);if(y&&y.triggerSpec&&y.triggerSpec.queue){p=y.triggerSpec.queue}}if(p==null){p="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(p==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(p==="all"){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(p==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){he(t,r,n,i,a)})}ie(o);return l}}var b=new XMLHttpRequest;f.xhr=b;f.abortable=x;var w=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var j=ne(n,"hx-prompt");if(j){var S=prompt(j);if(S===null||!ce(n,"htmx:prompt",{prompt:S,target:u})){ie(o);w();return l}}if(d&&!e){if(!confirm(d)){ie(o);w();return l}}var E=xr(n,u,S);if(t!=="get"&&!Sr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(a.headers){E=le(E,a.headers)}var _=dr(n,t);var C=_.errors;var R=_.values;if(a.values){R=le(R,a.values)}var z=Hr(n);var $=le(R,z);var T=yr($,n);if(Q.config.getCacheBusterParam&&t==="get"){T["org.htmx.cache-buster"]=ee(u,"id")||"true"}if(r==null||r===""){r=re().location.href}var O=Rr(n,"hx-request");var W=ae(n).boosted;var q=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;var H={boosted:W,useUrlParams:q,parameters:T,unfilteredParameters:$,headers:E,target:u,verb:t,errors:C,withCredentials:a.credentials||O.credentials||Q.config.withCredentials,timeout:a.timeout||O.timeout||Q.config.timeout,path:r,triggeringEvent:i};if(!ce(n,"htmx:configRequest",H)){ie(o);w();return l}r=H.path;t=H.verb;E=H.headers;T=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){ce(n,"htmx:validation:halted",H);ie(o);w();return l}var G=r.split("#");var J=G[0];var L=G[1];var A=r;if(q){A=J;var Z=Object.keys(T).length!==0;if(Z){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=mr(T);if(L){A+="#"+L}}}if(!kr(n,A,H)){fe(n,"htmx:invalidPath",H);ie(s);return l}b.open(t.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(O.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var K=E[N];Lr(b,N,K)}}}var I={xhr:b,target:u,requestConfig:H,etc:a,boosted:W,select:X,pathInfo:{requestPath:r,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=Ir(n);I.pathInfo.responsePath=Ar(b);M(n,I);lr(k,P);ce(n,"htmx:afterRequest",I);ce(n,"htmx:afterOnLoad",I);if(!se(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(se(r)){t=r}}if(t){ce(t,"htmx:afterRequest",I);ce(t,"htmx:afterOnLoad",I)}}ie(o);w()}catch(e){fe(n,"htmx:onLoadError",le({error:e},I));throw e}};b.onerror=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendError",I);ie(s);w()};b.onabort=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendAbort",I);ie(s);w()};b.ontimeout=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:timeout",I);ie(s);w()};if(!ce(n,"htmx:beforeRequest",I)){ie(o);w();return l}var k=or(n);var P=sr(n);oe(["loadstart","loadend","progress","abort"],function(t){oe([b,b.upload],function(e){e.addEventListener(t,function(e){ce(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ce(n,"htmx:beforeSend",I);var Y=q?null:Er(b,n,T);b.send(Y);return l}function Pr(e,t){var r=t.xhr;var n=null;var i=null;if(O(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(O(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(O(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=ne(e,"hx-push-url");var l=ne(e,"hx-replace-url");var u=ae(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function Mr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;var h=u.select;if(!ce(l,"htmx:beforeOnLoad",u))return;if(O(f,/HX-Trigger:/i)){_e(f,"HX-Trigger",l)}if(O(f,/HX-Location:/i)){er();var r=f.getResponseHeader("HX-Location");var v;if(r.indexOf("{")===0){v=E(r);r=v["path"];delete v["path"]}Nr("GET",r,v).then(function(){tr(r)});return}var n=O(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(O(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(O(f,/HX-Retarget:/i)){if(f.getResponseHeader("HX-Retarget")==="this"){u.target=l}else{u.target=ue(l,f.getResponseHeader("HX-Retarget"))}}var d=Pr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var g=f.response;var a=f.status>=400;var m=Q.config.ignoreTitle;var o=le({shouldSwap:i,serverResponse:g,isError:a,ignoreTitle:m},u);if(!ce(c,"htmx:beforeSwap",o))return;c=o.target;g=o.serverResponse;a=o.isError;m=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){at(l)}R(l,function(e){g=e.transformResponse(g,f,l)});if(d.type){er()}var s=e.swapOverride;if(O(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var v=wr(l,s);if(v.hasOwnProperty("ignoreTitle")){m=v.ignoreTitle}c.classList.add(Q.config.swappingClass);var p=null;var x=null;var y=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(h){r=h}if(O(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}if(d.type){ce(re().body,"htmx:beforeHistoryUpdate",le({history:d},u));if(d.type==="push"){tr(d.path);ce(re().body,"htmx:pushedIntoHistory",{path:d.path})}else{rr(d.path);ce(re().body,"htmx:replacedInHistory",{path:d.path})}}var n=T(c);je(v.swapStyle,c,l,g,n,r);if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){var i=document.getElementById(ee(t.elt,"id"));var a={preventScroll:v.focusScroll!==undefined?!v.focusScroll:!Q.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Q.config.swappingClass);oe(n.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}ce(e,"htmx:afterSwap",u)});if(O(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!se(l)){o=re().body}_e(f,"HX-Trigger-After-Swap",o)}var s=function(){oe(n.tasks,function(e){e.call()});oe(n.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}ce(e,"htmx:afterSettle",u)});if(u.pathInfo.anchor){var e=re().getElementById(u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!m){var t=C("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}Cr(n.elts,v);if(O(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!se(l)){r=re().body}_e(f,"HX-Trigger-After-Settle",r)}ie(p)};if(v.settleDelay>0){setTimeout(s,v.settleDelay)}else{s()}}catch(e){fe(l,"htmx:swapError",u);ie(x);throw e}};var b=Q.config.globalViewTransitions;if(v.hasOwnProperty("transition")){b=v.transition}if(b&&ce(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var w=new Promise(function(e,t){p=e;x=t});var S=y;y=function(){document.startViewTransition(function(){S();return w})}}if(v.swapDelay>0){setTimeout(y,v.swapDelay)}else{y()}}if(a){fe(l,"htmx:responseError",le({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Xr={};function Dr(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Ur(e,t){if(t.init){t.init(r)}Xr[e]=le(Dr(),t)}function Br(e){delete Xr[e]}function Fr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=te(e,"hx-ext");if(t){oe(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Xr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Fr(u(e),r,n)}var Vr=false;re().addEventListener("DOMContentLoaded",function(){Vr=true});function jr(e){if(Vr||re().readyState==="complete"){e()}else{re().addEventListener("DOMContentLoaded",e)}}function _r(){if(Q.config.includeIndicatorStyles!==false){re().head.insertAdjacentHTML("beforeend","<style> ."+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;} </style>")}}function zr(){var e=re().querySelector('meta[name="htmx-config"]');if(e){return E(e.content)}else{return null}}function $r(){var e=zr();if(e){Q.config=le(Q.config,e)}}jr(function(){$r();_r();var e=re().body;zt(e);var t=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ae(t);if(r&&r.xhr){r.xhr.abort()}});const r=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){ar();oe(t,function(e){ce(e,"htmx:restored",{document:re(),triggerEvent:ce})})}else{if(r){r(e)}}};setTimeout(function(){ce(e,"htmx:load",{});e=null},0)});return Q}()}); \ No newline at end of file
diff --git a/web/templates/index.html b/web/templates/index.html
index 7668a94..2d35b37 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -4,182 +4,53 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Personal Dashboard</title>
- <link rel="stylesheet" href="/static/css/styles.css">
- <script src="https://cdn.tailwindcss.com"></script>
+ <link rel="stylesheet" href="/static/css/output.css">
</head>
-<body class="bg-gray-100 min-h-screen">
- <div class="container mx-auto px-4 py-8 max-w-7xl">
+<body class="min-h-screen">
+ <div class="content-max-width py-8">
<!-- Header -->
- <header class="mb-8 flex justify-between items-center">
- <h1 class="text-3xl font-bold text-gray-800">Personal Dashboard</h1>
+ <header class="mb-8 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
+ <h1 class="text-4xl font-bold text-gray-900">Personal Dashboard</h1>
<div class="flex items-center gap-4">
<span class="text-sm text-gray-600">
Last updated: <span id="last-updated">{{.LastUpdated.Format "3:04 PM"}}</span>
</span>
- <button onclick="refreshData()" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
- Refresh
+ <button onclick="refreshData()"
+ class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg transition-colors font-medium no-print">
+ <span id="refresh-text">Refresh</span>
</button>
</div>
</header>
- <!-- Error Messages -->
- {{if .Errors}}
- <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg mb-6">
- <p class="font-bold">Errors:</p>
- <ul class="list-disc list-inside">
- {{range .Errors}}
- <li>{{.}}</li>
- {{end}}
- </ul>
- </div>
- {{end}}
-
- <!-- Main Grid -->
- <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
- <!-- Trello Boards Section (PRIORITY) -->
- <div class="lg:col-span-3">
- {{if .Boards}}
- <div class="bg-white rounded-lg shadow-md p-6 mb-6">
- <h2 class="text-xl font-semibold mb-4 text-gray-800">📋 Trello Boards</h2>
- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
- {{range .Boards}}
- <div class="border border-gray-300 rounded-lg p-4 bg-gradient-to-br from-blue-50 to-white">
- <h3 class="font-bold text-lg text-gray-800 mb-3">{{.Name}}</h3>
- {{if .Cards}}
- <div class="space-y-2 max-h-96 overflow-y-auto">
- {{range .Cards}}
- <div class="bg-white border border-gray-200 rounded p-3 hover:shadow-md transition">
- <p class="font-medium text-gray-800 text-sm">{{.Name}}</p>
- {{if .ListName}}
- <span class="inline-block mt-1 text-xs bg-gray-200 text-gray-700 px-2 py-1 rounded">
- {{.ListName}}
- </span>
- {{end}}
- {{if .DueDate}}
- <span class="inline-block mt-1 text-xs bg-red-100 text-red-800 px-2 py-1 rounded">
- Due: {{.DueDate.Format "Jan 2"}}
- </span>
- {{end}}
- {{if .URL}}
- <a href="{{.URL}}" target="_blank" class="text-blue-600 hover:text-blue-800 text-xs mt-1 inline-block">
- View →
- </a>
- {{end}}
- </div>
- {{end}}
- </div>
- {{else}}
- <p class="text-gray-500 text-sm text-center py-4">No cards</p>
- {{end}}
- </div>
- {{end}}
- </div>
- </div>
- {{end}}
- </div>
-
- <!-- Tasks Section -->
- <div class="lg:col-span-2">
- <div class="bg-white rounded-lg shadow-md p-6">
- <h2 class="text-xl font-semibold mb-4 text-gray-800">✓ Todoist Tasks</h2>
-
- {{if .Tasks}}
- <div class="space-y-3">
- {{range .Tasks}}
- <div class="flex items-start gap-3 p-3 hover:bg-gray-50 rounded-lg transition">
- <input type="checkbox" {{if .Completed}}checked{{end}}
- class="mt-1 h-5 w-5 text-blue-600 rounded" disabled>
- <div class="flex-1">
- <p class="font-medium text-gray-800 {{if .Completed}}line-through text-gray-500{{end}}">
- {{.Content}}
- </p>
- {{if .Description}}
- <p class="text-sm text-gray-600 mt-1">{{.Description}}</p>
- {{end}}
- <div class="flex gap-2 mt-2 text-xs text-gray-500">
- {{if .ProjectName}}
- <span class="bg-gray-200 px-2 py-1 rounded">{{.ProjectName}}</span>
- {{end}}
- {{if .DueDate}}
- <span class="bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
- Due: {{.DueDate.Format "Jan 2"}}
- </span>
- {{end}}
- {{range .Labels}}
- <span class="bg-blue-100 text-blue-800 px-2 py-1 rounded">{{.}}</span>
- {{end}}
- </div>
- </div>
- {{if .URL}}
- <a href="{{.URL}}" target="_blank" class="text-blue-600 hover:text-blue-800">
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
- </svg>
- </a>
- {{end}}
- </div>
- {{end}}
- </div>
- {{else}}
- <p class="text-gray-500 text-center py-8">No tasks found</p>
- {{end}}
- </div>
- </div>
-
- <!-- Meals Section -->
- <div>
- <div class="bg-white rounded-lg shadow-md p-6">
- <h2 class="text-xl font-semibold mb-4 text-gray-800">Upcoming Meals</h2>
-
- {{if .Meals}}
- <div class="space-y-3">
- {{range .Meals}}
- <div class="border-l-4 border-green-500 pl-3 py-2">
- <p class="font-medium text-gray-800">{{.RecipeName}}</p>
- <div class="flex justify-between items-center mt-1">
- <span class="text-sm text-gray-600">{{.Date.Format "Mon, Jan 2"}}</span>
- <span class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded capitalize">
- {{.MealType}}
- </span>
- </div>
- </div>
- {{end}}
- </div>
- {{else}}
- <p class="text-gray-500 text-center py-8">No meals planned</p>
- {{end}}
- </div>
- </div>
+ <!-- Tab Navigation -->
+ <div class="border-b border-gray-200 mb-8 no-print">
+ <nav class="-mb-px flex space-x-8">
+ <button
+ class="tab-button tab-button-active"
+ hx-get="/tabs/tasks"
+ hx-target="#tab-content"
+ hx-push-url="false"
+ onclick="setActiveTab(this)">
+ 📋 Tasks & Planning
+ </button>
+ <button
+ class="tab-button"
+ hx-get="/tabs/notes"
+ hx-target="#tab-content"
+ hx-push-url="false"
+ onclick="setActiveTab(this)">
+ 📝 Notes
+ </button>
+ </nav>
</div>
- <!-- Notes Section -->
- {{if .Notes}}
- <div class="mt-6">
- <div class="bg-white rounded-lg shadow-md p-6">
- <h2 class="text-xl font-semibold mb-4 text-gray-800">Recent Notes</h2>
- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
- {{range .Notes}}
- <div class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition">
- <h3 class="font-semibold text-gray-800 mb-2">{{.Title}}</h3>
- <p class="text-sm text-gray-600 mb-2 line-clamp-3">{{.Content}}</p>
- <div class="flex justify-between items-center text-xs text-gray-500">
- <span>{{.Modified.Format "Jan 2, 3:04 PM"}}</span>
- {{if .Tags}}
- <div class="flex gap-1">
- {{range .Tags}}
- <span class="bg-purple-100 text-purple-800 px-2 py-1 rounded">#{{.}}</span>
- {{end}}
- </div>
- {{end}}
- </div>
- </div>
- {{end}}
- </div>
- </div>
+ <!-- Tab Content -->
+ <div id="tab-content">
+ {{template "tasks-tab" .}}
</div>
- {{end}}
</div>
+ <script src="/static/js/htmx.min.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>
diff --git a/web/templates/partials/error-banner.html b/web/templates/partials/error-banner.html
new file mode 100644
index 0000000..eb4c08d
--- /dev/null
+++ b/web/templates/partials/error-banner.html
@@ -0,0 +1,12 @@
+{{define "error-banner"}}
+{{if .Errors}}
+<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg mb-6">
+ <p class="font-bold">Errors:</p>
+ <ul class="list-disc list-inside">
+ {{range .Errors}}
+ <li>{{.}}</li>
+ {{end}}
+ </ul>
+</div>
+{{end}}
+{{end}}
diff --git a/web/templates/partials/notes-tab.html b/web/templates/partials/notes-tab.html
new file mode 100644
index 0000000..526f387
--- /dev/null
+++ b/web/templates/partials/notes-tab.html
@@ -0,0 +1,22 @@
+{{define "notes-tab"}}
+<div class="space-y-10">
+ <!-- Error Messages -->
+ {{template "error-banner" .}}
+
+ <!-- Obsidian Notes Section -->
+ {{if .Notes}}
+ {{template "obsidian-notes" .}}
+ {{else}}
+ <div class="text-center py-20">
+ <svg class="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
+ </svg>
+ <h3 class="mt-6 text-xl font-medium text-gray-900">No notes found</h3>
+ <p class="mt-2 text-sm text-gray-500 max-w-md mx-auto">
+ Configure your Obsidian vault path in the settings to see your recent notes displayed here.
+ </p>
+ </div>
+ {{end}}
+</div>
+{{end}}
diff --git a/web/templates/partials/obsidian-notes.html b/web/templates/partials/obsidian-notes.html
new file mode 100644
index 0000000..268a0fe
--- /dev/null
+++ b/web/templates/partials/obsidian-notes.html
@@ -0,0 +1,30 @@
+{{define "obsidian-notes"}}
+{{if .Notes}}
+<section class="section-spacing">
+ <!-- Section Header with Brand Color -->
+ <div class="flex items-center gap-3 mb-6">
+ <div class="w-1 h-8 bg-obsidian rounded"></div>
+ <h2 class="text-2xl font-bold text-gray-900">Recent Notes</h2>
+ </div>
+
+ <div class="card-grid">
+ {{range .Notes}}
+ <div class="note-card">
+ <h3 class="font-semibold text-gray-900 mb-2">{{.Title}}</h3>
+ <p class="text-sm text-gray-600 mb-3 line-clamp-3">{{.Content}}</p>
+ <div class="flex justify-between items-center text-xs">
+ <span class="text-gray-500">{{.Modified.Format "Jan 2, 3:04 PM"}}</span>
+ {{if .Tags}}
+ <div class="flex gap-1 flex-wrap">
+ {{range .Tags}}
+ <span class="badge bg-purple-100 text-purple-800">#{{.}}</span>
+ {{end}}
+ </div>
+ {{end}}
+ </div>
+ </div>
+ {{end}}
+ </div>
+</section>
+{{end}}
+{{end}}
diff --git a/web/templates/partials/plantoeat-meals.html b/web/templates/partials/plantoeat-meals.html
new file mode 100644
index 0000000..78e403e
--- /dev/null
+++ b/web/templates/partials/plantoeat-meals.html
@@ -0,0 +1,35 @@
+{{define "plantoeat-meals"}}
+<section class="card">
+ <!-- Section Header with Brand Color -->
+ <div class="flex items-center gap-3 mb-6">
+ <div class="w-1 h-8 bg-plantoeat rounded"></div>
+ <h2 class="text-2xl font-bold text-gray-900">Upcoming Meals</h2>
+ </div>
+
+ {{if .Meals}}
+ <div class="space-y-3">
+ {{range .Meals}}
+ <div class="border-l-4 border-plantoeat bg-green-50/50 pl-4 py-3 rounded-r-lg hover:bg-green-50 transition-colors">
+ <p class="font-medium text-gray-900">{{.RecipeName}}</p>
+ <div class="flex justify-between items-center mt-2">
+ <span class="text-sm text-gray-600">{{.Date.Format "Mon, Jan 2"}}</span>
+ <span class="badge bg-green-100 text-green-800 capitalize">
+ {{.MealType}}
+ </span>
+ </div>
+ </div>
+ {{end}}
+ </div>
+ {{else}}
+ <div class="text-center py-16">
+ <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
+ </svg>
+ <h3 class="mt-4 text-lg font-medium text-gray-900">No meals planned</h3>
+ <p class="mt-2 text-sm text-gray-500">
+ Schedule your meals to see them here.
+ </p>
+ </div>
+ {{end}}
+</section>
+{{end}}
diff --git a/web/templates/partials/tasks-tab.html b/web/templates/partials/tasks-tab.html
new file mode 100644
index 0000000..5678193
--- /dev/null
+++ b/web/templates/partials/tasks-tab.html
@@ -0,0 +1,22 @@
+{{define "tasks-tab"}}
+<div class="space-y-10">
+ <!-- Error Messages -->
+ {{template "error-banner" .}}
+
+ <!-- Trello Boards Section -->
+ {{template "trello-boards" .}}
+
+ <!-- Todoist + PlanToEat Grid -->
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
+ <!-- Todoist (2 cols) -->
+ <div class="lg:col-span-2">
+ {{template "todoist-tasks" .}}
+ </div>
+
+ <!-- PlanToEat (1 col) -->
+ <div>
+ {{template "plantoeat-meals" .}}
+ </div>
+ </div>
+</div>
+{{end}}
diff --git a/web/templates/partials/todoist-tasks.html b/web/templates/partials/todoist-tasks.html
new file mode 100644
index 0000000..7595ac7
--- /dev/null
+++ b/web/templates/partials/todoist-tasks.html
@@ -0,0 +1,58 @@
+{{define "todoist-tasks"}}
+<section class="card">
+ <!-- Section Header with Brand Color -->
+ <div class="flex items-center gap-3 mb-6">
+ <div class="w-1 h-8 bg-todoist rounded"></div>
+ <h2 class="text-2xl font-bold text-gray-900">Todoist Tasks</h2>
+ </div>
+
+ {{if .Tasks}}
+ <div class="space-y-3">
+ {{range .Tasks}}
+ <div class="task-item">
+ <input type="checkbox" {{if .Completed}}checked{{end}}
+ class="mt-1 h-5 w-5 text-todoist rounded border-gray-300" disabled>
+ <div class="flex-1">
+ <p class="font-medium text-gray-900 {{if .Completed}}line-through text-gray-500{{end}}">
+ {{.Content}}
+ </p>
+ {{if .Description}}
+ <p class="text-sm text-gray-600 mt-1">{{.Description}}</p>
+ {{end}}
+ <div class="flex flex-wrap gap-2 mt-2">
+ {{if .ProjectName}}
+ <span class="badge bg-gray-100 text-gray-700">{{.ProjectName}}</span>
+ {{end}}
+ {{if .DueDate}}
+ <span class="badge bg-yellow-100 text-yellow-800">
+ Due: {{.DueDate.Format "Jan 2"}}
+ </span>
+ {{end}}
+ {{range .Labels}}
+ <span class="badge bg-blue-100 text-blue-800">{{.}}</span>
+ {{end}}
+ </div>
+ </div>
+ {{if .URL}}
+ <a href="{{.URL}}" target="_blank" class="text-todoist hover:text-todoist/80 transition-colors">
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
+ </svg>
+ </a>
+ {{end}}
+ </div>
+ {{end}}
+ </div>
+ {{else}}
+ <div class="text-center py-16">
+ <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
+ </svg>
+ <h3 class="mt-4 text-lg font-medium text-gray-900">No tasks found</h3>
+ <p class="mt-2 text-sm text-gray-500">
+ All tasks completed or no tasks available.
+ </p>
+ </div>
+ {{end}}
+</section>
+{{end}}
diff --git a/web/templates/partials/trello-boards.html b/web/templates/partials/trello-boards.html
new file mode 100644
index 0000000..bd460cf
--- /dev/null
+++ b/web/templates/partials/trello-boards.html
@@ -0,0 +1,72 @@
+{{define "trello-boards"}}
+{{if .Boards}}
+<section class="card section-spacing">
+ <!-- Section Header with Brand Color -->
+ <div class="flex items-center gap-3 mb-6">
+ <div class="w-1 h-8 bg-trello rounded"></div>
+ <h2 class="text-2xl font-bold text-gray-900">Trello Boards</h2>
+ </div>
+
+ <!-- Active Boards Grid (boards with cards) -->
+ <div class="card-grid mb-6">
+ {{range .Boards}}
+ {{if .Cards}}
+ <div class="board-card">
+ <h3 class="font-bold text-lg text-gray-900 mb-4">{{.Name}}</h3>
+ <div class="space-y-3 max-h-96 overflow-y-auto scrollbar-thin">
+ {{range .Cards}}
+ <div class="trello-card-item">
+ <p class="font-medium text-sm text-gray-900 mb-2">{{.Name}}</p>
+ <div class="flex flex-wrap gap-2 items-center">
+ {{if .ListName}}
+ <span class="badge bg-gray-100 text-gray-700">
+ {{.ListName}}
+ </span>
+ {{end}}
+ {{if .DueDate}}
+ <span class="badge bg-red-100 text-red-800">
+ Due: {{.DueDate.Format "Jan 2"}}
+ </span>
+ {{end}}
+ {{if .URL}}
+ <a href="{{.URL}}" target="_blank"
+ class="text-trello hover:text-trello/80 text-xs font-medium ml-auto transition-colors">
+ View →
+ </a>
+ {{end}}
+ </div>
+ </div>
+ {{end}}
+ </div>
+ </div>
+ {{end}}
+ {{end}}
+ </div>
+
+ <!-- Empty Boards Collapsible (boards without cards) -->
+ <details class="mt-6 border-t border-gray-200 pt-6">
+ <summary class="cursor-pointer flex items-center justify-between group">
+ <span class="text-sm font-medium text-gray-600 group-hover:text-gray-900 transition-colors">
+ Empty Boards
+ </span>
+ <svg class="w-5 h-5 text-gray-400 group-hover:text-gray-600 transition-all"
+ fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
+ </svg>
+ </summary>
+ <div class="mt-4">
+ <div class="card-grid">
+ {{range .Boards}}
+ {{if not .Cards}}
+ <div class="border border-gray-200 rounded-lg p-4 bg-gray-50 opacity-70 hover:opacity-100 transition-opacity">
+ <h3 class="font-semibold text-gray-700">{{.Name}}</h3>
+ <p class="text-sm text-gray-500 mt-2">No cards</p>
+ </div>
+ {{end}}
+ {{end}}
+ </div>
+ </div>
+ </details>
+</section>
+{{end}}
+{{end}}