diff --git a/.golangci.yml b/.golangci.yml index 782a3da9..04ba18ca 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,15 +1,18 @@ version: "2" + linters: disable: - errcheck + settings: staticcheck: checks: - all - - -QF1003 - - -QF1008 - - -S1017 - - -SA9003 + - -SA9003 # Empty branch - sometimes used for clarity + - -QF1003 # Tagged switch suggestion - keep explicit for readability + - -QF1008 # Remove embedded field from selector - keep explicit for clarity + - -S1017 # TrimPrefix suggestion - keep explicit for readability + exclusions: generated: lax presets: @@ -21,9 +24,11 @@ linters: - third_party$ - builtin$ - examples$ + issues: max-issues-per-linter: 0 max-same-issues: 0 + formatters: exclusions: generated: lax diff --git a/Makefile b/Makefile index f56a9651..afb6ac96 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-no-restart install clean test deploy +.PHONY: build build-no-restart install clean test deploy web # Configuration SERVER ?= root@cloud-claude @@ -21,6 +21,24 @@ build-ty: build-taskd: $(GO) build -o bin/taskd ./cmd/taskd +build-taskweb: + go build -o bin/taskweb ./cmd/taskweb + +# Build web frontend +web: + cd web && npm install && npm run build + +# Build taskweb with embedded frontend +build-taskweb-full: web build-taskweb + +# Run web UI in development mode (connects to local database) +# Usage: make webdev (run API server), then in another terminal: make webui +webdev: + go run ./cmd/taskweb-dev + +webui: + cd web && npm install && npm run dev + # Restart daemon if it's running (silent if not). Never fail the build if we lack permissions. restart-daemon: @if pgrep -f "ty daemon" > /dev/null; then \ diff --git a/cmd/taskweb-dev/main.go b/cmd/taskweb-dev/main.go new file mode 100644 index 00000000..87baec84 --- /dev/null +++ b/cmd/taskweb-dev/main.go @@ -0,0 +1,60 @@ +// taskweb-dev runs the web API server locally for development. +// It connects to your local task database and serves the API on port 8081. +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/webapi" +) + +func main() { + // Use the same database as the local task CLI + dbPath := db.DefaultPath() + fmt.Printf("Using database: %s\n", dbPath) + + database, err := db.Open(dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to open database: %v\n", err) + os.Exit(1) + } + defer database.Close() + + // Create the API server with dev mode enabled + server := webapi.New(webapi.Config{ + Addr: ":8081", + DB: database, + DevMode: true, + DevOrigin: "http://localhost:5173", + }) + + // Handle graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + go func() { + <-sigCh + fmt.Println("\nShutting down...") + cancel() + }() + + fmt.Println("Starting web API server on http://localhost:8081") + fmt.Println("Frontend should run on http://localhost:5173") + fmt.Println() + fmt.Println("To start the frontend:") + fmt.Println(" cd web && npm install && npm run dev") + fmt.Println() + + if err := server.Start(ctx); err != nil { + fmt.Fprintf(os.Stderr, "Server error: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/taskweb/main.go b/cmd/taskweb/main.go new file mode 100644 index 00000000..510c18c6 --- /dev/null +++ b/cmd/taskweb/main.go @@ -0,0 +1,124 @@ +// Package main provides the entry point for the taskweb server. +// This server provides a web-based UI for taskyou with Fly Sprites integration. +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/bborn/workflow/internal/hostdb" + "github.com/bborn/workflow/internal/webserver" + "github.com/charmbracelet/log" + "github.com/spf13/cobra" +) + +var ( + addr string + dbPath string + baseURL string + secure bool + domain string +) + +func main() { + rootCmd := &cobra.Command{ + Use: "taskweb", + Short: "Web server for taskyou with Fly Sprites integration", + Long: `taskweb provides a web-based UI for taskyou. + +Each user gets their own isolated Fly Sprite running the task executor. +User data (tasks, projects) stays entirely within their sprite. +This host service handles only authentication and sprite orchestration. + +Environment variables: + TASKWEB_DATABASE_PATH - Path to host database (default: ~/.local/share/taskweb/taskweb.db) + GOOGLE_CLIENT_ID - Google OAuth client ID + GOOGLE_CLIENT_SECRET - Google OAuth client secret + GITHUB_CLIENT_ID - GitHub OAuth client ID + GITHUB_CLIENT_SECRET - GitHub OAuth client secret + SPRITES_TOKEN - Fly Sprites API token`, + Run: runServer, + } + + rootCmd.Flags().StringVarP(&addr, "addr", "a", getEnvOrDefault("TASKWEB_ADDR", ":8080"), "Server address") + rootCmd.Flags().StringVarP(&dbPath, "db", "d", hostdb.DefaultPath(), "Database path") + rootCmd.Flags().StringVar(&baseURL, "base-url", getEnvOrDefault("TASKWEB_BASE_URL", "http://localhost:8080"), "Base URL for OAuth callbacks") + rootCmd.Flags().BoolVar(&secure, "secure", getEnvOrDefault("TASKWEB_SECURE", "") == "true", "Use secure cookies (HTTPS)") + rootCmd.Flags().StringVar(&domain, "domain", getEnvOrDefault("TASKWEB_DOMAIN", ""), "Cookie domain") + + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func runServer(cmd *cobra.Command, args []string) { + logger := log.NewWithOptions(os.Stderr, log.Options{ + Prefix: "taskweb", + }) + + // Open host database + db, err := hostdb.Open(dbPath) + if err != nil { + logger.Fatal("failed to open database", "error", err) + } + defer db.Close() + + logger.Info("database opened", "path", dbPath) + + // Create server + server, err := webserver.New(webserver.Config{ + Addr: addr, + DB: db, + BaseURL: baseURL, + Secure: secure, + Domain: domain, + }) + if err != nil { + logger.Fatal("failed to create server", "error", err) + } + + // Setup context with signal handling + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigCh + logger.Info("received signal, shutting down", "signal", sig) + cancel() + }() + + // Log configuration + logger.Info("starting server", + "addr", addr, + "base_url", baseURL, + "secure", secure, + ) + + // Check OAuth configuration + if os.Getenv("GOOGLE_CLIENT_ID") == "" && os.Getenv("GITHUB_CLIENT_ID") == "" { + logger.Warn("no OAuth providers configured - authentication will not work") + } + + if os.Getenv("SPRITES_TOKEN") == "" { + logger.Warn("SPRITES_TOKEN not set - sprite management will not work") + } + + // Start server + if err := server.Start(ctx); err != nil { + logger.Fatal("server error", "error", err) + } +} + +func getEnvOrDefault(key, defaultValue string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultValue +} diff --git a/go.mod b/go.mod index 732a7d25..e3ee5fd0 100644 --- a/go.mod +++ b/go.mod @@ -11,13 +11,21 @@ require ( github.com/charmbracelet/log v0.4.1 github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 github.com/charmbracelet/wish v1.4.7 + github.com/fsnotify/fsnotify v1.9.0 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/spf13/cobra v1.10.2 + github.com/superfly/sprites-go v0.0.0-20260112234611-135551a277ef golang.org/x/crypto v0.37.0 + golang.org/x/oauth2 v0.34.0 golang.org/x/term v0.31.0 + gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.42.2 ) require ( + cloud.google.com/go/compute/metadata v0.3.0 // indirect + github.com/Masterminds/semver/v3 v3.2.1 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/atotto/clipboard v0.1.4 // indirect @@ -40,9 +48,7 @@ require ( github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect @@ -58,7 +64,6 @@ require ( github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect @@ -67,8 +72,8 @@ require ( golang.org/x/net v0.36.0 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.24.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect + nhooyr.io/websocket v1.8.17 // indirect ) diff --git a/go.sum b/go.sum index 56d0eb24..284d4793 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= @@ -83,12 +87,12 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -121,14 +125,14 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/superfly/sprites-go v0.0.0-20260112234611-135551a277ef h1:n5iTBxDcVWMq43jqeLUaG0i2xJsdOJIs8WTXOtz3NlY= +github.com/superfly/sprites-go v0.0.0-20260112234611-135551a277ef/go.mod h1:4zltGIGJa3HV+XumRyNn4BmhlavbUZH3Uh5xJNaDwsY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= @@ -145,6 +149,8 @@ golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -157,6 +163,7 @@ golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -186,3 +193,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go new file mode 100644 index 00000000..41e0df79 --- /dev/null +++ b/internal/auth/oauth.go @@ -0,0 +1,169 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "golang.org/x/oauth2" +) + +// UserInfo represents user information from OAuth providers. +type UserInfo struct { + Provider Provider + ProviderAccountID string + Email string + Name string + AvatarURL string +} + +// ExchangeCode exchanges an authorization code for tokens. +func ExchangeCode(ctx context.Context, config *Config, code string) (*oauth2.Token, error) { + token, err := config.OAuth2.Exchange(ctx, code) + if err != nil { + return nil, fmt.Errorf("exchange code: %w", err) + } + return token, nil +} + +// GetUserInfo fetches user information from the OAuth provider. +func GetUserInfo(ctx context.Context, config *Config, token *oauth2.Token) (*UserInfo, error) { + switch config.Provider { + case ProviderGoogle: + return getGoogleUserInfo(ctx, config, token) + case ProviderGitHub: + return getGitHubUserInfo(ctx, config, token) + default: + return nil, fmt.Errorf("unsupported provider: %s", config.Provider) + } +} + +// getGoogleUserInfo fetches user info from Google. +func getGoogleUserInfo(ctx context.Context, config *Config, token *oauth2.Token) (*UserInfo, error) { + client := config.OAuth2.Client(ctx, token) + + resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo") + if err != nil { + return nil, fmt.Errorf("get user info: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("get user info failed: %s - %s", resp.Status, string(body)) + } + + var data struct { + ID string `json:"id"` + Email string `json:"email"` + VerifiedEmail bool `json:"verified_email"` + Name string `json:"name"` + Picture string `json:"picture"` + } + + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + return &UserInfo{ + Provider: ProviderGoogle, + ProviderAccountID: data.ID, + Email: data.Email, + Name: data.Name, + AvatarURL: data.Picture, + }, nil +} + +// getGitHubUserInfo fetches user info from GitHub. +func getGitHubUserInfo(ctx context.Context, config *Config, token *oauth2.Token) (*UserInfo, error) { + client := config.OAuth2.Client(ctx, token) + + // Get user profile + resp, err := client.Get("https://api.github.com/user") + if err != nil { + return nil, fmt.Errorf("get user info: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("get user info failed: %s - %s", resp.Status, string(body)) + } + + var userData struct { + ID int64 `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + } + + if err := json.NewDecoder(resp.Body).Decode(&userData); err != nil { + return nil, fmt.Errorf("decode user response: %w", err) + } + + email := userData.Email + name := userData.Name + if name == "" { + name = userData.Login + } + + // If email is not in profile, fetch from emails API + if email == "" { + emailResp, err := client.Get("https://api.github.com/user/emails") + if err != nil { + return nil, fmt.Errorf("get user emails: %w", err) + } + defer emailResp.Body.Close() + + if emailResp.StatusCode == http.StatusOK { + var emails []struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` + } + + if err := json.NewDecoder(emailResp.Body).Decode(&emails); err == nil { + // Find primary verified email + for _, e := range emails { + if e.Primary && e.Verified { + email = e.Email + break + } + } + // Fallback to any verified email + if email == "" { + for _, e := range emails { + if e.Verified { + email = e.Email + break + } + } + } + } + } + } + + if email == "" { + return nil, fmt.Errorf("could not get email from GitHub") + } + + return &UserInfo{ + Provider: ProviderGitHub, + ProviderAccountID: fmt.Sprintf("%d", userData.ID), + Email: email, + Name: name, + AvatarURL: userData.AvatarURL, + }, nil +} + +// TokenExpiry returns the token expiry time, or nil if unknown. +func TokenExpiry(token *oauth2.Token) *time.Time { + if token.Expiry.IsZero() { + return nil + } + return &token.Expiry +} diff --git a/internal/auth/providers.go b/internal/auth/providers.go new file mode 100644 index 00000000..42956a67 --- /dev/null +++ b/internal/auth/providers.go @@ -0,0 +1,82 @@ +// Package auth provides OAuth2 authentication for the web UI. +package auth + +import ( + "os" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" + "golang.org/x/oauth2/google" +) + +// Provider represents an OAuth2 provider. +type Provider string + +const ( + ProviderGoogle Provider = "google" + ProviderGitHub Provider = "github" +) + +// Config holds OAuth configuration for a provider. +type Config struct { + OAuth2 *oauth2.Config + Provider Provider +} + +// GoogleConfig returns the OAuth2 config for Google. +func GoogleConfig(redirectURL string) *Config { + return &Config{ + Provider: ProviderGoogle, + OAuth2: &oauth2.Config{ + ClientID: os.Getenv("GOOGLE_CLIENT_ID"), + ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), + RedirectURL: redirectURL, + Scopes: []string{ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + }, + Endpoint: google.Endpoint, + }, + } +} + +// GitHubConfig returns the OAuth2 config for GitHub. +func GitHubConfig(redirectURL string) *Config { + return &Config{ + Provider: ProviderGitHub, + OAuth2: &oauth2.Config{ + ClientID: os.Getenv("GITHUB_CLIENT_ID"), + ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), + RedirectURL: redirectURL, + Scopes: []string{ + "user:email", + "read:user", + }, + Endpoint: github.Endpoint, + }, + } +} + +// GetConfig returns the OAuth2 config for the specified provider. +func GetConfig(provider Provider, redirectURL string) *Config { + switch provider { + case ProviderGoogle: + return GoogleConfig(redirectURL) + case ProviderGitHub: + return GitHubConfig(redirectURL) + default: + return nil + } +} + +// IsConfigured checks if a provider has valid credentials configured. +func IsConfigured(provider Provider) bool { + switch provider { + case ProviderGoogle: + return os.Getenv("GOOGLE_CLIENT_ID") != "" && os.Getenv("GOOGLE_CLIENT_SECRET") != "" + case ProviderGitHub: + return os.Getenv("GITHUB_CLIENT_ID") != "" && os.Getenv("GITHUB_CLIENT_SECRET") != "" + default: + return false + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 00000000..72b45364 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,171 @@ +package auth + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "net/http" + "time" + + "github.com/bborn/workflow/internal/hostdb" +) + +const ( + // SessionCookieName is the name of the session cookie. + SessionCookieName = "taskyou_session" + + // OAuthStateCookieName is the name of the OAuth state cookie. + OAuthStateCookieName = "taskyou_oauth_state" +) + +// SessionManager handles session creation and validation. +type SessionManager struct { + db *hostdb.DB + secure bool // Use secure cookies (HTTPS only) + domain string + maxAge time.Duration +} + +// NewSessionManager creates a new session manager. +func NewSessionManager(db *hostdb.DB, secure bool, domain string) *SessionManager { + return &SessionManager{ + db: db, + secure: secure, + domain: domain, + maxAge: hostdb.DefaultSessionDuration, + } +} + +// CreateSession creates a new session and sets the session cookie. +func (sm *SessionManager) CreateSession(w http.ResponseWriter, userID string) (*hostdb.Session, error) { + session, err := sm.db.CreateSession(userID, sm.maxAge) + if err != nil { + return nil, fmt.Errorf("create session: %w", err) + } + + sm.setSessionCookie(w, session.ID, session.ExpiresAt) + return session, nil +} + +// GetSession retrieves the session from the request cookie. +// Returns nil if no valid session exists. +func (sm *SessionManager) GetSession(r *http.Request) (*hostdb.Session, error) { + cookie, err := r.Cookie(SessionCookieName) + if err != nil { + return nil, nil // No cookie, no session + } + + session, err := sm.db.GetSession(cookie.Value) + if err != nil { + return nil, fmt.Errorf("get session: %w", err) + } + + return session, nil +} + +// GetSessionWithUser retrieves the session and user from the request cookie. +// Returns nil for both if no valid session exists. +func (sm *SessionManager) GetSessionWithUser(r *http.Request) (*hostdb.Session, *hostdb.User, error) { + cookie, err := r.Cookie(SessionCookieName) + if err != nil { + return nil, nil, nil // No cookie, no session + } + + session, user, err := sm.db.GetSessionWithUser(cookie.Value) + if err != nil { + return nil, nil, fmt.Errorf("get session with user: %w", err) + } + + return session, user, nil +} + +// DeleteSession deletes the session and clears the cookie. +func (sm *SessionManager) DeleteSession(w http.ResponseWriter, r *http.Request) error { + cookie, err := r.Cookie(SessionCookieName) + if err != nil { + return nil // No cookie to delete + } + + if err := sm.db.DeleteSession(cookie.Value); err != nil { + return fmt.Errorf("delete session: %w", err) + } + + sm.clearSessionCookie(w) + return nil +} + +// setSessionCookie sets the session cookie. +func (sm *SessionManager) setSessionCookie(w http.ResponseWriter, sessionID string, expiresAt time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: sessionID, + Path: "/", + Domain: sm.domain, + Expires: expiresAt, + MaxAge: int(time.Until(expiresAt).Seconds()), + HttpOnly: true, + Secure: sm.secure, + SameSite: http.SameSiteLaxMode, // Lax for OAuth redirects to work + }) +} + +// clearSessionCookie clears the session cookie. +func (sm *SessionManager) clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: "", + Path: "/", + Domain: sm.domain, + Expires: time.Unix(0, 0), + MaxAge: -1, + HttpOnly: true, + Secure: sm.secure, + SameSite: http.SameSiteLaxMode, + }) +} + +// GenerateOAuthState generates a random state string for OAuth. +func GenerateOAuthState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate random bytes: %w", err) + } + return base64.URLEncoding.EncodeToString(b), nil +} + +// SetOAuthStateCookie sets the OAuth state cookie. +func (sm *SessionManager) SetOAuthStateCookie(w http.ResponseWriter, state string) { + http.SetCookie(w, &http.Cookie{ + Name: OAuthStateCookieName, + Value: state, + Path: "/", + Domain: sm.domain, + MaxAge: 600, // 10 minutes + HttpOnly: true, + Secure: sm.secure, + SameSite: http.SameSiteLaxMode, + }) +} + +// GetOAuthStateCookie retrieves and clears the OAuth state cookie. +func (sm *SessionManager) GetOAuthStateCookie(w http.ResponseWriter, r *http.Request) string { + cookie, err := r.Cookie(OAuthStateCookieName) + if err != nil { + return "" + } + + // Clear the cookie + http.SetCookie(w, &http.Cookie{ + Name: OAuthStateCookieName, + Value: "", + Path: "/", + Domain: sm.domain, + Expires: time.Unix(0, 0), + MaxAge: -1, + HttpOnly: true, + Secure: sm.secure, + SameSite: http.SameSiteLaxMode, + }) + + return cookie.Value +} diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 1afb38c1..6e71e261 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -500,6 +500,18 @@ func (db *DB) UpdateTaskPaneIDs(taskID int64, claudePaneID, shellPaneID string) return nil } +// UpdateTaskSchedule updates the scheduled_at, recurrence, and last_run_at for a task. +func (db *DB) UpdateTaskSchedule(taskID int64, scheduledAt *LocalTime, recurrence string, lastRunAt *LocalTime) error { + _, err := db.Exec(` + UPDATE tasks SET scheduled_at = ?, recurrence = ?, last_run_at = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, scheduledAt, recurrence, lastRunAt, taskID) + if err != nil { + return fmt.Errorf("update task schedule: %w", err) + } + return nil +} + // DeleteTask deletes a task. func (db *DB) DeleteTask(id int64) error { _, err := db.Exec("DELETE FROM tasks WHERE id = ?", id) diff --git a/internal/hostdb/db.go b/internal/hostdb/db.go new file mode 100644 index 00000000..3747636b --- /dev/null +++ b/internal/hostdb/db.go @@ -0,0 +1,135 @@ +// Package hostdb provides SQLite database operations for the web host service. +// This database stores only authentication data, user accounts, and sprite mappings. +// User task data is stored separately in each user's Fly Sprite. +package hostdb + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +// DB wraps the SQLite database connection for the host database. +type DB struct { + *sql.DB + path string +} + +// Path returns the path to the database file. +func (db *DB) Path() string { + return db.path +} + +// Open opens or creates a SQLite database at the given path. +func Open(path string) (*DB, error) { + // Ensure directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("create db directory: %w", err) + } + + // Add busy timeout to handle concurrent access + dsn := path + "?_busy_timeout=5000" + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + // Enable WAL mode for better concurrent access + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + return nil, fmt.Errorf("enable WAL: %w", err) + } + + // Enable foreign keys + if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { + return nil, fmt.Errorf("enable foreign keys: %w", err) + } + + wrapped := &DB{DB: db, path: path} + + // Run migrations + if err := wrapped.migrate(); err != nil { + return nil, fmt.Errorf("migrate: %w", err) + } + + return wrapped, nil +} + +// migrate runs database migrations. +func (db *DB) migrate() error { + migrations := []string{ + // Users table - authenticated users + `CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT DEFAULT '', + avatar_url TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + + // OAuth accounts linked to users + `CREATE TABLE IF NOT EXISTS oauth_accounts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_account_id TEXT NOT NULL, + access_token TEXT DEFAULT '', + refresh_token TEXT DEFAULT '', + expires_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(provider, provider_account_id) + )`, + + // Sprite instances per user + `CREATE TABLE IF NOT EXISTS sprites ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + sprite_name TEXT NOT NULL UNIQUE, + status TEXT DEFAULT 'pending', + region TEXT DEFAULT 'ord', + ssh_public_key TEXT DEFAULT '', + last_checkpoint TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + + // Sessions for cookie-based session management + `CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + + // Indexes + `CREATE INDEX IF NOT EXISTS idx_oauth_accounts_user ON oauth_accounts(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_oauth_accounts_provider ON oauth_accounts(provider, provider_account_id)`, + `CREATE INDEX IF NOT EXISTS idx_sprites_user ON sprites(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)`, + } + + for _, m := range migrations { + if _, err := db.Exec(m); err != nil { + return fmt.Errorf("migration failed: %w\nSQL: %s", err, m) + } + } + + return nil +} + +// DefaultPath returns the default database path for the host service. +func DefaultPath() string { + // Check for explicit path + if p := os.Getenv("TASKWEB_DATABASE_PATH"); p != "" { + return p + } + + // Default to ~/.local/share/taskweb/taskweb.db + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "share", "taskweb", "taskweb.db") +} diff --git a/internal/hostdb/db_test.go b/internal/hostdb/db_test.go new file mode 100644 index 00000000..0d898522 --- /dev/null +++ b/internal/hostdb/db_test.go @@ -0,0 +1,245 @@ +package hostdb + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func setupTestDB(t *testing.T) (*DB, func()) { + tmpDir, err := os.MkdirTemp("", "hostdb-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + dbPath := filepath.Join(tmpDir, "test.db") + db, err := Open(dbPath) + if err != nil { + os.RemoveAll(tmpDir) + t.Fatalf("failed to open database: %v", err) + } + + cleanup := func() { + db.Close() + os.RemoveAll(tmpDir) + } + + return db, cleanup +} + +func TestCreateAndGetUser(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + // Create user + user, err := db.CreateUser("test@example.com", "Test User", "https://example.com/avatar.png") + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + if user.Email != "test@example.com" { + t.Errorf("expected email test@example.com, got %s", user.Email) + } + if user.Name != "Test User" { + t.Errorf("expected name Test User, got %s", user.Name) + } + + // Get by ID + retrieved, err := db.GetUserByID(user.ID) + if err != nil { + t.Fatalf("failed to get user by ID: %v", err) + } + if retrieved == nil { + t.Fatal("expected user, got nil") + } + if retrieved.Email != user.Email { + t.Errorf("expected email %s, got %s", user.Email, retrieved.Email) + } + + // Get by email + retrieved, err = db.GetUserByEmail("test@example.com") + if err != nil { + t.Fatalf("failed to get user by email: %v", err) + } + if retrieved == nil { + t.Fatal("expected user, got nil") + } +} + +func TestGetOrCreateUserByOAuth(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + expiresAt := time.Now().Add(time.Hour) + + // First call should create user + user1, isNew1, err := db.GetOrCreateUserByOAuth( + "google", "12345", "test@example.com", "Test User", "https://example.com/avatar.png", + "access_token", "refresh_token", &expiresAt, + ) + if err != nil { + t.Fatalf("failed to get or create user: %v", err) + } + if !isNew1 { + t.Error("expected new user to be created") + } + if user1.Email != "test@example.com" { + t.Errorf("expected email test@example.com, got %s", user1.Email) + } + + // Second call with same OAuth should return existing user + user2, isNew2, err := db.GetOrCreateUserByOAuth( + "google", "12345", "test@example.com", "Updated Name", "https://example.com/new-avatar.png", + "new_access_token", "new_refresh_token", &expiresAt, + ) + if err != nil { + t.Fatalf("failed to get or create user: %v", err) + } + if isNew2 { + t.Error("expected existing user, not new") + } + if user2.ID != user1.ID { + t.Errorf("expected same user ID %s, got %s", user1.ID, user2.ID) + } + // Name should be updated + if user2.Name != "Updated Name" { + t.Errorf("expected updated name, got %s", user2.Name) + } +} + +func TestCreateAndGetSprite(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + // Create user first + user, err := db.CreateUser("test@example.com", "Test User", "") + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + // Create sprite + sprite, err := db.CreateSprite(user.ID, "taskyou-abc123", "ord") + if err != nil { + t.Fatalf("failed to create sprite: %v", err) + } + + if sprite.SpriteName != "taskyou-abc123" { + t.Errorf("expected sprite name taskyou-abc123, got %s", sprite.SpriteName) + } + if sprite.Status != SpriteStatusPending { + t.Errorf("expected status pending, got %s", sprite.Status) + } + + // Get by user + retrieved, err := db.GetSpriteByUser(user.ID) + if err != nil { + t.Fatalf("failed to get sprite by user: %v", err) + } + if retrieved == nil { + t.Fatal("expected sprite, got nil") + } + if retrieved.ID != sprite.ID { + t.Errorf("expected sprite ID %s, got %s", sprite.ID, retrieved.ID) + } + + // Update status + err = db.UpdateSpriteStatus(sprite.ID, SpriteStatusRunning) + if err != nil { + t.Fatalf("failed to update sprite status: %v", err) + } + + retrieved, _ = db.GetSpriteByID(sprite.ID) + if retrieved.Status != SpriteStatusRunning { + t.Errorf("expected status running, got %s", retrieved.Status) + } +} + +func TestSessions(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + // Create user + user, err := db.CreateUser("test@example.com", "Test User", "") + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + // Create session + session, err := db.CreateSession(user.ID, time.Hour) + if err != nil { + t.Fatalf("failed to create session: %v", err) + } + + if session.UserID != user.ID { + t.Errorf("expected user ID %s, got %s", user.ID, session.UserID) + } + + // Get session + retrieved, err := db.GetSession(session.ID) + if err != nil { + t.Fatalf("failed to get session: %v", err) + } + if retrieved == nil { + t.Fatal("expected session, got nil") + } + + // Get session with user + sess, usr, err := db.GetSessionWithUser(session.ID) + if err != nil { + t.Fatalf("failed to get session with user: %v", err) + } + if sess == nil || usr == nil { + t.Fatal("expected session and user") + } + if usr.ID != user.ID { + t.Errorf("expected user ID %s, got %s", user.ID, usr.ID) + } + + // Delete session + err = db.DeleteSession(session.ID) + if err != nil { + t.Fatalf("failed to delete session: %v", err) + } + + // Verify deleted + retrieved, _ = db.GetSession(session.ID) + if retrieved != nil { + t.Error("expected session to be deleted") + } +} + +func TestExpiredSession(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + // Create user + user, err := db.CreateUser("test@example.com", "Test User", "") + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + // Create already-expired session + session, err := db.CreateSession(user.ID, -time.Hour) // Negative duration = already expired + if err != nil { + t.Fatalf("failed to create session: %v", err) + } + + // Should not find expired session + retrieved, err := db.GetSession(session.ID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if retrieved != nil { + t.Error("expected nil for expired session") + } + + // Cleanup expired sessions + count, err := db.CleanupExpiredSessions() + if err != nil { + t.Fatalf("failed to cleanup sessions: %v", err) + } + if count != 1 { + t.Errorf("expected 1 expired session cleaned up, got %d", count) + } +} diff --git a/internal/hostdb/sessions.go b/internal/hostdb/sessions.go new file mode 100644 index 00000000..341a90e9 --- /dev/null +++ b/internal/hostdb/sessions.go @@ -0,0 +1,132 @@ +package hostdb + +import ( + "crypto/rand" + "database/sql" + "encoding/base64" + "fmt" + "time" +) + +// Session represents an authenticated user session. +type Session struct { + ID string + UserID string + ExpiresAt time.Time + CreatedAt time.Time +} + +// DefaultSessionDuration is the default duration for sessions. +const DefaultSessionDuration = 30 * 24 * time.Hour // 30 days + +// generateSessionID generates a cryptographically secure session ID. +func generateSessionID() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate random bytes: %w", err) + } + return base64.URLEncoding.EncodeToString(b), nil +} + +// CreateSession creates a new session for a user. +func (db *DB) CreateSession(userID string, duration time.Duration) (*Session, error) { + id, err := generateSessionID() + if err != nil { + return nil, fmt.Errorf("generate session id: %w", err) + } + + now := time.Now() + expiresAt := now.Add(duration) + + _, err = db.Exec(` + INSERT INTO sessions (id, user_id, expires_at, created_at) + VALUES (?, ?, ?, ?) + `, id, userID, expiresAt, now) + if err != nil { + return nil, fmt.Errorf("insert session: %w", err) + } + + return &Session{ + ID: id, + UserID: userID, + ExpiresAt: expiresAt, + CreatedAt: now, + }, nil +} + +// GetSession retrieves a session by its ID. +// Returns nil if the session doesn't exist or has expired. +func (db *DB) GetSession(id string) (*Session, error) { + var s Session + err := db.QueryRow(` + SELECT id, user_id, expires_at, created_at + FROM sessions WHERE id = ? AND expires_at > ? + `, id, time.Now()).Scan(&s.ID, &s.UserID, &s.ExpiresAt, &s.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query session: %w", err) + } + return &s, nil +} + +// GetSessionWithUser retrieves a session and its associated user. +// Returns nil for both if the session doesn't exist or has expired. +func (db *DB) GetSessionWithUser(sessionID string) (*Session, *User, error) { + session, err := db.GetSession(sessionID) + if err != nil { + return nil, nil, fmt.Errorf("get session: %w", err) + } + if session == nil { + return nil, nil, nil + } + + user, err := db.GetUserByID(session.UserID) + if err != nil { + return nil, nil, fmt.Errorf("get user: %w", err) + } + + return session, user, nil +} + +// DeleteSession deletes a session. +func (db *DB) DeleteSession(id string) error { + _, err := db.Exec(`DELETE FROM sessions WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete session: %w", err) + } + return nil +} + +// DeleteUserSessions deletes all sessions for a user. +func (db *DB) DeleteUserSessions(userID string) error { + _, err := db.Exec(`DELETE FROM sessions WHERE user_id = ?`, userID) + if err != nil { + return fmt.Errorf("delete user sessions: %w", err) + } + return nil +} + +// CleanupExpiredSessions removes all expired sessions from the database. +func (db *DB) CleanupExpiredSessions() (int64, error) { + result, err := db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, time.Now()) + if err != nil { + return 0, fmt.Errorf("delete expired sessions: %w", err) + } + count, _ := result.RowsAffected() + return count, nil +} + +// ExtendSession extends the expiration of a session. +func (db *DB) ExtendSession(id string, duration time.Duration) error { + expiresAt := time.Now().Add(duration) + _, err := db.Exec(` + UPDATE sessions SET expires_at = ? + WHERE id = ? + `, expiresAt, id) + if err != nil { + return fmt.Errorf("extend session: %w", err) + } + return nil +} diff --git a/internal/hostdb/sprites.go b/internal/hostdb/sprites.go new file mode 100644 index 00000000..561e992f --- /dev/null +++ b/internal/hostdb/sprites.go @@ -0,0 +1,197 @@ +package hostdb + +import ( + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" +) + +// SpriteStatus represents the status of a sprite instance. +type SpriteStatus string + +const ( + SpriteStatusPending SpriteStatus = "pending" + SpriteStatusCreating SpriteStatus = "creating" + SpriteStatusRunning SpriteStatus = "running" + SpriteStatusStopped SpriteStatus = "stopped" + SpriteStatusError SpriteStatus = "error" +) + +// Sprite represents a Fly Sprite instance for a user. +type Sprite struct { + ID string + UserID string + SpriteName string // Fly sprite name (e.g., "taskyou-abc123") + Status SpriteStatus + Region string // Fly region (e.g., "ord") + SSHPublicKey string + LastCheckpoint string // Last checkpoint ID for restore + CreatedAt time.Time + UpdatedAt time.Time +} + +// CreateSprite creates a new sprite record for a user. +func (db *DB) CreateSprite(userID, spriteName, region string) (*Sprite, error) { + id := uuid.New().String() + now := time.Now() + + _, err := db.Exec(` + INSERT INTO sprites (id, user_id, sprite_name, status, region, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, id, userID, spriteName, SpriteStatusPending, region, now, now) + if err != nil { + return nil, fmt.Errorf("insert sprite: %w", err) + } + + return &Sprite{ + ID: id, + UserID: userID, + SpriteName: spriteName, + Status: SpriteStatusPending, + Region: region, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +// GetSpriteByID retrieves a sprite by its ID. +func (db *DB) GetSpriteByID(id string) (*Sprite, error) { + var s Sprite + err := db.QueryRow(` + SELECT id, user_id, sprite_name, status, region, ssh_public_key, last_checkpoint, created_at, updated_at + FROM sprites WHERE id = ? + `, id).Scan(&s.ID, &s.UserID, &s.SpriteName, &s.Status, &s.Region, &s.SSHPublicKey, &s.LastCheckpoint, &s.CreatedAt, &s.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query sprite: %w", err) + } + return &s, nil +} + +// GetSpriteByUser retrieves the sprite for a user. +// Each user has at most one sprite. +func (db *DB) GetSpriteByUser(userID string) (*Sprite, error) { + var s Sprite + err := db.QueryRow(` + SELECT id, user_id, sprite_name, status, region, ssh_public_key, last_checkpoint, created_at, updated_at + FROM sprites WHERE user_id = ? + `, userID).Scan(&s.ID, &s.UserID, &s.SpriteName, &s.Status, &s.Region, &s.SSHPublicKey, &s.LastCheckpoint, &s.CreatedAt, &s.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query sprite: %w", err) + } + return &s, nil +} + +// GetSpriteByName retrieves a sprite by its Fly sprite name. +func (db *DB) GetSpriteByName(spriteName string) (*Sprite, error) { + var s Sprite + err := db.QueryRow(` + SELECT id, user_id, sprite_name, status, region, ssh_public_key, last_checkpoint, created_at, updated_at + FROM sprites WHERE sprite_name = ? + `, spriteName).Scan(&s.ID, &s.UserID, &s.SpriteName, &s.Status, &s.Region, &s.SSHPublicKey, &s.LastCheckpoint, &s.CreatedAt, &s.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query sprite: %w", err) + } + return &s, nil +} + +// UpdateSpriteStatus updates the status of a sprite. +func (db *DB) UpdateSpriteStatus(id string, status SpriteStatus) error { + _, err := db.Exec(` + UPDATE sprites SET status = ?, updated_at = ? + WHERE id = ? + `, status, time.Now(), id) + if err != nil { + return fmt.Errorf("update sprite status: %w", err) + } + return nil +} + +// UpdateSpriteSSHKey updates the SSH public key for a sprite. +func (db *DB) UpdateSpriteSSHKey(id, sshPublicKey string) error { + _, err := db.Exec(` + UPDATE sprites SET ssh_public_key = ?, updated_at = ? + WHERE id = ? + `, sshPublicKey, time.Now(), id) + if err != nil { + return fmt.Errorf("update sprite ssh key: %w", err) + } + return nil +} + +// UpdateSpriteCheckpoint updates the last checkpoint ID for a sprite. +func (db *DB) UpdateSpriteCheckpoint(id, checkpointID string) error { + _, err := db.Exec(` + UPDATE sprites SET last_checkpoint = ?, updated_at = ? + WHERE id = ? + `, checkpointID, time.Now(), id) + if err != nil { + return fmt.Errorf("update sprite checkpoint: %w", err) + } + return nil +} + +// DeleteSprite deletes a sprite record. +func (db *DB) DeleteSprite(id string) error { + _, err := db.Exec(`DELETE FROM sprites WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete sprite: %w", err) + } + return nil +} + +// ListSprites retrieves all sprites. +func (db *DB) ListSprites() ([]*Sprite, error) { + rows, err := db.Query(` + SELECT id, user_id, sprite_name, status, region, ssh_public_key, last_checkpoint, created_at, updated_at + FROM sprites ORDER BY created_at DESC + `) + if err != nil { + return nil, fmt.Errorf("query sprites: %w", err) + } + defer rows.Close() + + var sprites []*Sprite + for rows.Next() { + var s Sprite + if err := rows.Scan(&s.ID, &s.UserID, &s.SpriteName, &s.Status, &s.Region, &s.SSHPublicKey, &s.LastCheckpoint, &s.CreatedAt, &s.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan sprite: %w", err) + } + sprites = append(sprites, &s) + } + + return sprites, nil +} + +// ListSpritesByStatus retrieves all sprites with a specific status. +func (db *DB) ListSpritesByStatus(status SpriteStatus) ([]*Sprite, error) { + rows, err := db.Query(` + SELECT id, user_id, sprite_name, status, region, ssh_public_key, last_checkpoint, created_at, updated_at + FROM sprites WHERE status = ? ORDER BY created_at DESC + `, status) + if err != nil { + return nil, fmt.Errorf("query sprites: %w", err) + } + defer rows.Close() + + var sprites []*Sprite + for rows.Next() { + var s Sprite + if err := rows.Scan(&s.ID, &s.UserID, &s.SpriteName, &s.Status, &s.Region, &s.SSHPublicKey, &s.LastCheckpoint, &s.CreatedAt, &s.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan sprite: %w", err) + } + sprites = append(sprites, &s) + } + + return sprites, nil +} diff --git a/internal/hostdb/users.go b/internal/hostdb/users.go new file mode 100644 index 00000000..d4aa14fa --- /dev/null +++ b/internal/hostdb/users.go @@ -0,0 +1,265 @@ +package hostdb + +import ( + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" +) + +// User represents an authenticated user. +type User struct { + ID string + Email string + Name string + AvatarURL string + CreatedAt time.Time + UpdatedAt time.Time +} + +// OAuthAccount represents a linked OAuth provider account. +type OAuthAccount struct { + ID string + UserID string + Provider string // "google", "github" + ProviderAccountID string + AccessToken string + RefreshToken string + ExpiresAt *time.Time + CreatedAt time.Time +} + +// CreateUser creates a new user and returns the created user. +func (db *DB) CreateUser(email, name, avatarURL string) (*User, error) { + id := uuid.New().String() + now := time.Now() + + _, err := db.Exec(` + INSERT INTO users (id, email, name, avatar_url, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `, id, email, name, avatarURL, now, now) + if err != nil { + return nil, fmt.Errorf("insert user: %w", err) + } + + return &User{ + ID: id, + Email: email, + Name: name, + AvatarURL: avatarURL, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +// GetUserByID retrieves a user by their ID. +func (db *DB) GetUserByID(id string) (*User, error) { + var u User + err := db.QueryRow(` + SELECT id, email, name, avatar_url, created_at, updated_at + FROM users WHERE id = ? + `, id).Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt, &u.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query user: %w", err) + } + return &u, nil +} + +// GetUserByEmail retrieves a user by their email. +func (db *DB) GetUserByEmail(email string) (*User, error) { + var u User + err := db.QueryRow(` + SELECT id, email, name, avatar_url, created_at, updated_at + FROM users WHERE email = ? + `, email).Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt, &u.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query user: %w", err) + } + return &u, nil +} + +// UpdateUser updates a user's profile information. +func (db *DB) UpdateUser(id, name, avatarURL string) error { + _, err := db.Exec(` + UPDATE users SET name = ?, avatar_url = ?, updated_at = ? + WHERE id = ? + `, name, avatarURL, time.Now(), id) + if err != nil { + return fmt.Errorf("update user: %w", err) + } + return nil +} + +// DeleteUser deletes a user and all associated data. +func (db *DB) DeleteUser(id string) error { + _, err := db.Exec(`DELETE FROM users WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete user: %w", err) + } + return nil +} + +// CreateOAuthAccount creates a new OAuth account link for a user. +func (db *DB) CreateOAuthAccount(userID, provider, providerAccountID, accessToken, refreshToken string, expiresAt *time.Time) (*OAuthAccount, error) { + id := uuid.New().String() + now := time.Now() + + _, err := db.Exec(` + INSERT INTO oauth_accounts (id, user_id, provider, provider_account_id, access_token, refresh_token, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, id, userID, provider, providerAccountID, accessToken, refreshToken, expiresAt, now) + if err != nil { + return nil, fmt.Errorf("insert oauth account: %w", err) + } + + return &OAuthAccount{ + ID: id, + UserID: userID, + Provider: provider, + ProviderAccountID: providerAccountID, + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresAt: expiresAt, + CreatedAt: now, + }, nil +} + +// GetOAuthAccount retrieves an OAuth account by provider and provider account ID. +func (db *DB) GetOAuthAccount(provider, providerAccountID string) (*OAuthAccount, error) { + var oa OAuthAccount + var expiresAt sql.NullTime + + err := db.QueryRow(` + SELECT id, user_id, provider, provider_account_id, access_token, refresh_token, expires_at, created_at + FROM oauth_accounts WHERE provider = ? AND provider_account_id = ? + `, provider, providerAccountID).Scan( + &oa.ID, &oa.UserID, &oa.Provider, &oa.ProviderAccountID, + &oa.AccessToken, &oa.RefreshToken, &expiresAt, &oa.CreatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query oauth account: %w", err) + } + + if expiresAt.Valid { + oa.ExpiresAt = &expiresAt.Time + } + + return &oa, nil +} + +// UpdateOAuthTokens updates the tokens for an OAuth account. +func (db *DB) UpdateOAuthTokens(id, accessToken, refreshToken string, expiresAt *time.Time) error { + _, err := db.Exec(` + UPDATE oauth_accounts SET access_token = ?, refresh_token = ?, expires_at = ? + WHERE id = ? + `, accessToken, refreshToken, expiresAt, id) + if err != nil { + return fmt.Errorf("update oauth tokens: %w", err) + } + return nil +} + +// GetOAuthAccountsByUser retrieves all OAuth accounts for a user. +func (db *DB) GetOAuthAccountsByUser(userID string) ([]*OAuthAccount, error) { + rows, err := db.Query(` + SELECT id, user_id, provider, provider_account_id, access_token, refresh_token, expires_at, created_at + FROM oauth_accounts WHERE user_id = ? + `, userID) + if err != nil { + return nil, fmt.Errorf("query oauth accounts: %w", err) + } + defer rows.Close() + + var accounts []*OAuthAccount + for rows.Next() { + var oa OAuthAccount + var expiresAt sql.NullTime + + if err := rows.Scan( + &oa.ID, &oa.UserID, &oa.Provider, &oa.ProviderAccountID, + &oa.AccessToken, &oa.RefreshToken, &expiresAt, &oa.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("scan oauth account: %w", err) + } + + if expiresAt.Valid { + oa.ExpiresAt = &expiresAt.Time + } + + accounts = append(accounts, &oa) + } + + return accounts, nil +} + +// GetOrCreateUserByOAuth gets or creates a user based on OAuth login. +// Returns the user and a boolean indicating if the user was newly created. +func (db *DB) GetOrCreateUserByOAuth(provider, providerAccountID, email, name, avatarURL, accessToken, refreshToken string, expiresAt *time.Time) (*User, bool, error) { + // Check if OAuth account already exists + oa, err := db.GetOAuthAccount(provider, providerAccountID) + if err != nil { + return nil, false, fmt.Errorf("get oauth account: %w", err) + } + + if oa != nil { + // Account exists, update tokens and return user + if err := db.UpdateOAuthTokens(oa.ID, accessToken, refreshToken, expiresAt); err != nil { + return nil, false, fmt.Errorf("update oauth tokens: %w", err) + } + + user, err := db.GetUserByID(oa.UserID) + if err != nil { + return nil, false, fmt.Errorf("get user: %w", err) + } + + // Update user profile if changed + if user.Name != name || user.AvatarURL != avatarURL { + if err := db.UpdateUser(user.ID, name, avatarURL); err != nil { + return nil, false, fmt.Errorf("update user: %w", err) + } + user.Name = name + user.AvatarURL = avatarURL + } + + return user, false, nil + } + + // Check if user with this email already exists + user, err := db.GetUserByEmail(email) + if err != nil { + return nil, false, fmt.Errorf("get user by email: %w", err) + } + + if user != nil { + // User exists, link OAuth account + _, err = db.CreateOAuthAccount(user.ID, provider, providerAccountID, accessToken, refreshToken, expiresAt) + if err != nil { + return nil, false, fmt.Errorf("create oauth account: %w", err) + } + return user, false, nil + } + + // Create new user + user, err = db.CreateUser(email, name, avatarURL) + if err != nil { + return nil, false, fmt.Errorf("create user: %w", err) + } + + // Create OAuth account link + _, err = db.CreateOAuthAccount(user.ID, provider, providerAccountID, accessToken, refreshToken, expiresAt) + if err != nil { + return nil, false, fmt.Errorf("create oauth account: %w", err) + } + + return user, true, nil +} diff --git a/internal/sprite/client.go b/internal/sprite/client.go new file mode 100644 index 00000000..6f9114cd --- /dev/null +++ b/internal/sprite/client.go @@ -0,0 +1,115 @@ +// Package sprite provides Fly Sprites integration for isolated user environments. +package sprite + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + + sprites "github.com/superfly/sprites-go" +) + +// Client wraps the Fly Sprites SDK client. +type Client struct { + client *sprites.Client +} + +// NewClient creates a new Sprites client. +// Uses SPRITES_TOKEN environment variable for authentication. +func NewClient() (*Client, error) { + token := os.Getenv("SPRITES_TOKEN") + if token == "" { + return nil, fmt.Errorf("SPRITES_TOKEN environment variable not set") + } + + client := sprites.New(token) + return &Client{client: client}, nil +} + +// NewClientWithToken creates a new Sprites client with an explicit token. +func NewClientWithToken(token string) *Client { + return &Client{client: sprites.New(token)} +} + +// Sprite returns a handle to a specific sprite. +func (c *Client) Sprite(name string) *sprites.Sprite { + return c.client.Sprite(name) +} + +// CreateSprite creates a new sprite with the given name. +func (c *Client) CreateSprite(ctx context.Context, name string, opts *CreateOptions) error { + sprite := c.client.Sprite(name) + + // Start the sprite (this creates it if it doesn't exist) + cmd := sprite.CommandContext(ctx, "true") // Just run true to initialize + if err := cmd.Run(); err != nil { + return fmt.Errorf("create sprite: %w", err) + } + + return nil +} + +// CreateOptions contains options for creating a sprite. +type CreateOptions struct { + Region string +} + +// DestroySprite destroys a sprite. +func (c *Client) DestroySprite(ctx context.Context, name string) error { + // The sprites-go SDK handles destruction through the API + // For now, we'll use a direct HTTP call since the SDK may not expose this + req, err := http.NewRequestWithContext(ctx, "DELETE", fmt.Sprintf("https://api.sprites.dev/v1/sprites/%s", name), nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+os.Getenv("SPRITES_TOKEN")) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("destroy sprite: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("destroy sprite failed: %s - %s", resp.Status, string(body)) + } + + return nil +} + +// RunCommand runs a command on a sprite and returns its output. +func (c *Client) RunCommand(ctx context.Context, name string, command string, args ...string) ([]byte, error) { + sprite := c.client.Sprite(name) + cmd := sprite.CommandContext(ctx, command, args...) + return cmd.Output() +} + +// RunCommandCombined runs a command on a sprite and returns combined stdout/stderr. +func (c *Client) RunCommandCombined(ctx context.Context, name string, command string, args ...string) ([]byte, error) { + sprite := c.client.Sprite(name) + cmd := sprite.CommandContext(ctx, command, args...) + return cmd.CombinedOutput() +} + +// StartCommand starts a command on a sprite without waiting for completion. +func (c *Client) StartCommand(ctx context.Context, name string, command string, args ...string) error { + sprite := c.client.Sprite(name) + cmd := sprite.CommandContext(ctx, command, args...) + return cmd.Start() +} + +// GetSpriteStatus checks if a sprite is running. +func (c *Client) GetSpriteStatus(ctx context.Context, name string) (string, error) { + // Try to run a simple command to check if sprite is accessible + sprite := c.client.Sprite(name) + cmd := sprite.CommandContext(ctx, "echo", "ok") + _, err := cmd.Output() + if err != nil { + return "stopped", nil + } + return "running", nil +} diff --git a/internal/sprite/manager.go b/internal/sprite/manager.go new file mode 100644 index 00000000..aef4e832 --- /dev/null +++ b/internal/sprite/manager.go @@ -0,0 +1,222 @@ +package sprite + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/bborn/workflow/internal/hostdb" +) + +// Manager handles sprite lifecycle management. +type Manager struct { + client *Client + db *hostdb.DB +} + +// NewManager creates a new sprite manager. +func NewManager(client *Client, db *hostdb.DB) *Manager { + return &Manager{ + client: client, + db: db, + } +} + +// generateSpriteName generates a unique sprite name for a user. +func generateSpriteName() string { + b := make([]byte, 6) + rand.Read(b) + return fmt.Sprintf("taskyou-%s", hex.EncodeToString(b)) +} + +// ProvisionSprite provisions a new sprite for a user. +// If the user already has a sprite, returns the existing one. +func (m *Manager) ProvisionSprite(ctx context.Context, userID string) (*hostdb.Sprite, error) { + // Check if user already has a sprite + existing, err := m.db.GetSpriteByUser(userID) + if err != nil { + return nil, fmt.Errorf("get existing sprite: %w", err) + } + if existing != nil { + return existing, nil + } + + // Create new sprite record + spriteName := generateSpriteName() + region := "ord" // Default to Chicago + + sprite, err := m.db.CreateSprite(userID, spriteName, region) + if err != nil { + return nil, fmt.Errorf("create sprite record: %w", err) + } + + // Update status to creating + if err := m.db.UpdateSpriteStatus(sprite.ID, hostdb.SpriteStatusCreating); err != nil { + return nil, fmt.Errorf("update sprite status: %w", err) + } + + // Create the actual Fly Sprite + if err := m.client.CreateSprite(ctx, spriteName, &CreateOptions{Region: region}); err != nil { + m.db.UpdateSpriteStatus(sprite.ID, hostdb.SpriteStatusError) + return nil, fmt.Errorf("create fly sprite: %w", err) + } + + // Initialize the sprite with taskd + if err := m.initializeSprite(ctx, spriteName); err != nil { + m.db.UpdateSpriteStatus(sprite.ID, hostdb.SpriteStatusError) + return nil, fmt.Errorf("initialize sprite: %w", err) + } + + // Update status to running + if err := m.db.UpdateSpriteStatus(sprite.ID, hostdb.SpriteStatusRunning); err != nil { + return nil, fmt.Errorf("update sprite status: %w", err) + } + + sprite.Status = hostdb.SpriteStatusRunning + return sprite, nil +} + +// initializeSprite sets up a sprite with taskd and required dependencies. +func (m *Manager) initializeSprite(ctx context.Context, spriteName string) error { + // Install taskd binary and start the web API + // The sprite image should have taskd pre-installed, but we ensure it's running + commands := []struct { + name string + args []string + }{ + // Ensure data directory exists + {"mkdir", []string{"-p", "/data"}}, + // Start taskd with web API in background + // Note: In production, this would be managed by a process supervisor + {"sh", []string{"-c", "nohup /usr/local/bin/taskd --web-api --addr :8080 > /var/log/taskd.log 2>&1 &"}}, + } + + for _, cmd := range commands { + if _, err := m.client.RunCommandCombined(ctx, spriteName, cmd.name, cmd.args...); err != nil { + // Log but don't fail - some commands might fail on subsequent runs + continue + } + } + + // Wait for taskd to be ready + for i := 0; i < 30; i++ { + output, err := m.client.RunCommand(ctx, spriteName, "curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:8080/health") + if err == nil && strings.TrimSpace(string(output)) == "200" { + return nil + } + time.Sleep(time.Second) + } + + return fmt.Errorf("taskd did not become ready in time") +} + +// GetUserSprite retrieves the sprite for a user. +func (m *Manager) GetUserSprite(ctx context.Context, userID string) (*hostdb.Sprite, error) { + sprite, err := m.db.GetSpriteByUser(userID) + if err != nil { + return nil, fmt.Errorf("get sprite: %w", err) + } + return sprite, nil +} + +// GetSpriteStatus checks the current status of a sprite. +func (m *Manager) GetSpriteStatus(ctx context.Context, sprite *hostdb.Sprite) (hostdb.SpriteStatus, error) { + status, err := m.client.GetSpriteStatus(ctx, sprite.SpriteName) + if err != nil { + return hostdb.SpriteStatusError, fmt.Errorf("get sprite status: %w", err) + } + + switch status { + case "running": + return hostdb.SpriteStatusRunning, nil + case "stopped": + return hostdb.SpriteStatusStopped, nil + default: + return hostdb.SpriteStatusError, nil + } +} + +// StartSprite starts a stopped sprite. +func (m *Manager) StartSprite(ctx context.Context, sprite *hostdb.Sprite) error { + // Run a command to wake up the sprite + if err := m.client.StartCommand(ctx, sprite.SpriteName, "true"); err != nil { + return fmt.Errorf("start sprite: %w", err) + } + + // Re-initialize to ensure taskd is running + if err := m.initializeSprite(ctx, sprite.SpriteName); err != nil { + return fmt.Errorf("reinitialize sprite: %w", err) + } + + if err := m.db.UpdateSpriteStatus(sprite.ID, hostdb.SpriteStatusRunning); err != nil { + return fmt.Errorf("update sprite status: %w", err) + } + + return nil +} + +// StopSprite stops a running sprite. +func (m *Manager) StopSprite(ctx context.Context, sprite *hostdb.Sprite) error { + // Sprites auto-stop when idle, but we can explicitly stop them + // by killing the taskd process + m.client.RunCommand(ctx, sprite.SpriteName, "pkill", "-f", "taskd") + + if err := m.db.UpdateSpriteStatus(sprite.ID, hostdb.SpriteStatusStopped); err != nil { + return fmt.Errorf("update sprite status: %w", err) + } + + return nil +} + +// DestroySprite destroys a sprite and removes its record. +func (m *Manager) DestroySprite(ctx context.Context, sprite *hostdb.Sprite) error { + // Destroy the Fly Sprite + if err := m.client.DestroySprite(ctx, sprite.SpriteName); err != nil { + return fmt.Errorf("destroy fly sprite: %w", err) + } + + // Delete the record + if err := m.db.DeleteSprite(sprite.ID); err != nil { + return fmt.Errorf("delete sprite record: %w", err) + } + + return nil +} + +// GetSSHConfig returns SSH configuration for connecting to a sprite. +func (m *Manager) GetSSHConfig(ctx context.Context, sprite *hostdb.Sprite) (string, error) { + // The SSH host is the sprite URL + sshHost := fmt.Sprintf("%s.sprites.dev", sprite.SpriteName) + + config := fmt.Sprintf(`Host %s + HostName %s + User root + Port 22 + IdentityFile ~/.ssh/id_ed25519 +`, sprite.SpriteName, sshHost) + + return config, nil +} + +// ExportDatabase exports the sprite's SQLite database. +func (m *Manager) ExportDatabase(ctx context.Context, sprite *hostdb.Sprite) ([]byte, error) { + // Create a backup of the database + _, err := m.client.RunCommand(ctx, sprite.SpriteName, "sqlite3", "/data/tasks.db", ".backup /tmp/export.db") + if err != nil { + return nil, fmt.Errorf("backup database: %w", err) + } + + // Read the backup file + output, err := m.client.RunCommand(ctx, sprite.SpriteName, "cat", "/tmp/export.db") + if err != nil { + return nil, fmt.Errorf("read backup: %w", err) + } + + // Clean up + m.client.RunCommand(ctx, sprite.SpriteName, "rm", "/tmp/export.db") + + return output, nil +} diff --git a/internal/sprite/proxy.go b/internal/sprite/proxy.go new file mode 100644 index 00000000..278aeaec --- /dev/null +++ b/internal/sprite/proxy.go @@ -0,0 +1,125 @@ +package sprite + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/bborn/workflow/internal/hostdb" +) + +// ProxyHandler handles proxying requests to user sprites. +type ProxyHandler struct { + manager *Manager +} + +// NewProxyHandler creates a new proxy handler. +func NewProxyHandler(manager *Manager) *ProxyHandler { + return &ProxyHandler{manager: manager} +} + +// GetSpriteURL returns the internal URL for a sprite's web API. +func (p *ProxyHandler) GetSpriteURL(sprite *hostdb.Sprite) string { + // Sprites expose their web API on port 8080 + // The URL format for accessing sprites via the Fly network + return fmt.Sprintf("http://%s.internal:8080", sprite.SpriteName) +} + +// ProxyRequest proxies an HTTP request to a user's sprite. +func (p *ProxyHandler) ProxyRequest(ctx context.Context, sprite *hostdb.Sprite, w http.ResponseWriter, r *http.Request) error { + target, err := url.Parse(p.GetSpriteURL(sprite)) + if err != nil { + return fmt.Errorf("parse sprite url: %w", err) + } + + proxy := httputil.NewSingleHostReverseProxy(target) + + // Modify the director to strip the /api prefix and adjust headers + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + originalDirector(req) + + // Strip /api prefix from path (the sprite API doesn't use /api prefix) + req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api") + if req.URL.Path == "" { + req.URL.Path = "/" + } + + // Set forwarding headers + req.Header.Set("X-Forwarded-For", r.RemoteAddr) + req.Header.Set("X-Forwarded-Host", r.Host) + req.Header.Set("X-Forwarded-Proto", "https") + + // Remove cookies and auth headers from proxied request + // (sprite API doesn't need them - we've already authenticated) + req.Header.Del("Cookie") + req.Header.Del("Authorization") + } + + // Custom error handler + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, fmt.Sprintf("Sprite unavailable: %v", err), http.StatusBadGateway) + } + + proxy.ServeHTTP(w, r) + return nil +} + +// ProxyWebSocket proxies a WebSocket connection to a user's sprite. +func (p *ProxyHandler) ProxyWebSocket(ctx context.Context, sprite *hostdb.Sprite, w http.ResponseWriter, r *http.Request) error { + // Get sprite internal URL + spriteURL := p.GetSpriteURL(sprite) + target, err := url.Parse(spriteURL) + if err != nil { + return fmt.Errorf("parse sprite url: %w", err) + } + + // Change scheme to ws + target.Scheme = "ws" + target.Path = "/ws" + + // Hijack the connection for WebSocket proxying + hijacker, ok := w.(http.Hijacker) + if !ok { + return fmt.Errorf("response writer does not support hijacking") + } + + clientConn, _, err := hijacker.Hijack() + if err != nil { + return fmt.Errorf("hijack connection: %w", err) + } + defer clientConn.Close() + + // Connect to sprite WebSocket + // This is a simplified implementation - in production you'd use gorilla/websocket + // or nhooyr.io/websocket for proper WebSocket handling + spriteConn, err := dialWebSocket(ctx, target.String()) + if err != nil { + return fmt.Errorf("dial sprite websocket: %w", err) + } + defer spriteConn.Close() + + // Bidirectional copy + errCh := make(chan error, 2) + go func() { + _, err := io.Copy(clientConn, spriteConn) + errCh <- err + }() + go func() { + _, err := io.Copy(spriteConn, clientConn) + errCh <- err + }() + + return <-errCh +} + +// dialWebSocket establishes a WebSocket connection. +// This is a placeholder - in production, use a proper WebSocket library. +func dialWebSocket(ctx context.Context, url string) (io.ReadWriteCloser, error) { + // In production, use gorilla/websocket or nhooyr.io/websocket + return nil, fmt.Errorf("websocket not implemented - use gorilla/websocket") +} diff --git a/internal/webapi/projects.go b/internal/webapi/projects.go new file mode 100644 index 00000000..bfbd1430 --- /dev/null +++ b/internal/webapi/projects.go @@ -0,0 +1,237 @@ +package webapi + +import ( + "net/http" + "time" + + "github.com/bborn/workflow/internal/db" +) + +// ProjectResponse represents a project in JSON responses. +type ProjectResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Aliases string `json:"aliases"` + Instructions string `json:"instructions"` + Color string `json:"color"` + CreatedAt time.Time `json:"created_at"` +} + +func projectToResponse(p *db.Project) *ProjectResponse { + return &ProjectResponse{ + ID: p.ID, + Name: p.Name, + Path: p.Path, + Aliases: p.Aliases, + Instructions: p.Instructions, + Color: p.Color, + CreatedAt: p.CreatedAt.Time, + } +} + +// handleListProjects handles GET /projects +func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) { + projects, err := s.db.ListProjects() + if err != nil { + s.logger.Error("list projects failed", "error", err) + jsonError(w, "Failed to list projects", http.StatusInternalServerError) + return + } + + responses := make([]*ProjectResponse, len(projects)) + for i, p := range projects { + responses[i] = projectToResponse(p) + } + + jsonResponse(w, responses, http.StatusOK) +} + +// CreateProjectRequest represents a request to create a project. +type CreateProjectRequest struct { + Name string `json:"name"` + Path string `json:"path"` + Aliases string `json:"aliases,omitempty"` + Instructions string `json:"instructions,omitempty"` + Color string `json:"color,omitempty"` +} + +// handleCreateProject handles POST /projects +func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { + var req CreateProjectRequest + if err := parseJSON(r, &req); err != nil { + jsonError(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Name == "" { + jsonError(w, "Name is required", http.StatusBadRequest) + return + } + if req.Path == "" { + jsonError(w, "Path is required", http.StatusBadRequest) + return + } + + project := &db.Project{ + Name: req.Name, + Path: req.Path, + Aliases: req.Aliases, + Instructions: req.Instructions, + Color: req.Color, + } + + if err := s.db.CreateProject(project); err != nil { + s.logger.Error("create project failed", "error", err) + jsonError(w, "Failed to create project", http.StatusInternalServerError) + return + } + + jsonResponse(w, projectToResponse(project), http.StatusCreated) +} + +// handleGetProject handles GET /projects/{id} +func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) { + id, err := getIDParam(r) + if err != nil { + jsonError(w, "Invalid project ID", http.StatusBadRequest) + return + } + + project, err := s.getProjectByID(id) + if err != nil { + s.logger.Error("get project failed", "error", err) + jsonError(w, "Failed to get project", http.StatusInternalServerError) + return + } + + if project == nil { + jsonError(w, "Project not found", http.StatusNotFound) + return + } + + jsonResponse(w, projectToResponse(project), http.StatusOK) +} + +// getProjectByID retrieves a project by ID by scanning all projects. +func (s *Server) getProjectByID(id int64) (*db.Project, error) { + projects, err := s.db.ListProjects() + if err != nil { + return nil, err + } + for _, p := range projects { + if p.ID == id { + return p, nil + } + } + return nil, nil +} + +// UpdateProjectRequest represents a request to update a project. +type UpdateProjectRequest struct { + Name *string `json:"name,omitempty"` + Path *string `json:"path,omitempty"` + Aliases *string `json:"aliases,omitempty"` + Instructions *string `json:"instructions,omitempty"` + Color *string `json:"color,omitempty"` +} + +// handleUpdateProject handles PUT /projects/{id} +func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) { + id, err := getIDParam(r) + if err != nil { + jsonError(w, "Invalid project ID", http.StatusBadRequest) + return + } + + var req UpdateProjectRequest + if err := parseJSON(r, &req); err != nil { + jsonError(w, "Invalid request body", http.StatusBadRequest) + return + } + + project, err := s.getProjectByID(id) + if err != nil { + s.logger.Error("get project failed", "error", err) + jsonError(w, "Failed to get project", http.StatusInternalServerError) + return + } + if project == nil { + jsonError(w, "Project not found", http.StatusNotFound) + return + } + + // Apply updates + if req.Name != nil { + project.Name = *req.Name + } + if req.Path != nil { + project.Path = *req.Path + } + if req.Aliases != nil { + project.Aliases = *req.Aliases + } + if req.Instructions != nil { + project.Instructions = *req.Instructions + } + if req.Color != nil { + project.Color = *req.Color + } + + if err := s.db.UpdateProject(project); err != nil { + s.logger.Error("update project failed", "error", err) + jsonError(w, "Failed to update project", http.StatusInternalServerError) + return + } + + jsonResponse(w, projectToResponse(project), http.StatusOK) +} + +// handleDeleteProject handles DELETE /projects/{id} +func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) { + id, err := getIDParam(r) + if err != nil { + jsonError(w, "Invalid project ID", http.StatusBadRequest) + return + } + + if err := s.db.DeleteProject(id); err != nil { + s.logger.Error("delete project failed", "error", err) + jsonError(w, "Failed to delete project", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// handleGetSettings handles GET /settings +func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) { + settings := make(map[string]string) + + // Get common settings + keys := []string{"theme", "pane_height", "default_project", "default_type"} + for _, key := range keys { + if value, err := s.db.GetSetting(key); err == nil { + settings[key] = value + } + } + + jsonResponse(w, settings, http.StatusOK) +} + +// handleUpdateSettings handles PUT /settings +func (s *Server) handleUpdateSettings(w http.ResponseWriter, r *http.Request) { + var settings map[string]string + if err := parseJSON(r, &settings); err != nil { + jsonError(w, "Invalid request body", http.StatusBadRequest) + return + } + + for key, value := range settings { + if err := s.db.SetSetting(key, value); err != nil { + s.logger.Error("set setting failed", "key", key, "error", err) + } + } + + jsonResponse(w, map[string]bool{"success": true}, http.StatusOK) +} diff --git a/internal/webapi/server.go b/internal/webapi/server.go new file mode 100644 index 00000000..a3138db8 --- /dev/null +++ b/internal/webapi/server.go @@ -0,0 +1,399 @@ +// Package webapi provides the HTTP API that runs inside each user's sprite. +// This exposes the task database and executor state via REST and WebSocket. +package webapi + +import ( + "bufio" + "context" + "encoding/json" + "net" + "net/http" + "os" + "strconv" + "sync" + "time" + + "github.com/bborn/workflow/internal/db" + "github.com/charmbracelet/log" + "github.com/gorilla/websocket" +) + +// Server is the web API server that runs inside each sprite. +type Server struct { + db *db.DB + addr string + logger *log.Logger + wsHub *WebSocketHub + devMode bool + devOrigin string +} + +// Config holds server configuration. +type Config struct { + Addr string + DB *db.DB + DevMode bool // Enable CORS for local development + DevOrigin string // Allowed origin in dev mode (e.g., "http://localhost:5173") +} + +// New creates a new API server. +func New(cfg Config) *Server { + return &Server{ + db: cfg.DB, + addr: cfg.Addr, + logger: log.NewWithOptions(os.Stderr, log.Options{Prefix: "webapi"}), + wsHub: NewWebSocketHub(), + devMode: cfg.DevMode, + devOrigin: cfg.DevOrigin, + } +} + +// Start starts the API server. +func (s *Server) Start(ctx context.Context) error { + mux := http.NewServeMux() + + // Health check + mux.HandleFunc("GET /health", s.handleHealth) + + // Task endpoints + mux.HandleFunc("GET /tasks", s.handleListTasks) + mux.HandleFunc("POST /tasks", s.handleCreateTask) + mux.HandleFunc("GET /tasks/{id}", s.handleGetTask) + mux.HandleFunc("PUT /tasks/{id}", s.handleUpdateTask) + mux.HandleFunc("DELETE /tasks/{id}", s.handleDeleteTask) + mux.HandleFunc("POST /tasks/{id}/queue", s.handleQueueTask) + mux.HandleFunc("POST /tasks/{id}/retry", s.handleRetryTask) + mux.HandleFunc("POST /tasks/{id}/close", s.handleCloseTask) + + // Project endpoints + mux.HandleFunc("GET /projects", s.handleListProjects) + mux.HandleFunc("POST /projects", s.handleCreateProject) + mux.HandleFunc("GET /projects/{id}", s.handleGetProject) + mux.HandleFunc("PUT /projects/{id}", s.handleUpdateProject) + mux.HandleFunc("DELETE /projects/{id}", s.handleDeleteProject) + + // Settings endpoints + mux.HandleFunc("GET /settings", s.handleGetSettings) + mux.HandleFunc("PUT /settings", s.handleUpdateSettings) + + // Task logs + mux.HandleFunc("GET /tasks/{id}/logs", s.handleGetTaskLogs) + + // Terminal endpoints (ttyd) + mux.HandleFunc("GET /tasks/{id}/terminal", s.handleGetTerminal) + mux.HandleFunc("DELETE /tasks/{id}/terminal", s.handleStopTerminal) + + // WebSocket + mux.HandleFunc("GET /ws", s.handleWebSocket) + + // Apply middleware + var handler http.Handler = mux + if s.devMode { + handler = s.corsMiddleware(handler) + } + handler = s.loggingMiddleware(handler) + + server := &http.Server{ + Addr: s.addr, + Handler: handler, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Start WebSocket hub + go s.wsHub.Run() + + s.logger.Info("starting API server", "addr", s.addr) + + // Start server in goroutine + errCh := make(chan error, 1) + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + + // Wait for context cancellation or error + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return server.Shutdown(shutdownCtx) + case err := <-errCh: + return err + } +} + +// corsMiddleware adds CORS headers for local development. +func (s *Server) corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // In dev mode, allow the actual request origin (Vite may use different ports) + origin := r.Header.Get("Origin") + if origin == "" { + origin = s.devOrigin + } + if origin == "" { + origin = "http://localhost:5173" + } + + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Allow-Credentials", "true") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) +} + +// loggingMiddleware logs HTTP requests. +func (s *Server) loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + next.ServeHTTP(wrapped, r) + + s.logger.Debug("request", + "method", r.Method, + "path", r.URL.Path, + "status", wrapped.statusCode, + "duration", time.Since(start), + ) + }) +} + +type responseWriter struct { + http.ResponseWriter + statusCode int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +// Hijack implements http.Hijacker for WebSocket support. +func (rw *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hijacker, ok := rw.ResponseWriter.(http.Hijacker); ok { + return hijacker.Hijack() + } + return nil, nil, http.ErrNotSupported +} + +// JSON response helpers +func jsonResponse(w http.ResponseWriter, data interface{}, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func jsonError(w http.ResponseWriter, message string, status int) { + jsonResponse(w, map[string]string{"error": message}, status) +} + +func parseJSON(r *http.Request, v interface{}) error { + return json.NewDecoder(r.Body).Decode(v) +} + +// getIDParam extracts an ID from the URL path. +func getIDParam(r *http.Request) (int64, error) { + idStr := r.PathValue("id") + return strconv.ParseInt(idStr, 10, 64) +} + +// handleHealth handles health check requests. +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} + +// BroadcastTaskUpdate broadcasts a task update to all connected WebSocket clients. +func (s *Server) BroadcastTaskUpdate(task *db.Task) { + s.wsHub.Broadcast(Message{ + Type: "task_update", + Data: task, + }) +} + +// BroadcastTaskLog broadcasts a task log entry to all connected WebSocket clients. +func (s *Server) BroadcastTaskLog(taskID int64, log *db.TaskLog) { + s.wsHub.Broadcast(Message{ + Type: "task_log", + Data: map[string]interface{}{ + "task_id": taskID, + "log": log, + }, + }) +} + +// WebSocketHub manages WebSocket connections. +type WebSocketHub struct { + clients map[*WebSocketClient]bool + broadcast chan Message + register chan *WebSocketClient + unregister chan *WebSocketClient + mu sync.RWMutex +} + +// WebSocketClient represents a connected WebSocket client. +type WebSocketClient struct { + hub *WebSocketHub + conn *websocket.Conn + send chan []byte +} + +// Message represents a WebSocket message. +type Message struct { + Type string `json:"type"` + Data interface{} `json:"data"` +} + +// NewWebSocketHub creates a new WebSocket hub. +func NewWebSocketHub() *WebSocketHub { + return &WebSocketHub{ + clients: make(map[*WebSocketClient]bool), + broadcast: make(chan Message), + register: make(chan *WebSocketClient), + unregister: make(chan *WebSocketClient), + } +} + +// Run starts the WebSocket hub. +func (h *WebSocketHub) Run() { + for { + select { + case client := <-h.register: + h.mu.Lock() + h.clients[client] = true + h.mu.Unlock() + + case client := <-h.unregister: + h.mu.Lock() + if _, ok := h.clients[client]; ok { + delete(h.clients, client) + close(client.send) + } + h.mu.Unlock() + + case message := <-h.broadcast: + data, err := json.Marshal(message) + if err != nil { + continue + } + + h.mu.RLock() + for client := range h.clients { + select { + case client.send <- data: + default: + h.mu.RUnlock() + h.mu.Lock() + close(client.send) + delete(h.clients, client) + h.mu.Unlock() + h.mu.RLock() + } + } + h.mu.RUnlock() + } + } +} + +// Broadcast sends a message to all connected clients. +func (h *WebSocketHub) Broadcast(msg Message) { + h.broadcast <- msg +} + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true // Allow all origins (we're behind the proxy) + }, +} + +// handleWebSocket handles WebSocket connections. +func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + s.logger.Error("websocket upgrade failed", "error", err) + return + } + + client := &WebSocketClient{ + hub: s.wsHub, + conn: conn, + send: make(chan []byte, 256), + } + + s.wsHub.register <- client + + // Start goroutines for reading and writing + go client.writePump() + go client.readPump() +} + +func (c *WebSocketClient) readPump() { + defer func() { + c.hub.unregister <- c + c.conn.Close() + }() + + c.conn.SetReadLimit(512 * 1024) // 512KB + c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + + for { + _, _, err := c.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + // Log unexpected close errors + } + break + } + } +} + +func (c *WebSocketClient) writePump() { + ticker := time.NewTicker(54 * time.Second) + defer func() { + ticker.Stop() + c.conn.Close() + }() + + for { + select { + case message, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if !ok { + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + w, err := c.conn.NextWriter(websocket.TextMessage) + if err != nil { + return + } + w.Write(message) + + if err := w.Close(); err != nil { + return + } + + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} diff --git a/internal/webapi/server_test.go b/internal/webapi/server_test.go new file mode 100644 index 00000000..960c106b --- /dev/null +++ b/internal/webapi/server_test.go @@ -0,0 +1,276 @@ +package webapi + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/bborn/workflow/internal/db" +) + +func setupTestServer(t *testing.T) (*Server, func()) { + tmpDir, err := os.MkdirTemp("", "webapi-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + dbPath := filepath.Join(tmpDir, "test.db") + database, err := db.Open(dbPath) + if err != nil { + os.RemoveAll(tmpDir) + t.Fatalf("failed to open database: %v", err) + } + + server := New(Config{ + Addr: ":0", + DB: database, + }) + + // Start the WebSocket hub in a goroutine + go server.wsHub.Run() + + cleanup := func() { + database.Close() + os.RemoveAll(tmpDir) + } + + return server, cleanup +} + +func TestListTasks(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + // Create a task first + task := &db.Task{ + Title: "Test Task", + Body: "Test body", + Type: "code", + Project: "personal", + Status: db.StatusBacklog, + } + if err := server.db.CreateTask(task); err != nil { + t.Fatalf("failed to create task: %v", err) + } + + // Test list tasks + req := httptest.NewRequest("GET", "/tasks", nil) + w := httptest.NewRecorder() + + server.handleListTasks(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + var tasks []*TaskResponse + body, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(body, &tasks); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(tasks) != 1 { + t.Errorf("expected 1 task, got %d", len(tasks)) + } + if tasks[0].Title != "Test Task" { + t.Errorf("expected title 'Test Task', got '%s'", tasks[0].Title) + } +} + +func TestCreateTask(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + // Create task via API + createReq := CreateTaskRequest{ + Title: "New Task", + Body: "Task body", + Type: "writing", + Project: "personal", + } + reqBody, _ := json.Marshal(createReq) + + req := httptest.NewRequest("POST", "/tasks", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleCreateTask(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected status 201, got %d: %s", resp.StatusCode, string(body)) + } + + var task TaskResponse + body, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(body, &task); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if task.Title != "New Task" { + t.Errorf("expected title 'New Task', got '%s'", task.Title) + } + if task.Status != db.StatusBacklog { + t.Errorf("expected status 'backlog', got '%s'", task.Status) + } +} + +func TestCreateTaskRequiresTitle(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + // Create task without title + createReq := CreateTaskRequest{ + Body: "Task body", + } + reqBody, _ := json.Marshal(createReq) + + req := httptest.NewRequest("POST", "/tasks", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleCreateTask(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", resp.StatusCode) + } +} + +func TestListProjects(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + req := httptest.NewRequest("GET", "/projects", nil) + w := httptest.NewRecorder() + + server.handleListProjects(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + var projects []*ProjectResponse + body, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(body, &projects); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + // Should have at least the default 'personal' project + found := false + for _, p := range projects { + if p.Name == "personal" { + found = true + break + } + } + if !found { + t.Error("expected 'personal' project to exist") + } +} + +func TestCreateProject(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + createReq := CreateProjectRequest{ + Name: "test-project", + Path: "/tmp/test-project", + Color: "#FF0000", + } + reqBody, _ := json.Marshal(createReq) + + req := httptest.NewRequest("POST", "/projects", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleCreateProject(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected status 201, got %d: %s", resp.StatusCode, string(body)) + } + + var project ProjectResponse + body, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(body, &project); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if project.Name != "test-project" { + t.Errorf("expected name 'test-project', got '%s'", project.Name) + } + if project.Color != "#FF0000" { + t.Errorf("expected color '#FF0000', got '%s'", project.Color) + } +} + +func TestGetSettings(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + // Set a setting first + server.db.SetSetting("theme", "dark") + + req := httptest.NewRequest("GET", "/settings", nil) + w := httptest.NewRecorder() + + server.handleGetSettings(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + var settings map[string]string + body, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(body, &settings); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if settings["theme"] != "dark" { + t.Errorf("expected theme 'dark', got '%s'", settings["theme"]) + } +} + +func TestUpdateSettings(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + settings := map[string]string{ + "theme": "light", + "pane_height": "50", + } + reqBody, _ := json.Marshal(settings) + + req := httptest.NewRequest("PUT", "/settings", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleUpdateSettings(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + // Verify settings were saved + theme, _ := server.db.GetSetting("theme") + if theme != "light" { + t.Errorf("expected theme 'light', got '%s'", theme) + } + + paneHeight, _ := server.db.GetSetting("pane_height") + if paneHeight != "50" { + t.Errorf("expected pane_height '50', got '%s'", paneHeight) + } +} diff --git a/internal/webapi/tasks.go b/internal/webapi/tasks.go new file mode 100644 index 00000000..478deaca --- /dev/null +++ b/internal/webapi/tasks.go @@ -0,0 +1,427 @@ +package webapi + +import ( + "net/http" + "time" + + "github.com/bborn/workflow/internal/db" +) + +// TaskResponse represents a task in JSON responses. +type TaskResponse struct { + ID int64 `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + Status string `json:"status"` + Type string `json:"type"` + Project string `json:"project"` + WorktreePath string `json:"worktree_path,omitempty"` + BranchName string `json:"branch_name,omitempty"` + Port int `json:"port,omitempty"` + PRUrl string `json:"pr_url,omitempty"` + PRNumber int `json:"pr_number,omitempty"` + DangerousMode bool `json:"dangerous_mode"` + ScheduledAt *time.Time `json:"scheduled_at,omitempty"` + Recurrence string `json:"recurrence,omitempty"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +func taskToResponse(t *db.Task) *TaskResponse { + resp := &TaskResponse{ + ID: t.ID, + Title: t.Title, + Body: t.Body, + Status: t.Status, + Type: t.Type, + Project: t.Project, + WorktreePath: t.WorktreePath, + BranchName: t.BranchName, + Port: t.Port, + PRUrl: t.PRURL, + PRNumber: t.PRNumber, + DangerousMode: t.DangerousMode, + Recurrence: t.Recurrence, + CreatedAt: t.CreatedAt.Time, + UpdatedAt: t.UpdatedAt.Time, + } + + if t.ScheduledAt != nil && !t.ScheduledAt.Time.IsZero() { + resp.ScheduledAt = &t.ScheduledAt.Time + } + if t.LastRunAt != nil && !t.LastRunAt.Time.IsZero() { + resp.LastRunAt = &t.LastRunAt.Time + } + if t.StartedAt != nil && !t.StartedAt.Time.IsZero() { + resp.StartedAt = &t.StartedAt.Time + } + if t.CompletedAt != nil && !t.CompletedAt.Time.IsZero() { + resp.CompletedAt = &t.CompletedAt.Time + } + + return resp +} + +// handleListTasks handles GET /tasks +func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) { + // Parse query parameters + opts := db.ListTasksOptions{} + + if status := r.URL.Query().Get("status"); status != "" { + opts.Status = status + } + if project := r.URL.Query().Get("project"); project != "" { + opts.Project = project + } + if taskType := r.URL.Query().Get("type"); taskType != "" { + opts.Type = taskType + } + if r.URL.Query().Get("all") == "true" { + opts.IncludeClosed = true + } + + tasks, err := s.db.ListTasks(opts) + if err != nil { + s.logger.Error("list tasks failed", "error", err) + jsonError(w, "Failed to list tasks", http.StatusInternalServerError) + return + } + + responses := make([]*TaskResponse, len(tasks)) + for i, t := range tasks { + responses[i] = taskToResponse(t) + } + + jsonResponse(w, responses, http.StatusOK) +} + +// CreateTaskRequest represents a request to create a task. +type CreateTaskRequest struct { + Title string `json:"title"` + Body string `json:"body"` + Type string `json:"type"` + Project string `json:"project"` + ScheduledAt string `json:"scheduled_at,omitempty"` + Recurrence string `json:"recurrence,omitempty"` +} + +// handleCreateTask handles POST /tasks +func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { + var req CreateTaskRequest + if err := parseJSON(r, &req); err != nil { + jsonError(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Title == "" { + jsonError(w, "Title is required", http.StatusBadRequest) + return + } + + // Set defaults + if req.Project == "" { + req.Project = "personal" + } + if req.Type == "" { + req.Type = "code" + } + + // Build task struct + task := &db.Task{ + Title: req.Title, + Body: req.Body, + Type: req.Type, + Project: req.Project, + Status: db.StatusBacklog, + Recurrence: req.Recurrence, + } + + // Handle scheduling + if req.ScheduledAt != "" { + scheduledAt, err := time.Parse(time.RFC3339, req.ScheduledAt) + if err == nil { + task.ScheduledAt = &db.LocalTime{Time: scheduledAt} + } + } + + if err := s.db.CreateTask(task); err != nil { + s.logger.Error("create task failed", "error", err) + jsonError(w, "Failed to create task", http.StatusInternalServerError) + return + } + + // Reload task to get all fields + task, _ = s.db.GetTask(task.ID) + + // Broadcast update + s.BroadcastTaskUpdate(task) + + jsonResponse(w, taskToResponse(task), http.StatusCreated) +} + +// handleGetTask handles GET /tasks/{id} +func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) { + id, err := getIDParam(r) + if err != nil { + jsonError(w, "Invalid task ID", http.StatusBadRequest) + return + } + + task, err := s.db.GetTask(id) + if err != nil { + s.logger.Error("get task failed", "error", err) + jsonError(w, "Failed to get task", http.StatusInternalServerError) + return + } + + if task == nil { + jsonError(w, "Task not found", http.StatusNotFound) + return + } + + jsonResponse(w, taskToResponse(task), http.StatusOK) +} + +// UpdateTaskRequest represents a request to update a task. +type UpdateTaskRequest struct { + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + Status *string `json:"status,omitempty"` + Type *string `json:"type,omitempty"` + Project *string `json:"project,omitempty"` + ScheduledAt *string `json:"scheduled_at,omitempty"` + Recurrence *string `json:"recurrence,omitempty"` +} + +// handleUpdateTask handles PUT /tasks/{id} +func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) { + id, err := getIDParam(r) + if err != nil { + jsonError(w, "Invalid task ID", http.StatusBadRequest) + return + } + + var req UpdateTaskRequest + if err := parseJSON(r, &req); err != nil { + jsonError(w, "Invalid request body", http.StatusBadRequest) + return + } + + task, err := s.db.GetTask(id) + if err != nil || task == nil { + jsonError(w, "Task not found", http.StatusNotFound) + return + } + + // Apply updates + if req.Title != nil { + task.Title = *req.Title + } + if req.Body != nil { + task.Body = *req.Body + } + if req.Status != nil { + task.Status = *req.Status + } + if req.Type != nil { + task.Type = *req.Type + } + if req.Project != nil { + task.Project = *req.Project + } + + if err := s.db.UpdateTask(task); err != nil { + s.logger.Error("update task failed", "error", err) + jsonError(w, "Failed to update task", http.StatusInternalServerError) + return + } + + // Handle scheduling + if req.ScheduledAt != nil || req.Recurrence != nil { + var scheduledAt *db.LocalTime + var recurrence string + + if req.ScheduledAt != nil { + if *req.ScheduledAt != "" { + t, err := time.Parse(time.RFC3339, *req.ScheduledAt) + if err == nil { + scheduledAt = &db.LocalTime{Time: t} + } + } + } + if req.Recurrence != nil { + recurrence = *req.Recurrence + } + + s.db.UpdateTaskSchedule(id, scheduledAt, recurrence, nil) + } + + // Reload task + task, _ = s.db.GetTask(id) + + // Broadcast update + s.BroadcastTaskUpdate(task) + + jsonResponse(w, taskToResponse(task), http.StatusOK) +} + +// handleDeleteTask handles DELETE /tasks/{id} +func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) { + id, err := getIDParam(r) + if err != nil { + jsonError(w, "Invalid task ID", http.StatusBadRequest) + return + } + + if err := s.db.DeleteTask(id); err != nil { + s.logger.Error("delete task failed", "error", err) + jsonError(w, "Failed to delete task", http.StatusInternalServerError) + return + } + + // Broadcast deletion + s.wsHub.Broadcast(Message{ + Type: "task_deleted", + Data: map[string]int64{"id": id}, + }) + + w.WriteHeader(http.StatusNoContent) +} + +// handleQueueTask handles POST /tasks/{id}/queue +func (s *Server) handleQueueTask(w http.ResponseWriter, r *http.Request) { + id, err := getIDParam(r) + if err != nil { + jsonError(w, "Invalid task ID", http.StatusBadRequest) + return + } + + task, err := s.db.GetTask(id) + if err != nil || task == nil { + jsonError(w, "Task not found", http.StatusNotFound) + return + } + + // Update status to queued + if err := s.db.UpdateTaskStatus(id, "queued"); err != nil { + s.logger.Error("queue task failed", "error", err) + jsonError(w, "Failed to queue task", http.StatusInternalServerError) + return + } + + // Reload task + task, _ = s.db.GetTask(id) + + // Broadcast update + s.BroadcastTaskUpdate(task) + + jsonResponse(w, taskToResponse(task), http.StatusOK) +} + +// RetryTaskRequest represents a request to retry a task. +type RetryTaskRequest struct { + Feedback string `json:"feedback,omitempty"` +} + +// handleRetryTask handles POST /tasks/{id}/retry +func (s *Server) handleRetryTask(w http.ResponseWriter, r *http.Request) { + id, err := getIDParam(r) + if err != nil { + jsonError(w, "Invalid task ID", http.StatusBadRequest) + return + } + + var req RetryTaskRequest + parseJSON(r, &req) // Feedback is optional + + task, err := s.db.GetTask(id) + if err != nil || task == nil { + jsonError(w, "Task not found", http.StatusNotFound) + return + } + + // If feedback provided, add to body + if req.Feedback != "" { + newBody := task.Body + if newBody != "" { + newBody += "\n\n---\nFeedback:\n" + } + newBody += req.Feedback + task.Body = newBody + s.db.UpdateTask(task) + } + + // Update status to queued + if err := s.db.UpdateTaskStatus(id, "queued"); err != nil { + s.logger.Error("retry task failed", "error", err) + jsonError(w, "Failed to retry task", http.StatusInternalServerError) + return + } + + // Reload task + task, _ = s.db.GetTask(id) + + // Broadcast update + s.BroadcastTaskUpdate(task) + + jsonResponse(w, taskToResponse(task), http.StatusOK) +} + +// handleCloseTask handles POST /tasks/{id}/close +func (s *Server) handleCloseTask(w http.ResponseWriter, r *http.Request) { + id, err := getIDParam(r) + if err != nil { + jsonError(w, "Invalid task ID", http.StatusBadRequest) + return + } + + task, err := s.db.GetTask(id) + if err != nil || task == nil { + jsonError(w, "Task not found", http.StatusNotFound) + return + } + + // Update status to done + if err := s.db.UpdateTaskStatus(id, "done"); err != nil { + s.logger.Error("close task failed", "error", err) + jsonError(w, "Failed to close task", http.StatusInternalServerError) + return + } + + // Reload task + task, _ = s.db.GetTask(id) + + // Broadcast update + s.BroadcastTaskUpdate(task) + + jsonResponse(w, taskToResponse(task), http.StatusOK) +} + +// handleGetTaskLogs handles GET /tasks/{id}/logs +func (s *Server) handleGetTaskLogs(w http.ResponseWriter, r *http.Request) { + id, err := getIDParam(r) + if err != nil { + jsonError(w, "Invalid task ID", http.StatusBadRequest) + return + } + + // Parse limit parameter + limit := 100 + if l := r.URL.Query().Get("limit"); l != "" { + if parsed, err := getIDParam(r); err == nil && parsed > 0 { + limit = int(parsed) + } + } + + logs, err := s.db.GetTaskLogs(id, limit) + if err != nil { + s.logger.Error("get task logs failed", "error", err) + jsonError(w, "Failed to get task logs", http.StatusInternalServerError) + return + } + + jsonResponse(w, logs, http.StatusOK) +} diff --git a/internal/webapi/terminal.go b/internal/webapi/terminal.go new file mode 100644 index 00000000..525ed3e2 --- /dev/null +++ b/internal/webapi/terminal.go @@ -0,0 +1,178 @@ +package webapi + +import ( + "fmt" + "net/http" + "os/exec" + "strings" + "sync" +) + +// terminalManager manages ttyd processes for task terminals. +type terminalManager struct { + mu sync.Mutex + sessions map[int64]*ttydSession +} + +type ttydSession struct { + taskID int64 + port int + process *exec.Cmd +} + +var terminals = &terminalManager{ + sessions: make(map[int64]*ttydSession), +} + +// BasePort is the starting port for ttyd instances. +const ttydBasePort = 7681 + +// getTerminalPort returns the port for a task's terminal. +func getTerminalPort(taskID int64) int { + return ttydBasePort + int(taskID%1000) +} + +// findDaemonSession finds the current task-daemon session. +func findDaemonSession() string { + out, err := exec.Command("tmux", "list-sessions", "-F", "#{session_name}").Output() + if err != nil { + return "" + } + for _, line := range strings.Split(string(out), "\n") { + if strings.HasPrefix(line, "task-daemon-") { + return line + } + } + return "" +} + +// handleGetTerminal returns terminal connection info for a task. +func (s *Server) handleGetTerminal(w http.ResponseWriter, r *http.Request) { + taskID, err := getIDParam(r) + if err != nil { + jsonError(w, "invalid task ID", http.StatusBadRequest) + return + } + + // Get task to find its daemon session + task, err := s.db.GetTask(taskID) + if err != nil { + jsonError(w, "task not found", http.StatusNotFound) + return + } + + // Try to find the daemon session - use task's recorded session or auto-detect + daemonSession := task.DaemonSession + if daemonSession == "" { + daemonSession = findDaemonSession() + } + if daemonSession == "" { + jsonError(w, "task has no active terminal session", http.StatusNotFound) + return + } + + windowName := fmt.Sprintf("task-%d", taskID) + tmuxTarget := fmt.Sprintf("%s:%s", daemonSession, windowName) + + // Check if tmux window exists + if err := exec.Command("tmux", "has-session", "-t", tmuxTarget).Run(); err != nil { + jsonError(w, "task terminal session not running", http.StatusNotFound) + return + } + + // Start ttyd if not already running + port, err := terminals.ensureRunning(taskID, tmuxTarget) + if err != nil { + s.logger.Error("failed to start terminal", "task", taskID, "error", err) + jsonError(w, "failed to start terminal: "+err.Error(), http.StatusInternalServerError) + return + } + + jsonResponse(w, map[string]interface{}{ + "task_id": taskID, + "port": port, + "tmux_target": tmuxTarget, + "websocket_url": fmt.Sprintf("ws://localhost:%d/ws", port), + }, http.StatusOK) +} + +// ensureRunning starts ttyd for a task if not already running. +func (tm *terminalManager) ensureRunning(taskID int64, tmuxTarget string) (int, error) { + tm.mu.Lock() + defer tm.mu.Unlock() + + // Check if already running + if session, ok := tm.sessions[taskID]; ok { + // Verify process is still alive + if session.process != nil && session.process.Process != nil { + if err := session.process.Process.Signal(nil); err == nil { + return session.port, nil + } + } + // Process died, clean up + delete(tm.sessions, taskID) + } + + port := getTerminalPort(taskID) + + // Check if ttyd is available + if _, err := exec.LookPath("ttyd"); err != nil { + return 0, fmt.Errorf("ttyd not installed: %w", err) + } + + // Start ttyd attached to the tmux session + // -W = writable (interactive) + // -p = port + // Use -- to separate ttyd options from the command + cmd := exec.Command("ttyd", + "-W", + "-p", fmt.Sprintf("%d", port), + "--", + "tmux", "attach-session", "-t", tmuxTarget, + ) + + if err := cmd.Start(); err != nil { + return 0, fmt.Errorf("failed to start ttyd: %w", err) + } + + tm.sessions[taskID] = &ttydSession{ + taskID: taskID, + port: port, + process: cmd, + } + + // Clean up process when it exits + go func() { + cmd.Wait() + tm.mu.Lock() + delete(tm.sessions, taskID) + tm.mu.Unlock() + }() + + return port, nil +} + +// stopTerminal stops the ttyd process for a task. +func (tm *terminalManager) stopTerminal(taskID int64) { + tm.mu.Lock() + defer tm.mu.Unlock() + + if session, ok := tm.sessions[taskID]; ok { + if session.process != nil && session.process.Process != nil { + session.process.Process.Kill() + } + delete(tm.sessions, taskID) + } +} + +// handleStopTerminal stops a task's terminal. +func (s *Server) handleStopTerminal(w http.ResponseWriter, r *http.Request) { + taskID, err := getIDParam(r) + if err != nil { + jsonError(w, "invalid task ID", http.StatusBadRequest) + return + } + + terminals.stopTerminal(taskID) + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/webserver/handlers.go b/internal/webserver/handlers.go new file mode 100644 index 00000000..1c369f1d --- /dev/null +++ b/internal/webserver/handlers.go @@ -0,0 +1,500 @@ +package webserver + +import ( + "fmt" + "net/http" + + "github.com/bborn/workflow/internal/auth" +) + +// handleGoogleAuth initiates Google OAuth flow. +func (s *Server) handleGoogleAuth(w http.ResponseWriter, r *http.Request) { + s.handleOAuthStart(w, r, auth.ProviderGoogle) +} + +// handleGitHubAuth initiates GitHub OAuth flow. +func (s *Server) handleGitHubAuth(w http.ResponseWriter, r *http.Request) { + s.handleOAuthStart(w, r, auth.ProviderGitHub) +} + +// handleOAuthStart initiates an OAuth flow for the given provider. +func (s *Server) handleOAuthStart(w http.ResponseWriter, r *http.Request, provider auth.Provider) { + if !auth.IsConfigured(provider) { + jsonError(w, fmt.Sprintf("%s OAuth not configured", provider), http.StatusServiceUnavailable) + return + } + + // Generate state + state, err := auth.GenerateOAuthState() + if err != nil { + s.logger.Error("generate oauth state failed", "error", err) + jsonError(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Store state in cookie with provider info + stateWithProvider := fmt.Sprintf("%s:%s", provider, state) + s.sessionMgr.SetOAuthStateCookie(w, stateWithProvider) + + // Get OAuth config + redirectURL := fmt.Sprintf("%s/api/auth/callback", s.baseURL) + config := auth.GetConfig(provider, redirectURL) + + // Redirect to provider + url := config.OAuth2.AuthCodeURL(state) + http.Redirect(w, r, url, http.StatusTemporaryRedirect) +} + +// handleOAuthCallback handles the OAuth callback from the provider. +func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { + // Get state from cookie + stateWithProvider := s.sessionMgr.GetOAuthStateCookie(w, r) + if stateWithProvider == "" { + jsonError(w, "Invalid OAuth state", http.StatusBadRequest) + return + } + + // Parse provider from state + var provider auth.Provider + var expectedState string + if n, _ := fmt.Sscanf(stateWithProvider, "%s:%s", &provider, &expectedState); n != 2 { + // Fallback to parsing with string operations + for _, p := range []auth.Provider{auth.ProviderGoogle, auth.ProviderGitHub} { + prefix := string(p) + ":" + if len(stateWithProvider) > len(prefix) && stateWithProvider[:len(prefix)] == prefix { + provider = p + expectedState = stateWithProvider[len(prefix):] + break + } + } + } + + if provider == "" { + jsonError(w, "Invalid OAuth state format", http.StatusBadRequest) + return + } + + // Verify state + state := r.URL.Query().Get("state") + if state != expectedState { + jsonError(w, "OAuth state mismatch", http.StatusBadRequest) + return + } + + // Check for error from provider + if errMsg := r.URL.Query().Get("error"); errMsg != "" { + errDesc := r.URL.Query().Get("error_description") + s.logger.Error("oauth error", "provider", provider, "error", errMsg, "description", errDesc) + http.Redirect(w, r, fmt.Sprintf("/?error=%s", errMsg), http.StatusTemporaryRedirect) + return + } + + // Exchange code for tokens + code := r.URL.Query().Get("code") + if code == "" { + jsonError(w, "Missing authorization code", http.StatusBadRequest) + return + } + + redirectURL := fmt.Sprintf("%s/api/auth/callback", s.baseURL) + config := auth.GetConfig(provider, redirectURL) + + token, err := auth.ExchangeCode(r.Context(), config, code) + if err != nil { + s.logger.Error("exchange code failed", "error", err) + jsonError(w, "Failed to exchange authorization code", http.StatusInternalServerError) + return + } + + // Get user info from provider + userInfo, err := auth.GetUserInfo(r.Context(), config, token) + if err != nil { + s.logger.Error("get user info failed", "error", err) + jsonError(w, "Failed to get user information", http.StatusInternalServerError) + return + } + + // Get or create user + expiresAt := auth.TokenExpiry(token) + user, isNew, err := s.db.GetOrCreateUserByOAuth( + string(provider), + userInfo.ProviderAccountID, + userInfo.Email, + userInfo.Name, + userInfo.AvatarURL, + token.AccessToken, + token.RefreshToken, + expiresAt, + ) + if err != nil { + s.logger.Error("get or create user failed", "error", err) + jsonError(w, "Failed to create user", http.StatusInternalServerError) + return + } + + s.logger.Info("user authenticated", + "user_id", user.ID, + "email", user.Email, + "provider", provider, + "new_user", isNew, + ) + + // Create session + if _, err := s.sessionMgr.CreateSession(w, user.ID); err != nil { + s.logger.Error("create session failed", "error", err) + jsonError(w, "Failed to create session", http.StatusInternalServerError) + return + } + + // Provision sprite for new users + if isNew && s.spriteMgr != nil { + go func() { + if _, err := s.spriteMgr.ProvisionSprite(r.Context(), user.ID); err != nil { + s.logger.Error("provision sprite failed", "user_id", user.ID, "error", err) + } + }() + } + + // Redirect to dashboard + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) +} + +// handleLogout logs the user out. +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if err := s.sessionMgr.DeleteSession(w, r); err != nil { + s.logger.Error("delete session failed", "error", err) + } + jsonResponse(w, map[string]bool{"success": true}, http.StatusOK) +} + +// handleGetMe returns the current user's information. +func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) { + _, user, err := s.sessionMgr.GetSessionWithUser(r) + if err != nil { + s.logger.Error("get session failed", "error", err) + jsonError(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if user == nil { + jsonError(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get user's sprite status + var spriteStatus interface{} + if s.spriteMgr != nil { + sprite, err := s.spriteMgr.GetUserSprite(r.Context(), user.ID) + if err == nil && sprite != nil { + spriteStatus = map[string]interface{}{ + "id": sprite.ID, + "name": sprite.SpriteName, + "status": sprite.Status, + "region": sprite.Region, + "created_at": sprite.CreatedAt, + } + } + } + + jsonResponse(w, map[string]interface{}{ + "id": user.ID, + "email": user.Email, + "name": user.Name, + "avatar_url": user.AvatarURL, + "sprite": spriteStatus, + }, http.StatusOK) +} + +// handleGetSprite returns the user's sprite status. +func (s *Server) handleGetSprite(w http.ResponseWriter, r *http.Request) { + user := getUserFromContext(r) + if user == nil { + jsonError(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if s.spriteMgr == nil { + jsonError(w, "Sprites not configured", http.StatusServiceUnavailable) + return + } + + sprite, err := s.spriteMgr.GetUserSprite(r.Context(), user.ID) + if err != nil { + s.logger.Error("get user sprite failed", "error", err) + jsonError(w, "Failed to get sprite", http.StatusInternalServerError) + return + } + + if sprite == nil { + jsonResponse(w, map[string]interface{}{"sprite": nil}, http.StatusOK) + return + } + + // Get current status from Fly + status, _ := s.spriteMgr.GetSpriteStatus(r.Context(), sprite) + + jsonResponse(w, map[string]interface{}{ + "id": sprite.ID, + "name": sprite.SpriteName, + "status": status, + "region": sprite.Region, + "created_at": sprite.CreatedAt, + }, http.StatusOK) +} + +// handleCreateSprite creates a sprite for the user. +func (s *Server) handleCreateSprite(w http.ResponseWriter, r *http.Request) { + user := getUserFromContext(r) + if user == nil { + jsonError(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if s.spriteMgr == nil { + jsonError(w, "Sprites not configured", http.StatusServiceUnavailable) + return + } + + sprite, err := s.spriteMgr.ProvisionSprite(r.Context(), user.ID) + if err != nil { + s.logger.Error("provision sprite failed", "error", err) + jsonError(w, fmt.Sprintf("Failed to create sprite: %v", err), http.StatusInternalServerError) + return + } + + jsonResponse(w, map[string]interface{}{ + "id": sprite.ID, + "name": sprite.SpriteName, + "status": sprite.Status, + "region": sprite.Region, + "created_at": sprite.CreatedAt, + }, http.StatusCreated) +} + +// handleStartSprite starts the user's sprite. +func (s *Server) handleStartSprite(w http.ResponseWriter, r *http.Request) { + user := getUserFromContext(r) + if user == nil { + jsonError(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if s.spriteMgr == nil { + jsonError(w, "Sprites not configured", http.StatusServiceUnavailable) + return + } + + sprite, err := s.spriteMgr.GetUserSprite(r.Context(), user.ID) + if err != nil { + jsonError(w, "Failed to get sprite", http.StatusInternalServerError) + return + } + + if sprite == nil { + jsonError(w, "No sprite found", http.StatusNotFound) + return + } + + if err := s.spriteMgr.StartSprite(r.Context(), sprite); err != nil { + s.logger.Error("start sprite failed", "error", err) + jsonError(w, fmt.Sprintf("Failed to start sprite: %v", err), http.StatusInternalServerError) + return + } + + jsonResponse(w, map[string]bool{"success": true}, http.StatusOK) +} + +// handleStopSprite stops the user's sprite. +func (s *Server) handleStopSprite(w http.ResponseWriter, r *http.Request) { + user := getUserFromContext(r) + if user == nil { + jsonError(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if s.spriteMgr == nil { + jsonError(w, "Sprites not configured", http.StatusServiceUnavailable) + return + } + + sprite, err := s.spriteMgr.GetUserSprite(r.Context(), user.ID) + if err != nil { + jsonError(w, "Failed to get sprite", http.StatusInternalServerError) + return + } + + if sprite == nil { + jsonError(w, "No sprite found", http.StatusNotFound) + return + } + + if err := s.spriteMgr.StopSprite(r.Context(), sprite); err != nil { + s.logger.Error("stop sprite failed", "error", err) + jsonError(w, fmt.Sprintf("Failed to stop sprite: %v", err), http.StatusInternalServerError) + return + } + + jsonResponse(w, map[string]bool{"success": true}, http.StatusOK) +} + +// handleDestroySprite destroys the user's sprite. +func (s *Server) handleDestroySprite(w http.ResponseWriter, r *http.Request) { + user := getUserFromContext(r) + if user == nil { + jsonError(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if s.spriteMgr == nil { + jsonError(w, "Sprites not configured", http.StatusServiceUnavailable) + return + } + + sprite, err := s.spriteMgr.GetUserSprite(r.Context(), user.ID) + if err != nil { + jsonError(w, "Failed to get sprite", http.StatusInternalServerError) + return + } + + if sprite == nil { + jsonError(w, "No sprite found", http.StatusNotFound) + return + } + + if err := s.spriteMgr.DestroySprite(r.Context(), sprite); err != nil { + s.logger.Error("destroy sprite failed", "error", err) + jsonError(w, fmt.Sprintf("Failed to destroy sprite: %v", err), http.StatusInternalServerError) + return + } + + jsonResponse(w, map[string]bool{"success": true}, http.StatusOK) +} + +// handleGetSSHConfig returns SSH config for connecting to the user's sprite. +func (s *Server) handleGetSSHConfig(w http.ResponseWriter, r *http.Request) { + user := getUserFromContext(r) + if user == nil { + jsonError(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if s.spriteMgr == nil { + jsonError(w, "Sprites not configured", http.StatusServiceUnavailable) + return + } + + sprite, err := s.spriteMgr.GetUserSprite(r.Context(), user.ID) + if err != nil { + jsonError(w, "Failed to get sprite", http.StatusInternalServerError) + return + } + + if sprite == nil { + jsonError(w, "No sprite found", http.StatusNotFound) + return + } + + config, err := s.spriteMgr.GetSSHConfig(r.Context(), sprite) + if err != nil { + jsonError(w, "Failed to get SSH config", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(config)) +} + +// handleExport exports the user's task database. +func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { + user := getUserFromContext(r) + if user == nil { + jsonError(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if s.spriteMgr == nil { + jsonError(w, "Sprites not configured", http.StatusServiceUnavailable) + return + } + + sprite, err := s.spriteMgr.GetUserSprite(r.Context(), user.ID) + if err != nil { + jsonError(w, "Failed to get sprite", http.StatusInternalServerError) + return + } + + if sprite == nil { + jsonError(w, "No sprite found", http.StatusNotFound) + return + } + + data, err := s.spriteMgr.ExportDatabase(r.Context(), sprite) + if err != nil { + s.logger.Error("export database failed", "error", err) + jsonError(w, "Failed to export database", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment; filename=tasks-export.db") + w.Write(data) +} + +// handleProxyTasks proxies requests to the user's sprite. +func (s *Server) handleProxyTasks(w http.ResponseWriter, r *http.Request) { + user := getUserFromContext(r) + if user == nil { + jsonError(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if s.spriteProxy == nil { + jsonError(w, "Sprites not configured", http.StatusServiceUnavailable) + return + } + + sprite, err := s.spriteMgr.GetUserSprite(r.Context(), user.ID) + if err != nil { + jsonError(w, "Failed to get sprite", http.StatusInternalServerError) + return + } + + if sprite == nil { + jsonError(w, "No sprite found. Please create a sprite first.", http.StatusNotFound) + return + } + + if err := s.spriteProxy.ProxyRequest(r.Context(), sprite, w, r); err != nil { + s.logger.Error("proxy request failed", "error", err) + jsonError(w, "Sprite unavailable", http.StatusBadGateway) + } +} + +// handleWebSocket handles WebSocket connections. +func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { + user := getUserFromContext(r) + if user == nil { + jsonError(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if s.spriteProxy == nil { + jsonError(w, "Sprites not configured", http.StatusServiceUnavailable) + return + } + + sprite, err := s.spriteMgr.GetUserSprite(r.Context(), user.ID) + if err != nil { + jsonError(w, "Failed to get sprite", http.StatusInternalServerError) + return + } + + if sprite == nil { + jsonError(w, "No sprite found", http.StatusNotFound) + return + } + + if err := s.spriteProxy.ProxyWebSocket(r.Context(), sprite, w, r); err != nil { + s.logger.Error("websocket proxy failed", "error", err) + } +} diff --git a/internal/webserver/middleware.go b/internal/webserver/middleware.go new file mode 100644 index 00000000..64d5eae8 --- /dev/null +++ b/internal/webserver/middleware.go @@ -0,0 +1,133 @@ +package webserver + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/bborn/workflow/internal/hostdb" +) + +// contextKey is a type for context keys. +type contextKey string + +const ( + userContextKey contextKey = "user" + sessionContextKey contextKey = "session" +) + +// loggingMiddleware logs HTTP requests. +func (s *Server) loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Wrap response writer to capture status code + wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + + next.ServeHTTP(wrapped, r) + + s.logger.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", wrapped.statusCode, + "duration", time.Since(start), + "ip", r.RemoteAddr, + ) + }) +} + +// responseWriter wraps http.ResponseWriter to capture the status code. +type responseWriter struct { + http.ResponseWriter + statusCode int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +// corsMiddleware adds CORS headers. +func (s *Server) corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Allow requests from the frontend origin + origin := r.Header.Get("Origin") + if origin == "" { + origin = s.baseURL + } + + // Only allow specific origins in production + allowedOrigins := []string{s.baseURL, "http://localhost:5173", "http://localhost:3000"} + allowed := false + for _, o := range allowedOrigins { + if o == origin { + allowed = true + break + } + } + + if allowed { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Max-Age", "86400") + } + + // Handle preflight requests + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} + +// requireAuth is middleware that requires authentication. +func (s *Server) requireAuth(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session, user, err := s.sessionMgr.GetSessionWithUser(r) + if err != nil { + s.logger.Error("get session failed", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if session == nil || user == nil { + jsonError(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Add user and session to context + ctx := context.WithValue(r.Context(), userContextKey, user) + ctx = context.WithValue(ctx, sessionContextKey, session) + + handler(w, r.WithContext(ctx)) + } +} + +// getUserFromContext retrieves the user from the request context. +func getUserFromContext(r *http.Request) *hostdb.User { + user, ok := r.Context().Value(userContextKey).(*hostdb.User) + if !ok { + return nil + } + return user +} + +// jsonResponse writes a JSON response. +func jsonResponse(w http.ResponseWriter, data interface{}, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +// jsonError writes a JSON error response. +func jsonError(w http.ResponseWriter, message string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": message}) +} + diff --git a/internal/webserver/server.go b/internal/webserver/server.go new file mode 100644 index 00000000..f237fc0f --- /dev/null +++ b/internal/webserver/server.go @@ -0,0 +1,243 @@ +// Package webserver provides the HTTP server for the web UI. +package webserver + +import ( + "context" + "io" + "io/fs" + "net/http" + "os" + "path" + "strings" + "time" + + "github.com/bborn/workflow/internal/auth" + "github.com/bborn/workflow/internal/hostdb" + "github.com/bborn/workflow/internal/sprite" + "github.com/charmbracelet/log" +) + +// Server is the web server for taskyou. +type Server struct { + addr string + db *hostdb.DB + sessionMgr *auth.SessionManager + spriteClient *sprite.Client + spriteMgr *sprite.Manager + spriteProxy *sprite.ProxyHandler + baseURL string + staticFS fs.FS + logger *log.Logger +} + +// Config holds server configuration. +type Config struct { + Addr string + DB *hostdb.DB + BaseURL string + Secure bool // Use secure cookies + Domain string // Cookie domain + StaticFS fs.FS // Embedded static files (optional) +} + +// New creates a new server. +func New(cfg Config) (*Server, error) { + sessionMgr := auth.NewSessionManager(cfg.DB, cfg.Secure, cfg.Domain) + + spriteClient, err := sprite.NewClient() + if err != nil { + // Sprites client is optional for development + log.Warn("sprites client not configured", "error", err) + } + + var spriteMgr *sprite.Manager + var spriteProxy *sprite.ProxyHandler + if spriteClient != nil { + spriteMgr = sprite.NewManager(spriteClient, cfg.DB) + spriteProxy = sprite.NewProxyHandler(spriteMgr) + } + + return &Server{ + addr: cfg.Addr, + db: cfg.DB, + sessionMgr: sessionMgr, + spriteClient: spriteClient, + spriteMgr: spriteMgr, + spriteProxy: spriteProxy, + baseURL: cfg.BaseURL, + staticFS: cfg.StaticFS, + logger: log.NewWithOptions(os.Stderr, log.Options{Prefix: "webserver"}), + }, nil +} + +// Start starts the server. +func (s *Server) Start(ctx context.Context) error { + mux := http.NewServeMux() + + // Auth routes + mux.HandleFunc("GET /api/auth/google", s.handleGoogleAuth) + mux.HandleFunc("GET /api/auth/github", s.handleGitHubAuth) + mux.HandleFunc("GET /api/auth/callback", s.handleOAuthCallback) + mux.HandleFunc("POST /api/auth/logout", s.handleLogout) + mux.HandleFunc("GET /api/auth/me", s.handleGetMe) + + // Sprite management routes + mux.HandleFunc("GET /api/sprite", s.requireAuth(s.handleGetSprite)) + mux.HandleFunc("POST /api/sprite", s.requireAuth(s.handleCreateSprite)) + mux.HandleFunc("POST /api/sprite/start", s.requireAuth(s.handleStartSprite)) + mux.HandleFunc("POST /api/sprite/stop", s.requireAuth(s.handleStopSprite)) + mux.HandleFunc("DELETE /api/sprite", s.requireAuth(s.handleDestroySprite)) + mux.HandleFunc("GET /api/sprite/ssh-config", s.requireAuth(s.handleGetSSHConfig)) + mux.HandleFunc("GET /api/export", s.requireAuth(s.handleExport)) + + // Proxied routes to sprite + mux.HandleFunc("GET /api/tasks", s.requireAuth(s.handleProxyTasks)) + mux.HandleFunc("POST /api/tasks", s.requireAuth(s.handleProxyTasks)) + mux.HandleFunc("GET /api/tasks/{id}", s.requireAuth(s.handleProxyTasks)) + mux.HandleFunc("PUT /api/tasks/{id}", s.requireAuth(s.handleProxyTasks)) + mux.HandleFunc("DELETE /api/tasks/{id}", s.requireAuth(s.handleProxyTasks)) + mux.HandleFunc("POST /api/tasks/{id}/queue", s.requireAuth(s.handleProxyTasks)) + mux.HandleFunc("POST /api/tasks/{id}/retry", s.requireAuth(s.handleProxyTasks)) + mux.HandleFunc("POST /api/tasks/{id}/close", s.requireAuth(s.handleProxyTasks)) + + mux.HandleFunc("GET /api/projects", s.requireAuth(s.handleProxyTasks)) + mux.HandleFunc("POST /api/projects", s.requireAuth(s.handleProxyTasks)) + mux.HandleFunc("GET /api/projects/{id}", s.requireAuth(s.handleProxyTasks)) + mux.HandleFunc("PUT /api/projects/{id}", s.requireAuth(s.handleProxyTasks)) + mux.HandleFunc("DELETE /api/projects/{id}", s.requireAuth(s.handleProxyTasks)) + + mux.HandleFunc("GET /api/settings", s.requireAuth(s.handleProxyTasks)) + mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleProxyTasks)) + + // WebSocket endpoint + mux.HandleFunc("GET /api/ws", s.requireAuth(s.handleWebSocket)) + + // Health check + mux.HandleFunc("GET /health", s.handleHealth) + + // Static files (React app) + if s.staticFS != nil { + mux.Handle("/", s.staticFileHandler()) + } + + // Apply middleware + handler := s.corsMiddleware(s.loggingMiddleware(mux)) + + server := &http.Server{ + Addr: s.addr, + Handler: handler, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + s.logger.Info("starting server", "addr", s.addr) + + // Start server in goroutine + errCh := make(chan error, 1) + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + + // Wait for context cancellation or error + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return server.Shutdown(shutdownCtx) + case err := <-errCh: + return err + } +} + +// staticFileHandler returns a handler for serving static files with SPA support. +func (s *Server) staticFileHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Clean the path + p := path.Clean(r.URL.Path) + if p == "/" { + p = "/index.html" + } + + // Try to open the file + filePath := strings.TrimPrefix(p, "/") + f, err := s.staticFS.Open(filePath) + if err != nil { + // File not found, serve index.html for SPA routing + f, err = s.staticFS.Open("index.html") + if err != nil { + http.NotFound(w, r) + return + } + filePath = "index.html" + } + defer f.Close() + + // Get file info + stat, err := f.Stat() + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // If it's a directory, serve index.html + if stat.IsDir() { + f.Close() + indexPath := path.Join(filePath, "index.html") + f, err = s.staticFS.Open(indexPath) + if err != nil { + // Fallback to root index.html for SPA + f, err = s.staticFS.Open("index.html") + if err != nil { + http.NotFound(w, r) + return + } + } + } + + // Set content type based on extension + contentType := getContentType(p) + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + + // Read file content and serve + content, err := io.ReadAll(f) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.Write(content) + }) +} + +// getContentType returns the content type for a file extension. +func getContentType(path string) string { + switch { + case strings.HasSuffix(path, ".html"): + return "text/html; charset=utf-8" + case strings.HasSuffix(path, ".css"): + return "text/css; charset=utf-8" + case strings.HasSuffix(path, ".js"): + return "application/javascript" + case strings.HasSuffix(path, ".json"): + return "application/json" + case strings.HasSuffix(path, ".svg"): + return "image/svg+xml" + case strings.HasSuffix(path, ".png"): + return "image/png" + case strings.HasSuffix(path, ".ico"): + return "image/x-icon" + default: + return "" + } +} + +// handleHealth handles health check requests. +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 00000000..5e6e247f --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + }, + }, +) diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..a3ddbacd --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + taskyou + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 00000000..56b14965 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,5693 @@ +{ + "name": "taskyou-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "taskyou-web", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-select": "^2.1.4", + "@radix-ui/react-separator": "^1.1.1", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.2", + "@radix-ui/react-toast": "^1.2.4", + "@radix-ui/react-tooltip": "^1.1.6", + "@xterm/addon-attach": "^0.12.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^11.18.2", + "lucide-react": "^0.469.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "~5.6.2", + "typescript-eslint": "^8.18.2", + "vite": "^6.0.5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", + "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", + "devOptional": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", + "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/type-utils": "8.53.0", + "@typescript-eslint/utils": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.53.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz", + "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz", + "integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==", + "dev": true, + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.53.0", + "@typescript-eslint/types": "^8.53.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz", + "integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz", + "integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.0.tgz", + "integrity": "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/utils": "8.53.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz", + "integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz", + "integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==", + "dev": true, + "dependencies": { + "@typescript-eslint/project-service": "8.53.0", + "@typescript-eslint/tsconfig-utils": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz", + "integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz", + "integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@xterm/addon-attach": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-attach/-/addon-attach-0.12.0.tgz", + "integrity": "sha512-1lxvXM4JYSm60lbFmE8WMOy2oF2ip3Ye8jWorSAmwy7x8FiC53netEJ5RguL8+FSRj79MUQsNCb2hprY2QA2ig==", + "license": "MIT" + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", + "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001764", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", + "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "11.18.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", + "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "dependencies": { + "motion-dom": "^11.18.1", + "motion-utils": "^11.18.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.469.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", + "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/motion-dom": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", + "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", + "dependencies": { + "motion-utils": "^11.18.1" + } + }, + "node_modules/motion-utils": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz", + "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", + "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", + "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", + "dependencies": { + "react-router": "7.12.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", + "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.53.0.tgz", + "integrity": "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.53.0", + "@typescript-eslint/parser": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/utils": "8.53.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 00000000..f5f2e522 --- /dev/null +++ b/web/package.json @@ -0,0 +1,53 @@ +{ + "name": "taskyou-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-select": "^2.1.4", + "@radix-ui/react-separator": "^1.1.1", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.2", + "@radix-ui/react-toast": "^1.2.4", + "@radix-ui/react-tooltip": "^1.1.6", + "@xterm/addon-attach": "^0.12.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^11.18.2", + "lucide-react": "^0.469.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "~5.6.2", + "typescript-eslint": "^8.18.2", + "vite": "^6.0.5" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 00000000..2e7af2b7 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 00000000..f8ee62cd --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react'; +import { useAuth } from './hooks/useAuth'; +import { LoginPage } from './pages/LoginPage'; +import { DashboardPage } from './pages/DashboardPage'; +import { SettingsPage } from './pages/SettingsPage'; + +type View = 'dashboard' | 'settings'; + +function App() { + const { user, loading, logout } = useAuth(); + const [view, setView] = useState('dashboard'); + + if (loading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!user) { + return ; + } + + if (view === 'settings') { + return setView('dashboard')} />; + } + + return ( + setView('settings')} + /> + ); +} + +export default App; diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 00000000..d5886a87 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,161 @@ +import type { + User, + Sprite, + Task, + CreateTaskRequest, + UpdateTaskRequest, + Project, + CreateProjectRequest, + UpdateProjectRequest, + TaskLog, +} from './types'; + +// In development, use the local API server. In production, use relative /api path. +const API_BASE = import.meta.env.DEV ? 'http://localhost:8081' : '/api'; + +async function fetchJSON(path: string, options?: RequestInit): Promise { + const response = await fetch(`${API_BASE}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + credentials: 'include', + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Request failed'); + } + + // Handle 204 No Content + if (response.status === 204) { + return undefined as T; + } + + return response.json(); +} + +// Auth API +export const auth = { + getMe: () => fetchJSON('/auth/me'), + logout: () => fetchJSON<{ success: boolean }>('/auth/logout', { method: 'POST' }), +}; + +// Sprite API +export const sprite = { + get: () => fetchJSON('/sprite'), + create: () => fetchJSON('/sprite', { method: 'POST' }), + start: () => fetchJSON<{ success: boolean }>('/sprite/start', { method: 'POST' }), + stop: () => fetchJSON<{ success: boolean }>('/sprite/stop', { method: 'POST' }), + destroy: () => fetchJSON<{ success: boolean }>('/sprite', { method: 'DELETE' }), + getSSHConfig: async () => { + const response = await fetch(`${API_BASE}/sprite/ssh-config`, { + credentials: 'include', + }); + if (!response.ok) { + throw new Error('Failed to get SSH config'); + } + return response.text(); + }, +}; + +// Tasks API +export const tasks = { + list: (options?: { status?: string; project?: string; type?: string; all?: boolean }) => { + const params = new URLSearchParams(); + if (options?.status) params.set('status', options.status); + if (options?.project) params.set('project', options.project); + if (options?.type) params.set('type', options.type); + if (options?.all) params.set('all', 'true'); + const query = params.toString(); + return fetchJSON(`/tasks${query ? `?${query}` : ''}`); + }, + get: (id: number) => fetchJSON(`/tasks/${id}`), + create: (data: CreateTaskRequest) => + fetchJSON('/tasks', { + method: 'POST', + body: JSON.stringify(data), + }), + update: (id: number, data: UpdateTaskRequest) => + fetchJSON(`/tasks/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + delete: (id: number) => + fetchJSON(`/tasks/${id}`, { method: 'DELETE' }), + queue: (id: number) => + fetchJSON(`/tasks/${id}/queue`, { method: 'POST' }), + retry: (id: number, feedback?: string) => + fetchJSON(`/tasks/${id}/retry`, { + method: 'POST', + body: JSON.stringify({ feedback }), + }), + close: (id: number) => + fetchJSON(`/tasks/${id}/close`, { method: 'POST' }), + getLogs: (id: number, limit?: number) => { + const params = limit ? `?limit=${limit}` : ''; + return fetchJSON(`/tasks/${id}/logs${params}`); + }, + getTerminal: (id: number) => + fetchJSON(`/tasks/${id}/terminal`), + stopTerminal: (id: number) => + fetchJSON(`/tasks/${id}/terminal`, { method: 'DELETE' }), +}; + +// Terminal info returned by the API +export interface TerminalInfo { + task_id: number; + port: number; + tmux_target: string; + websocket_url: string; +} + +// Projects API +export const projects = { + list: () => fetchJSON('/projects'), + get: (id: number) => fetchJSON(`/projects/${id}`), + create: (data: CreateProjectRequest) => + fetchJSON('/projects', { + method: 'POST', + body: JSON.stringify(data), + }), + update: (id: number, data: UpdateProjectRequest) => + fetchJSON(`/projects/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + delete: (id: number) => + fetchJSON(`/projects/${id}`, { method: 'DELETE' }), +}; + +// Settings API +export const settings = { + get: () => fetchJSON>('/settings'), + update: (data: Record) => + fetchJSON<{ success: boolean }>('/settings', { + method: 'PUT', + body: JSON.stringify(data), + }), +}; + +// Export API +export const exportData = { + download: async () => { + const response = await fetch(`${API_BASE}/export`, { + credentials: 'include', + }); + if (!response.ok) { + throw new Error('Failed to export data'); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'tasks-export.db'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, +}; diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 00000000..6b2a0ce7 --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,102 @@ +// User and authentication types +export interface User { + id: string; + email: string; + name: string; + avatar_url: string; + sprite?: Sprite | null; +} + +export interface Sprite { + id: string; + name: string; + status: 'pending' | 'creating' | 'running' | 'stopped' | 'error'; + region: string; + created_at: string; +} + +// Task types +export type TaskStatus = 'backlog' | 'queued' | 'processing' | 'blocked' | 'done'; + +export interface Task { + id: number; + title: string; + body: string; + status: TaskStatus; + type: string; + project: string; + worktree_path?: string; + branch_name?: string; + port?: number; + pr_url?: string; + pr_number?: number; + dangerous_mode: boolean; + scheduled_at?: string; + recurrence?: string; + last_run_at?: string; + created_at: string; + updated_at: string; + started_at?: string; + completed_at?: string; +} + +export interface CreateTaskRequest { + title: string; + body?: string; + type?: string; + project?: string; + scheduled_at?: string; + recurrence?: string; +} + +export interface UpdateTaskRequest { + title?: string; + body?: string; + status?: TaskStatus; + type?: string; + project?: string; + scheduled_at?: string; + recurrence?: string; +} + +// Project types +export interface Project { + id: number; + name: string; + path: string; + aliases: string; + instructions: string; + color: string; + created_at: string; +} + +export interface CreateProjectRequest { + name: string; + path: string; + aliases?: string; + instructions?: string; + color?: string; +} + +export interface UpdateProjectRequest { + name?: string; + path?: string; + aliases?: string; + instructions?: string; + color?: string; +} + +// Task log types +export interface TaskLog { + id: number; + task_id: number; + line_type: 'system' | 'text' | 'tool' | 'error' | 'output'; + content: string; + created_at: string; +} + +// WebSocket message types +export type WebSocketMessage = + | { type: 'task_update'; data: Task } + | { type: 'task_deleted'; data: { id: number } } + | { type: 'task_log'; data: { task_id: number; log: TaskLog } }; diff --git a/web/src/components/CommandPalette.tsx b/web/src/components/CommandPalette.tsx new file mode 100644 index 00000000..b520fe8f --- /dev/null +++ b/web/src/components/CommandPalette.tsx @@ -0,0 +1,314 @@ +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Search, + Clock, + Zap, + AlertCircle, + CheckCircle, + Plus, + Settings, + ArrowRight, + Command, +} from 'lucide-react'; +import type { Task } from '@/api/types'; +import { cn } from '@/lib/utils'; + +interface CommandPaletteProps { + isOpen: boolean; + onClose: () => void; + tasks: Task[]; + onSelectTask: (task: Task) => void; + onNewTask: () => void; + onSettings: () => void; +} + +interface CommandItem { + id: string; + type: 'task' | 'action'; + task?: Task; + label: string; + description?: string; + icon: React.ElementType; + iconColor?: string; + action?: () => void; +} + +const statusIcons: Record = { + backlog: { icon: Clock, color: 'text-[hsl(var(--status-backlog))]' }, + queued: { icon: Clock, color: 'text-[hsl(var(--status-queued))]' }, + processing: { icon: Zap, color: 'text-[hsl(var(--status-processing))]' }, + blocked: { icon: AlertCircle, color: 'text-[hsl(var(--status-blocked))]' }, + done: { icon: CheckCircle, color: 'text-[hsl(var(--status-done))]' }, +}; + +const defaultStatusIcon = { icon: Clock, color: 'text-muted-foreground' }; + +export function CommandPalette({ + isOpen, + onClose, + tasks, + onSelectTask, + onNewTask, + onSettings, +}: CommandPaletteProps) { + const [query, setQuery] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Reset state when opened + useEffect(() => { + if (isOpen) { + setQuery(''); + setSelectedIndex(0); + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [isOpen]); + + // Build filtered items + const items = useMemo(() => { + const actions: CommandItem[] = [ + { + id: 'new-task', + type: 'action', + label: 'New Task', + description: 'Create a new task', + icon: Plus, + iconColor: 'text-primary', + action: () => { + onClose(); + onNewTask(); + }, + }, + { + id: 'settings', + type: 'action', + label: 'Settings', + description: 'Open settings', + icon: Settings, + iconColor: 'text-muted-foreground', + action: () => { + onClose(); + onSettings(); + }, + }, + ]; + + const taskItems: CommandItem[] = tasks.map((task) => { + const statusInfo = statusIcons[task.status] || defaultStatusIcon; + return { + id: `task-${task.id}`, + type: 'task', + task, + label: task.title, + description: task.project !== 'personal' ? task.project : undefined, + icon: statusInfo.icon, + iconColor: statusInfo.color, + action: () => { + onClose(); + onSelectTask(task); + }, + }; + }); + + if (!query.trim()) { + // Show recent/active tasks first, then actions + const activeTasks = taskItems.filter( + (t) => t.task && ['processing', 'queued', 'blocked'].includes(t.task.status) + ); + const recentTasks = taskItems + .filter((t) => t.task && !['processing', 'queued', 'blocked'].includes(t.task.status)) + .slice(0, 5); + + return [...actions, ...activeTasks, ...recentTasks]; + } + + const lowerQuery = query.toLowerCase(); + + // Filter tasks by query + const filteredTasks = taskItems.filter((item) => { + if (!item.task) return false; + const task = item.task; + return ( + task.title.toLowerCase().includes(lowerQuery) || + task.project.toLowerCase().includes(lowerQuery) || + task.id.toString().includes(lowerQuery) || + (task.pr_number && task.pr_number.toString().includes(lowerQuery)) || + (task.pr_url && task.pr_url.toLowerCase().includes(lowerQuery)) + ); + }); + + // Filter actions by query + const filteredActions = actions.filter( + (item) => item.label.toLowerCase().includes(lowerQuery) + ); + + return [...filteredActions, ...filteredTasks]; + }, [tasks, query, onClose, onSelectTask, onNewTask, onSettings]); + + // Clamp selected index + useEffect(() => { + if (selectedIndex >= items.length) { + setSelectedIndex(Math.max(0, items.length - 1)); + } + }, [items.length, selectedIndex]); + + // Scroll selected item into view + useEffect(() => { + const selectedEl = listRef.current?.children[selectedIndex] as HTMLElement; + selectedEl?.scrollIntoView({ block: 'nearest' }); + }, [selectedIndex]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex((i) => Math.min(i + 1, items.length - 1)); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, 0)); + break; + case 'Enter': + e.preventDefault(); + items[selectedIndex]?.action?.(); + break; + case 'Escape': + e.preventDefault(); + onClose(); + break; + } + }, + [items, selectedIndex, onClose] + ); + + // Global keyboard shortcut + useEffect(() => { + const handleGlobalKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + if (isOpen) { + onClose(); + } + } + }; + + window.addEventListener('keydown', handleGlobalKeyDown); + return () => window.removeEventListener('keydown', handleGlobalKeyDown); + }, [isOpen, onClose]); + + return ( + + {isOpen && ( + + {/* Backdrop */} + + + {/* Dialog */} + + {/* Search input */} +
+ + { + setQuery(e.target.value); + setSelectedIndex(0); + }} + onKeyDown={handleKeyDown} + placeholder="Search tasks, run actions..." + className="flex-1 bg-transparent text-base outline-none placeholder:text-muted-foreground" + /> + + K + +
+ + {/* Results */} +
+ {items.length === 0 ? ( +
+ No results found for "{query}" +
+ ) : ( + items.map((item, index) => { + const Icon = item.icon; + const isSelected = index === selectedIndex; + + return ( + + ); + }) + )} +
+ + {/* Footer */} +
+
+ + ↑↓ + navigate + + + ↵ + select + + + esc + close + +
+ {items.length} results +
+
+
+ )} +
+ ); +} diff --git a/web/src/components/Terminal.tsx b/web/src/components/Terminal.tsx new file mode 100644 index 00000000..00b4c29b --- /dev/null +++ b/web/src/components/Terminal.tsx @@ -0,0 +1,246 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { Terminal as XTerm } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { Loader2, AlertCircle, Terminal as TerminalIcon, RefreshCw, Download } from 'lucide-react'; +import { tasks } from '@/api/client'; +import { cn } from '@/lib/utils'; +import '@xterm/xterm/css/xterm.css'; + +interface TerminalProps { + taskId: number; + className?: string; +} + +type TerminalState = 'connecting' | 'connected' | 'disconnected' | 'error' | 'no-session' | 'no-ttyd'; + +export function TaskTerminal({ taskId, className }: TerminalProps) { + const containerRef = useRef(null); + const terminalRef = useRef(null); + const fitAddonRef = useRef(null); + const wsRef = useRef(null); + const [state, setState] = useState('connecting'); + const [error, setError] = useState(null); + + const connect = useCallback(async () => { + if (!containerRef.current) return; + + setState('connecting'); + setError(null); + + try { + // Get terminal connection info from the API + const terminalInfo = await tasks.getTerminal(taskId); + + // Clean up any existing terminal + if (terminalRef.current) { + terminalRef.current.dispose(); + terminalRef.current = null; + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + + // Create terminal instance + const terminal = new XTerm({ + cursorBlink: true, + fontSize: 14, + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + theme: { + background: '#0f172a', + foreground: '#e2e8f0', + cursor: '#e2e8f0', + cursorAccent: '#0f172a', + selectionBackground: '#334155', + black: '#1e293b', + red: '#ef4444', + green: '#22c55e', + yellow: '#eab308', + blue: '#3b82f6', + magenta: '#a855f7', + cyan: '#06b6d4', + white: '#f8fafc', + brightBlack: '#475569', + brightRed: '#f87171', + brightGreen: '#4ade80', + brightYellow: '#facc15', + brightBlue: '#60a5fa', + brightMagenta: '#c084fc', + brightCyan: '#22d3ee', + brightWhite: '#ffffff', + }, + }); + + terminalRef.current = terminal; + + // Add fit addon + const fitAddon = new FitAddon(); + fitAddonRef.current = fitAddon; + terminal.loadAddon(fitAddon); + + // Open terminal in container + terminal.open(containerRef.current); + fitAddon.fit(); + + // Connect to ttyd websocket + const ws = new WebSocket(terminalInfo.websocket_url); + wsRef.current = ws; + + ws.onopen = () => { + setState('connected'); + terminal.focus(); + + // Send terminal size to ttyd + // ttyd protocol: byte 1 + JSON for resize (using actual byte values, not ASCII) + const sendSize = () => { + const msg = JSON.stringify({ columns: terminal.cols, rows: terminal.rows }); + ws.send(String.fromCharCode(1) + msg); + }; + sendSize(); + terminal.onResize(sendSize); + }; + + // Handle incoming data from ttyd + ws.onmessage = (event) => { + const data = event.data; + if (typeof data === 'string' && data.length > 0) { + const cmd = data.charCodeAt(0); + const payload = data.substring(1); + if (cmd === 0) { + // Output message - write to terminal + terminal.write(payload); + } + // cmd 1 = window title, 2 = preferences (ignored) + } + }; + + // Send input to ttyd + // ttyd protocol: byte 0 + input data (using actual byte values, not ASCII) + terminal.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(String.fromCharCode(0) + data); + } + }); + + ws.onclose = () => { + if (state !== 'error') { + setState('disconnected'); + } + }; + + ws.onerror = () => { + setState('error'); + setError('WebSocket connection failed'); + }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to connect'; + if (message.includes('no active terminal') || message.includes('not running')) { + setState('no-session'); + } else if (message.includes('ttyd not installed')) { + setState('no-ttyd'); + } else { + setState('error'); + setError(message); + } + } + }, [taskId, state]); + + // Connect on mount and when taskId changes + useEffect(() => { + connect(); + + return () => { + if (terminalRef.current) { + terminalRef.current.dispose(); + terminalRef.current = null; + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps + + // Handle window resize + useEffect(() => { + const handleResize = () => { + if (fitAddonRef.current && terminalRef.current) { + fitAddonRef.current.fit(); + } + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + // Render overlay for non-connected states + const renderOverlay = () => { + if (state === 'connected') return null; + + return ( +
+ {state === 'connecting' && ( + <> + +

Connecting to terminal...

+ + )} + {state === 'no-session' && ( + <> + +

No active terminal session

+

+ The task must be running to view its terminal +

+ + )} + {state === 'no-ttyd' && ( + <> + +

ttyd not installed

+

+ Install ttyd to enable live terminal streaming +

+ + brew install ttyd + + + )} + {state === 'disconnected' && ( + <> + +

Terminal disconnected

+ + + )} + {state === 'error' && ( + <> + +

Connection failed

+

{error}

+ + + )} +
+ ); + }; + + return ( +
+
+ {renderOverlay()} +
+ ); +} diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx new file mode 100644 index 00000000..f8bbb199 --- /dev/null +++ b/web/src/components/layout/Header.tsx @@ -0,0 +1,196 @@ +import { useState, useEffect } from 'react'; +import { + Search, + Settings, + LogOut, + Moon, + Sun, + Monitor, + Command, + Zap, + ChevronDown, +} from 'lucide-react'; +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import type { User } from '@/api/types'; + +interface HeaderProps { + user: User; + onLogout: () => void; + onSettings: () => void; + onCommandPalette: () => void; +} + +type Theme = 'light' | 'dark' | 'system'; + +export function Header({ user, onLogout, onSettings, onCommandPalette }: HeaderProps) { + const [showUserMenu, setShowUserMenu] = useState(false); + const [theme, setTheme] = useState(() => { + const stored = localStorage.getItem('theme') as Theme; + return stored || 'system'; + }); + + // Apply theme on mount and changes + useEffect(() => { + const root = document.documentElement; + if (theme === 'system') { + const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + root.classList.toggle('dark', systemDark); + } else { + root.classList.toggle('dark', theme === 'dark'); + } + }, [theme]); + + const handleThemeChange = (newTheme: Theme) => { + setTheme(newTheme); + localStorage.setItem('theme', newTheme); + }; + + const getInitials = (name: string) => { + return name + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2); + }; + + const themeIcons = { + light: Sun, + dark: Moon, + system: Monitor, + }; + const ThemeIcon = themeIcons[theme]; + + return ( +
+
+
+ {/* Logo */} +
+
+
+ +
+ + taskyou + +
+ {user.sprite && ( + + {user.sprite.status === 'running' ? ( + <> + + Online + + ) : ( + <> + + {user.sprite.status} + + )} + + )} +
+ + {/* Center - Search Trigger */} + + + {/* Right side */} +
+ {/* Mobile search */} + + + {/* Theme toggle */} + + + {/* Settings */} + + + {/* User menu */} +
+ + + {/* Dropdown */} + {showUserMenu && ( + <> +
setShowUserMenu(false)} + /> +
+
+

{user.name}

+

{user.email}

+
+ + +
+ + )} +
+
+
+
+
+ ); +} diff --git a/web/src/components/projects/ProjectDialog.tsx b/web/src/components/projects/ProjectDialog.tsx new file mode 100644 index 00000000..bc9301ec --- /dev/null +++ b/web/src/components/projects/ProjectDialog.tsx @@ -0,0 +1,170 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import type { Project, CreateProjectRequest, UpdateProjectRequest } from '@/api/types'; + +interface ProjectDialogProps { + project?: Project | null; + onSubmit: (data: CreateProjectRequest | UpdateProjectRequest) => Promise; + onDelete?: () => Promise; + onClose: () => void; +} + +const COLORS = [ + '#C678DD', // Purple + '#61AFEF', // Blue + '#56B6C2', // Cyan + '#98C379', // Green + '#E5C07B', // Yellow + '#E06C75', // Red/Pink + '#D19A66', // Orange + '#ABB2BF', // Gray +]; + +export function ProjectDialog({ project, onSubmit, onDelete, onClose }: ProjectDialogProps) { + const [name, setName] = useState(project?.name || ''); + const [path, setPath] = useState(project?.path || ''); + const [aliases, setAliases] = useState(project?.aliases || ''); + const [instructions, setInstructions] = useState(project?.instructions || ''); + const [color, setColor] = useState(project?.color || COLORS[0]); + const [loading, setLoading] = useState(false); + const [deleting, setDeleting] = useState(false); + + const isEditing = !!project; + const isPersonal = project?.name === 'personal'; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name || !path) return; + + setLoading(true); + try { + await onSubmit({ + name, + path, + aliases, + instructions, + color, + }); + onClose(); + } catch (err) { + console.error('Failed to save project:', err); + } finally { + setLoading(false); + } + }; + + const handleDelete = async () => { + if (!onDelete) return; + if (!window.confirm(`Delete project "${name}"? Tasks will not be deleted.`)) return; + + setDeleting(true); + try { + await onDelete(); + onClose(); + } catch (err) { + console.error('Failed to delete project:', err); + } finally { + setDeleting(false); + } + }; + + return ( +
+
+
+

+ {isEditing ? 'Edit Project' : 'New Project'} +

+ +
+
+ + setName(e.target.value)} + placeholder="project-name" + disabled={isPersonal} + required + /> + {isPersonal && ( +

+ The personal project cannot be renamed +

+ )} +
+ +
+ + setPath(e.target.value)} + placeholder="/path/to/project" + required + /> +
+ +
+ + setAliases(e.target.value)} + placeholder="alias1, alias2" + /> +
+ +
+ +