diff --git a/.env.example b/.env.example index 6c6e033..39bd7de 100644 --- a/.env.example +++ b/.env.example @@ -1,79 +1,18 @@ -# For local development only - uncomment these vars -# OSSE_PROTOCOL="http" -# OSSE_HOST="localhost" -# OSSE_SERVER_PORT="4200" -# OSSE_API_PORT="8000" -# OSSE_BROADCAST_PORT="9003" -# OSSE_BROADCAST_INTERNAL_PORT="9004" -# OSSE_REDIS_PORT="9005" -# OSSE_SERVER_URL="localhost:4200" -# OSSE_DIRECTORIES="~/Music" -# OSSE_ALLOW_REGISTRATION=true - - -APP_NAME=osse -APP_ENV=local -APP_KEY= -APP_DEBUG=true -APP_TIMEZONE=UTC -APP_URL=http://localhost - -APP_LOCALE=en -APP_FALLBACK_LOCALE=en -APP_FAKER_LOCALE=en_US - -APP_MAINTENANCE_DRIVER=file -# APP_MAINTENANCE_STORE=database - -PHP_CLI_SERVER_WORKERS=4 - -BCRYPT_ROUNDS=12 - -LOG_CHANNEL=stack -LOG_STACK=single -LOG_DEPRECATIONS_CHANNEL=null -LOG_LEVEL=debug - -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=laravel -# DB_USERNAME=root -# DB_PASSWORD= - -SESSION_DRIVER=redis -SESSION_LIFETIME=120 -SESSION_ENCRYPT=false -SESSION_PATH=/ -SESSION_DOMAIN=.localhost - -BROADCAST_CONNECTION=redis -FILESYSTEM_DISK=local -QUEUE_CONNECTION=database - -CACHE_STORE=redis -CACHE_PREFIX=cache_ - -MEMCACHED_HOST=127.0.0.1 - -REDIS_CLIENT=phpredis -REDIS_HOST=127.0.0.1 -REDIS_PASSWORD=null -REDIS_PORT=6379 - -MAIL_MAILER=log -MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 -MAIL_USERNAME=null -MAIL_PASSWORD=null -MAIL_ENCRYPTION=null -MAIL_FROM_ADDRESS="hello@example.com" -MAIL_FROM_NAME="${APP_NAME}" - -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION=us-east-1 -AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false - -VITE_APP_NAME="${APP_NAME}" +OSSE_DOMAIN=osse.localhost +# Can be local or prod +OSSE_ENV=prod +# Internal ports (never exposed publicly, but they need to not be in use) +OSSE_API_PORT=8000 +OSSE_BROADCAST_PORT=8090 +# 4200 is also in use if in development mode. +# Redis instance (include the domain and the port). Valkey works fine too! +OSSE_REDIS_HOST="localhost:6379" + +# The paths to scan for music. See examples below. Only absolute paths are supported (no ~ or env vars). Separate directories with comma. +export OSSE_DIRECTORIES="" +# export OSSE_DIRECTORIES="/home/me/Music,/mnt/server1/files" +# If true, allow new accounts to be created. Once you make your account, set this to false. +export OSSE_ALLOW_REGISTRATION=true + +# Storage (config, cache, database). Path will be created if not present. +LARAVEL_STORAGE_PATH="~/.config/osse-2" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f2dcc01..abf3f4f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,8 +17,10 @@ jobs: with: php-version: '8.4' - uses: actions/checkout@v4 + - name: Move to osse-core + run: cd osse-core - name: Copy .env - run: php -r "file_exists('.env') || copy('.env.example', '.env');" + run: php -r "file_exists('.env') || copy('.env.testing', '.env');" - name: Install Dependencies run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist - name: Generate key diff --git a/.gitignore b/.gitignore index c417dba..acd0b35 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,2 @@ -/.phpunit.cache -/node_modules -/public/build -/public/hot -/public/storage -/storage/*.key -/storage/pail -/vendor .env -.env.backup -.env.production -.phpactor.json -.phpunit.result.cache -Homestead.json -Homestead.yaml -auth.json -npm-debug.log -yarn-error.log -/.fleet -/.idea -/.nova -/.vscode -/.zed -osse.db -public/dist -storage/osse_setup -certs/ -user-conf/ +Caddyfile diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 642fa69..0000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule "osse-web"] - path = osse-web - url = https://github.com/aMytho/osse-web -[submodule "osse-broadcast"] - path = osse-broadcast - url = https://github.com/aMytho/osse-broadcast diff --git a/README.md b/README.md index 30bd6fc..80c471b 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,10 @@ Osse is a free and open source music player and server. This repository is the * > Interested in helping us test? Use the below instructions for an installation. -Docker is the recommended method of installation. You can also use Podman. Alternatively, you can run Osse on your local machine by installing it's dependencies. + Systemd files are available in this repo (outdated) -Devices with constrained resources should use the manual installation for performance reasons. Systemd files are available in this repo. - -- [Docker/Podman installation](https://github.com/aMytho/osse/wiki/Installation-(Docker-Podman)) - [Manual Installation](https://github.com/aMytho/osse/wiki/Installation-(Manual-System)) +- [Docker/Podman installation (outdated)](https://github.com/aMytho/osse/wiki/Installation-(Docker-Podman)) ## Providing Feedback diff --git a/deployment/Caddyfile.template b/deployment/Caddyfile.template new file mode 100644 index 0000000..2d6d76f --- /dev/null +++ b/deployment/Caddyfile.template @@ -0,0 +1,52 @@ +{ + frankenphp + admin off + auto_https disable_redirects + storage file_system { + root $HOME/.local/share/caddy + } +} + +https://${OSSE_DOMAIN} { + # Laravel sanctum + handle_path /api/sanctum/csrf-cookie { + root * osse-core/public + php_server { + env REQUEST_URI sanctum/csrf-cookie + try_files {path} index.php + } + } + + # Laravel login + handle_path /api/login { + root * osse-core/public + php_server { + env REQUEST_URI login + try_files {path} index.php + } + } + + # Laravel API (all other backend routes) + handle_path /api/* { + root * osse-core/public + php_server { + try_files {path} index.php + } + } + + + # Broadcast server + handle_path /broadcast/* { + reverse_proxy 127.0.0.1:${OSSE_BROADCAST_PORT} + } + + # Web frontend (this is replaced in the root osse script as it changes based on env) + handle { + root * osse-web/dist/osse-web/browser/ + + WEB_FRONTEND_TEMPLATE + } + + encode zstd br gzip +} + diff --git a/deployment/php.ini b/deployment/php.ini new file mode 100644 index 0000000..59e6789 --- /dev/null +++ b/deployment/php.ini @@ -0,0 +1,4 @@ +; deployment/php.ini +memory_limit = 2G +max_execution_time = 0 +opcache.enable_cli = 1 diff --git a/osse-broadcast b/osse-broadcast deleted file mode 160000 index 2972c90..0000000 --- a/osse-broadcast +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2972c906b4f1610874935724802be306ab241642 diff --git a/osse-broadcast/.dockerignore b/osse-broadcast/.dockerignore new file mode 100644 index 0000000..94c3d6b --- /dev/null +++ b/osse-broadcast/.dockerignore @@ -0,0 +1,3 @@ +.git +.github + diff --git a/osse-broadcast/.github/workflows/deploy.yml b/osse-broadcast/.github/workflows/deploy.yml new file mode 100644 index 0000000..751df2d --- /dev/null +++ b/osse-broadcast/.github/workflows/deploy.yml @@ -0,0 +1,40 @@ +name: Build Go Project + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + name: Build Go Binary + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Install Dependencies + run: go mod tidy + + - name: Build for Linux (x86_64) + run: | + GOOS=linux GOARCH=amd64 go build -o bin/osse-broadcast-linux-amd64 + + - name: Build for Linux (ARM64) + run: | + GOOS=linux GOARCH=arm64 go build -o bin/osse-broadcast-linux-arm64 + + - name: Upload Binaries + uses: actions/upload-artifact@v4 + with: + name: go-binaries + path: bin/ diff --git a/osse-broadcast/.gitignore b/osse-broadcast/.gitignore new file mode 100644 index 0000000..9f96217 --- /dev/null +++ b/osse-broadcast/.gitignore @@ -0,0 +1,2 @@ +bin/ +.env diff --git a/osse-broadcast/Dockerfile b/osse-broadcast/Dockerfile new file mode 100644 index 0000000..81e474f --- /dev/null +++ b/osse-broadcast/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.24-alpine + +WORKDIR /usr/src/app + +# pre-copy/cache go.mod for pre-downloading dependencies and only redownloading them in subsequent builds if they change +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN go build -v -o /usr/local/bin/osse-broadcast . + +ENTRYPOINT [ "sh", "prod-run.sh" ] diff --git a/osse-broadcast/README.md b/osse-broadcast/README.md new file mode 100644 index 0000000..f5a9809 --- /dev/null +++ b/osse-broadcast/README.md @@ -0,0 +1,7 @@ +# Osse-Broadcast + +This is the SSE server for the Osse music server. + +Osse is written in PHP. This works great for requests, but not so great for long lived connections. + +This server is in go, highly concurrent, and much lighter than the Laravel Reverb implementation. diff --git a/osse-broadcast/go.mod b/osse-broadcast/go.mod new file mode 100644 index 0000000..bb398d5 --- /dev/null +++ b/osse-broadcast/go.mod @@ -0,0 +1,13 @@ +module osse-broadcast + +go 1.24.1 + +require ( + github.com/redis/go-redis/v9 v9.7.3 + github.com/tmaxmax/go-sse v0.10.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect +) diff --git a/osse-broadcast/go.sum b/osse-broadcast/go.sum new file mode 100644 index 0000000..2911edf --- /dev/null +++ b/osse-broadcast/go.sum @@ -0,0 +1,12 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= +github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= +github.com/tmaxmax/go-sse v0.10.0 h1:j9F93WB4Hxt8wUf6oGffMm4dutALvUPoDDxfuDQOSqA= +github.com/tmaxmax/go-sse v0.10.0/go.mod h1:u/2kZQR1tyngo1lKaNCj1mJmhXGZWS1Zs5yiSOD+Eg8= diff --git a/osse-broadcast/internal/config/config.go b/osse-broadcast/internal/config/config.go new file mode 100644 index 0000000..7e5bc52 --- /dev/null +++ b/osse-broadcast/internal/config/config.go @@ -0,0 +1,32 @@ +package config + +import ( + "log" + "os" +) + +type OsseConfig struct { + HttpHost string + RedisHost string + OsseClientOrigin string +} + +func GetOsseConfig() OsseConfig { + httpHost := getEnvVar("OSSE_BROADCAST_PORT") + redisHost := getEnvVar("OSSE_REDIS_HOST") + osseClientOrigin := getEnvVar("OSSE_DOMAIN") + + return OsseConfig{httpHost, redisHost, osseClientOrigin} +} + +func getEnvVar(key string) string { + result, varExists := os.LookupEnv(key) + + if !varExists { + log.Println("The environment variable " + key + " was not set. Please set this var in the osse config file.") + log.Println("Osse Broadcast is shutting down!") + os.Exit(1) + } + + return result +} diff --git a/osse-broadcast/internal/messages/messages.go b/osse-broadcast/internal/messages/messages.go new file mode 100644 index 0000000..fa4f4d6 --- /dev/null +++ b/osse-broadcast/internal/messages/messages.go @@ -0,0 +1,166 @@ +package messages + +import ( + "encoding/json" + "fmt" + "log" +) + +const ( + SCANSTARTED string = "ScanStarted" + SCANCOMPLETE string = "ScanCompleted" + SCANPROGRESSED string = "ScanProgressed" + SCANERROR string = "ScanError" // Problem, scan not stopped. + SCANFAILED string = "ScanFailed" // Scan stopped. + SCANCANCELLED string = "ScanCancelled" +) + +var AllTopics = []string{ + SCANSTARTED, + SCANCOMPLETE, + SCANPROGRESSED, + SCANERROR, + SCANFAILED, + SCANCANCELLED, +} + +// The raw message sent from redis. +type BaseEvent struct { + Event string `json:"event"` + Data json.RawMessage `json:"data"` +} + +type OsseEvent interface { + GetType() string +} + +// The individual message types from Osse + +type ScanStarted struct { + Directories []ScanDirectory `json:"directories"` +} + +func (s ScanStarted) GetType() string { + return SCANSTARTED +} + +type ScanDirectory struct { + ID uint `json:"id"` + ScanJobID uint `json:"scanJobID"` + Path string `json:"path"` + Status string `json:"status"` + FilesScanned uint `json:"filesScanned"` + FilesSkipped uint `json:"filesSkipped"` + StartedAt *string `json:"startedAt"` + FinishedAt *string `json:"finishedAt"` +} + +type ScanProgressed struct { + DirectoryID uint `json:"directoryID"` + DirectoryName string `json:"directoryName"` + FilesScanned int `json:"filesScanned"` + FilesSkipped int `json:"filesSkipped"` + Status string `json:"status"` +} + +func (s ScanProgressed) GetType() string { + return SCANPROGRESSED +} + +type ScanCompleted struct { + DirectoryCount int `json:"directoryCount"` +} + +func (s ScanCompleted) GetType() string { + return SCANCOMPLETE +} + +type ScanError struct { + Message string `json:"message"` +} + +func (s ScanError) GetType() string { + return SCANERROR +} + +type ScanFailed struct { + Reason string `json:"message"` +} + +func (s ScanFailed) GetType() string { + return SCANFAILED +} + +type ScanCancelled struct { + DirectoriesScannedBeforeCancellation int `json:"directoriesScannedBeforeCancellation"` +} + +func (s ScanCancelled) GetType() string { + return SCANCANCELLED +} + +func GetEventFromMessage(message string) (OsseEvent, error) { + var base BaseEvent + err := json.Unmarshal([]byte(message), &base) + if err != nil { + log.Println("Error parsing event: ", err) + return nil, err + } + + // Now that its valid json, we determine the event type + switch base.Event { + case "App\\Events\\ScanStarted": + var data ScanStarted + err = json.Unmarshal(base.Data, &data) + if err == nil { + return data, nil + } + + case "App\\Events\\ScanProgressed": + var data ScanProgressed + err = json.Unmarshal(base.Data, &data) + if err == nil { + return data, nil + } + + case "App\\Events\\ScanCompleted": + var data ScanCompleted + err = json.Unmarshal(base.Data, &data) + if err == nil { + return data, nil + } + + case "App\\Events\\ScanError": + var data ScanError + err = json.Unmarshal(base.Data, &data) + if err == nil { + return data, nil + } + + case "App\\Events\\ScanFailed": + var data ScanFailed + err = json.Unmarshal(base.Data, &data) + if err == nil { + return data, nil + } + + case "App\\Events\\ScanCancelled": + var data ScanCancelled + err = json.Unmarshal(base.Data, &data) + if err == nil { + return data, nil + } + } + + return nil, fmt.Errorf("Unknown event: %s", base.Event) +} + +func GetJsonOfEvent(event OsseEvent) (string, error) { + jsonData, err := json.Marshal(event) + if err != nil { + log.Println("Error with converting osse event to json.") + return "", err + } + + return string(jsonData), nil +} diff --git a/osse-broadcast/internal/redis/redis.go b/osse-broadcast/internal/redis/redis.go new file mode 100644 index 0000000..393e172 --- /dev/null +++ b/osse-broadcast/internal/redis/redis.go @@ -0,0 +1,50 @@ +package redis + +import ( + "context" + "log" + "osse-broadcast/internal/messages" + "time" + + "github.com/redis/go-redis/v9" +) + +var rdb *redis.Client + +func Connect(host string, channel chan messages.OsseEvent) { + rdb = redis.NewClient(&redis.Options{ + Addr: host, + }) + + log.Println("Connected to redis on " + host) + + // Start Redis pub/sub listener + go listenRedis(channel) +} + +func listenRedis(channel chan messages.OsseEvent) { + ctx := context.Background() + pubsub := rdb.Subscribe(ctx, "osse_database_scan") + + for msg := range pubsub.Channel() { + log.Println("Received message:", msg.Payload) + + // Parse the message into a Message type + event, err := messages.GetEventFromMessage(msg.Payload) + if err != nil { + log.Println("Received message from Osse that osse-broadcast cannot parse...") + continue + } + + // Broadcast to connected SSE clients + channel <- event + } +} + +// Gets a redis value from a key. Returns the message and an error +func GetValue(key string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + return rdb.Get(ctx, key).Result() +} diff --git a/osse-broadcast/internal/server/middleware.go b/osse-broadcast/internal/server/middleware.go new file mode 100644 index 0000000..1da0db1 --- /dev/null +++ b/osse-broadcast/internal/server/middleware.go @@ -0,0 +1,16 @@ +package server + +import ( + "osse-broadcast/internal/redis" +) + +func validateUserToken(userID string, token string) bool { + // Check that the user is permitted to access osse-broadcast + userToken, err := redis.GetValue("osse_database_sse_access:" + userID) + if err != nil { + return false + } + + return userToken == token +} + diff --git a/osse-broadcast/internal/server/server.go b/osse-broadcast/internal/server/server.go new file mode 100644 index 0000000..98bb963 --- /dev/null +++ b/osse-broadcast/internal/server/server.go @@ -0,0 +1,150 @@ +package server + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "osse-broadcast/internal/messages" + "osse-broadcast/internal/redis" + "time" + + "github.com/tmaxmax/go-sse" +) + +// SSE connections +var Clients = make(chan messages.OsseEvent) + +func Start(host string, allowOrigin string) { + sseHandler := createSseSetup() + + mux := http.NewServeMux() + mux.Handle("/sse", sseHandler) + mux.HandleFunc("/stream", createFilestreamSetup) + + httpServer := &http.Server{ + Addr: ":" + host, + Handler: mux, + ReadHeaderTimeout: time.Second * 10, + } + + httpServer.RegisterOnShutdown(func() { + e := &sse.Message{Type: sse.Type("close")} + // Adding data is necessary because spec-compliant clients + // do not dispatch events without data. + e.AppendData("bye") + // Broadcast a close message so clients can gracefully disconnect. + _ = sseHandler.Publish(e) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + // We use a context with a timeout so the program doesn't wait indefinitely + // for connections to terminate. There may be misbehaving connections + // which may hang for an unknown timespan, so we just stop waiting on Shutdown + // after a certain duration. + _ = sseHandler.Shutdown(ctx) + }) + + // Listen for redis messages + go func() { + for event := range Clients { + eventJson, err := messages.GetJsonOfEvent(event) + if err != nil { + continue + } + + message := &sse.Message{} + message.AppendData(eventJson) + + // Set the event name (client listens for this) + eventName, err := sse.NewType(event.GetType()) + if err != nil { + continue + } + message.Type = eventName + + sseHandler.Publish(message, messages.AllTopics...) + log.Println("Sent Message to client") + } + }() + + log.Println("Osse Broadcast running on " + host) + runServer(httpServer) +} + +func createSseSetup() *sse.Server { + return &sse.Server{ + Provider: &sse.Joe{}, + OnSession: func(s *sse.Session) (sse.Subscription, bool) { + // Get the user ID and token + userID := s.Req.URL.Query().Get("id") + token := s.Req.URL.Query().Get("token") + + log.Println("User attempted to connect.") + + // Validate the userID and token + if !validateUserToken(userID, token) { + return sse.Subscription{}, false + } + + return sse.Subscription{ + Client: s, + Topics: messages.AllTopics, + }, true + }, + } +} + +func createFilestreamSetup(w http.ResponseWriter, r *http.Request) { + // Read the token and user id + token := r.URL.Query().Get("token") + trackID := r.URL.Query().Get("trackID") + userID := r.URL.Query().Get("id") + + if userID == "" { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + filePath, err := redis.GetValue("osse_database_file_access:" + userID + ":" + trackID + ":" + token) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + } + + // Make sure the file path is absolute (don't serve relatie files, although that should be impossible with how we do this.) + if filePath == "" { + http.Error(w, "invalid file path", http.StatusBadRequest) + return + } + + // Open the file + f, err := os.Open(filePath) + if err != nil { + http.Error(w, "file not found", http.StatusNotFound) + return + } + defer f.Close() + + // Get file info + stat, err := f.Stat() + if err != nil || stat.IsDir() { + http.Error(w, "invalid file", http.StatusBadRequest) + return + } + + // Go literally handles everything I wrote in manual PHP related to HTTP range requests. + // woooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo - amytho, 7:31 PM + http.ServeContent(w, r, stat.Name(), stat.ModTime(), f) +} + +func runServer(s *http.Server) error { + shutdownError := make(chan error) + + if err := s.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + + return <-shutdownError +} diff --git a/osse-broadcast/main.go b/osse-broadcast/main.go new file mode 100644 index 0000000..a27c42d --- /dev/null +++ b/osse-broadcast/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "osse-broadcast/internal/config" + "osse-broadcast/internal/redis" + "osse-broadcast/internal/server" +) + +func main() { + // Get config + config := config.GetOsseConfig() + + // Connect to Redis/Valkey + redis.Connect(config.RedisHost, server.Clients) + + // Start HTTP server with SSE route + server.Start(config.HttpHost, config.OsseClientOrigin) +} diff --git a/.dockerignore b/osse-core/.dockerignore similarity index 100% rename from .dockerignore rename to osse-core/.dockerignore diff --git a/.env.docker b/osse-core/.env.docker similarity index 100% rename from .env.docker rename to osse-core/.env.docker diff --git a/osse-core/.env.example b/osse-core/.env.example new file mode 100644 index 0000000..a108375 --- /dev/null +++ b/osse-core/.env.example @@ -0,0 +1,67 @@ +APP_NAME=osse +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_TIMEZONE=UTC +APP_URL=http://localhost + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=redis +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=.localhost + +BROADCAST_CONNECTION=redis +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=redis +CACHE_PREFIX=cache_ + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" + diff --git a/.env.testing b/osse-core/.env.testing similarity index 100% rename from .env.testing rename to osse-core/.env.testing diff --git a/osse-core/.gitignore b/osse-core/.gitignore new file mode 100644 index 0000000..c417dba --- /dev/null +++ b/osse-core/.gitignore @@ -0,0 +1,28 @@ +/.phpunit.cache +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +Homestead.json +Homestead.yaml +auth.json +npm-debug.log +yarn-error.log +/.fleet +/.idea +/.nova +/.vscode +/.zed +osse.db +public/dist +storage/osse_setup +certs/ +user-conf/ diff --git a/_ide_helper.php b/osse-core/_ide_helper.php similarity index 100% rename from _ide_helper.php rename to osse-core/_ide_helper.php diff --git a/app/Enums/ScanDirStatus.php b/osse-core/app/Enums/ScanDirStatus.php similarity index 100% rename from app/Enums/ScanDirStatus.php rename to osse-core/app/Enums/ScanDirStatus.php diff --git a/app/Enums/ScanStatus.php b/osse-core/app/Enums/ScanStatus.php similarity index 100% rename from app/Enums/ScanStatus.php rename to osse-core/app/Enums/ScanStatus.php diff --git a/app/Events/ScanCancelled.php b/osse-core/app/Events/ScanCancelled.php similarity index 100% rename from app/Events/ScanCancelled.php rename to osse-core/app/Events/ScanCancelled.php diff --git a/app/Events/ScanCompleted.php b/osse-core/app/Events/ScanCompleted.php similarity index 100% rename from app/Events/ScanCompleted.php rename to osse-core/app/Events/ScanCompleted.php diff --git a/app/Events/ScanError.php b/osse-core/app/Events/ScanError.php similarity index 100% rename from app/Events/ScanError.php rename to osse-core/app/Events/ScanError.php diff --git a/app/Events/ScanFailed.php b/osse-core/app/Events/ScanFailed.php similarity index 100% rename from app/Events/ScanFailed.php rename to osse-core/app/Events/ScanFailed.php diff --git a/app/Events/ScanProgressed.php b/osse-core/app/Events/ScanProgressed.php similarity index 100% rename from app/Events/ScanProgressed.php rename to osse-core/app/Events/ScanProgressed.php diff --git a/app/Events/ScanStarted.php b/osse-core/app/Events/ScanStarted.php similarity index 100% rename from app/Events/ScanStarted.php rename to osse-core/app/Events/ScanStarted.php diff --git a/app/Http/Controllers/AlbumController.php b/osse-core/app/Http/Controllers/AlbumController.php similarity index 100% rename from app/Http/Controllers/AlbumController.php rename to osse-core/app/Http/Controllers/AlbumController.php diff --git a/app/Http/Controllers/ArtistController.php b/osse-core/app/Http/Controllers/ArtistController.php similarity index 100% rename from app/Http/Controllers/ArtistController.php rename to osse-core/app/Http/Controllers/ArtistController.php diff --git a/app/Http/Controllers/AuthController.php b/osse-core/app/Http/Controllers/AuthController.php similarity index 97% rename from app/Http/Controllers/AuthController.php rename to osse-core/app/Http/Controllers/AuthController.php index 213a9c8..2b5868d 100644 --- a/app/Http/Controllers/AuthController.php +++ b/osse-core/app/Http/Controllers/AuthController.php @@ -81,7 +81,7 @@ public function authorizeSSE() { $token = Str::random(25); $id = Auth::user()->id; - $url = config('broadcasting.osse-broadcast.url').'sse'; + $url = config('broadcasting.osse-broadcast.sse'); // Give broadcast permission rights for 60 seconds. They have to connect in that window. Redis::setex('sse_access:'.$id, 60, $token); diff --git a/app/Http/Controllers/ConfigController.php b/osse-core/app/Http/Controllers/ConfigController.php similarity index 100% rename from app/Http/Controllers/ConfigController.php rename to osse-core/app/Http/Controllers/ConfigController.php diff --git a/app/Http/Controllers/Controller.php b/osse-core/app/Http/Controllers/Controller.php similarity index 100% rename from app/Http/Controllers/Controller.php rename to osse-core/app/Http/Controllers/Controller.php diff --git a/app/Http/Controllers/CoverArtController.php b/osse-core/app/Http/Controllers/CoverArtController.php similarity index 100% rename from app/Http/Controllers/CoverArtController.php rename to osse-core/app/Http/Controllers/CoverArtController.php diff --git a/app/Http/Controllers/PlaylistController.php b/osse-core/app/Http/Controllers/PlaylistController.php similarity index 100% rename from app/Http/Controllers/PlaylistController.php rename to osse-core/app/Http/Controllers/PlaylistController.php diff --git a/app/Http/Controllers/QueueController.php b/osse-core/app/Http/Controllers/QueueController.php similarity index 100% rename from app/Http/Controllers/QueueController.php rename to osse-core/app/Http/Controllers/QueueController.php diff --git a/app/Http/Controllers/ScanController.php b/osse-core/app/Http/Controllers/ScanController.php similarity index 100% rename from app/Http/Controllers/ScanController.php rename to osse-core/app/Http/Controllers/ScanController.php diff --git a/app/Http/Controllers/TrackController.php b/osse-core/app/Http/Controllers/TrackController.php similarity index 90% rename from app/Http/Controllers/TrackController.php rename to osse-core/app/Http/Controllers/TrackController.php index 4aca749..8d163fe 100644 --- a/app/Http/Controllers/TrackController.php +++ b/osse-core/app/Http/Controllers/TrackController.php @@ -56,20 +56,19 @@ public function stream(Track $track) return response()->json([ 'token' => $token, - 'url' => config('broadcasting.osse-broadcast.url').'stream', + 'url' => config('broadcasting.osse-broadcast.stream'), ]); } // Generate a unique token for auth and allow track access. $token = Str::random(25); - $url = config('broadcasting.osse-broadcast.url').'stream?token='.$token.'&id='.$id; // osse_database_file_access:1:1:abc123 Redis::setex('file_access:'.$id.':'.$track->id.':'.$token, 86400, $track->location); // Return the user the token. They already know the track id. return response()->json([ 'token' => $token, - 'url' => config('broadcasting.osse-broadcast.url').'stream', + 'url' => config('broadcasting.osse-broadcast.stream'), ]); } } diff --git a/app/Http/Middleware/HTTPCache.php b/osse-core/app/Http/Middleware/HTTPCache.php similarity index 100% rename from app/Http/Middleware/HTTPCache.php rename to osse-core/app/Http/Middleware/HTTPCache.php diff --git a/app/Http/Middleware/RegistrationCheck.php b/osse-core/app/Http/Middleware/RegistrationCheck.php similarity index 100% rename from app/Http/Middleware/RegistrationCheck.php rename to osse-core/app/Http/Middleware/RegistrationCheck.php diff --git a/app/Http/Requests/CreatePlaylistRequest.php b/osse-core/app/Http/Requests/CreatePlaylistRequest.php similarity index 100% rename from app/Http/Requests/CreatePlaylistRequest.php rename to osse-core/app/Http/Requests/CreatePlaylistRequest.php diff --git a/app/Http/Requests/QueueActiveTrackRequest.php b/osse-core/app/Http/Requests/QueueActiveTrackRequest.php similarity index 100% rename from app/Http/Requests/QueueActiveTrackRequest.php rename to osse-core/app/Http/Requests/QueueActiveTrackRequest.php diff --git a/app/Http/Requests/QueueRequest.php b/osse-core/app/Http/Requests/QueueRequest.php similarity index 100% rename from app/Http/Requests/QueueRequest.php rename to osse-core/app/Http/Requests/QueueRequest.php diff --git a/app/Http/Requests/StoreConfigRequest.php b/osse-core/app/Http/Requests/StoreConfigRequest.php similarity index 100% rename from app/Http/Requests/StoreConfigRequest.php rename to osse-core/app/Http/Requests/StoreConfigRequest.php diff --git a/app/Http/Requests/TrackSearchRequest.php b/osse-core/app/Http/Requests/TrackSearchRequest.php similarity index 100% rename from app/Http/Requests/TrackSearchRequest.php rename to osse-core/app/Http/Requests/TrackSearchRequest.php diff --git a/app/Http/Requests/UpdatePlaylistRequest.php b/osse-core/app/Http/Requests/UpdatePlaylistRequest.php similarity index 100% rename from app/Http/Requests/UpdatePlaylistRequest.php rename to osse-core/app/Http/Requests/UpdatePlaylistRequest.php diff --git a/app/Http/Resources/AlbumResponse.php b/osse-core/app/Http/Resources/AlbumResponse.php similarity index 100% rename from app/Http/Resources/AlbumResponse.php rename to osse-core/app/Http/Resources/AlbumResponse.php diff --git a/app/Jobs/ScanMusic.php b/osse-core/app/Jobs/ScanMusic.php similarity index 100% rename from app/Jobs/ScanMusic.php rename to osse-core/app/Jobs/ScanMusic.php diff --git a/app/Models/Album.php b/osse-core/app/Models/Album.php similarity index 100% rename from app/Models/Album.php rename to osse-core/app/Models/Album.php diff --git a/app/Models/Artist.php b/osse-core/app/Models/Artist.php similarity index 100% rename from app/Models/Artist.php rename to osse-core/app/Models/Artist.php diff --git a/app/Models/CoverArt.php b/osse-core/app/Models/CoverArt.php similarity index 100% rename from app/Models/CoverArt.php rename to osse-core/app/Models/CoverArt.php diff --git a/app/Models/PlaybackSession.php b/osse-core/app/Models/PlaybackSession.php similarity index 100% rename from app/Models/PlaybackSession.php rename to osse-core/app/Models/PlaybackSession.php diff --git a/app/Models/Playlist.php b/osse-core/app/Models/Playlist.php similarity index 100% rename from app/Models/Playlist.php rename to osse-core/app/Models/Playlist.php diff --git a/app/Models/ScanDirectory.php b/osse-core/app/Models/ScanDirectory.php similarity index 100% rename from app/Models/ScanDirectory.php rename to osse-core/app/Models/ScanDirectory.php diff --git a/app/Models/ScanError.php b/osse-core/app/Models/ScanError.php similarity index 100% rename from app/Models/ScanError.php rename to osse-core/app/Models/ScanError.php diff --git a/app/Models/ScanJob.php b/osse-core/app/Models/ScanJob.php similarity index 100% rename from app/Models/ScanJob.php rename to osse-core/app/Models/ScanJob.php diff --git a/app/Models/Track.php b/osse-core/app/Models/Track.php similarity index 100% rename from app/Models/Track.php rename to osse-core/app/Models/Track.php diff --git a/app/Models/User.php b/osse-core/app/Models/User.php similarity index 100% rename from app/Models/User.php rename to osse-core/app/Models/User.php diff --git a/app/Models/UserSetting.php b/osse-core/app/Models/UserSetting.php similarity index 100% rename from app/Models/UserSetting.php rename to osse-core/app/Models/UserSetting.php diff --git a/app/Providers/AppServiceProvider.php b/osse-core/app/Providers/AppServiceProvider.php similarity index 100% rename from app/Providers/AppServiceProvider.php rename to osse-core/app/Providers/AppServiceProvider.php diff --git a/app/Services/MusicProcessor/ArtExtractor.php b/osse-core/app/Services/MusicProcessor/ArtExtractor.php similarity index 100% rename from app/Services/MusicProcessor/ArtExtractor.php rename to osse-core/app/Services/MusicProcessor/ArtExtractor.php diff --git a/app/Services/MusicProcessor/ArtFile.php b/osse-core/app/Services/MusicProcessor/ArtFile.php similarity index 100% rename from app/Services/MusicProcessor/ArtFile.php rename to osse-core/app/Services/MusicProcessor/ArtFile.php diff --git a/app/Services/MusicProcessor/MusicMetadata.php b/osse-core/app/Services/MusicProcessor/MusicMetadata.php similarity index 100% rename from app/Services/MusicProcessor/MusicMetadata.php rename to osse-core/app/Services/MusicProcessor/MusicMetadata.php diff --git a/app/Services/MusicProcessor/MusicProcessor.php b/osse-core/app/Services/MusicProcessor/MusicProcessor.php similarity index 100% rename from app/Services/MusicProcessor/MusicProcessor.php rename to osse-core/app/Services/MusicProcessor/MusicProcessor.php diff --git a/app/Services/MusicProcessor/MusicPruner.php b/osse-core/app/Services/MusicProcessor/MusicPruner.php similarity index 100% rename from app/Services/MusicProcessor/MusicPruner.php rename to osse-core/app/Services/MusicProcessor/MusicPruner.php diff --git a/artisan b/osse-core/artisan similarity index 100% rename from artisan rename to osse-core/artisan diff --git a/bootstrap/app.php b/osse-core/bootstrap/app.php similarity index 100% rename from bootstrap/app.php rename to osse-core/bootstrap/app.php diff --git a/bootstrap/cache/.gitignore b/osse-core/bootstrap/cache/.gitignore similarity index 100% rename from bootstrap/cache/.gitignore rename to osse-core/bootstrap/cache/.gitignore diff --git a/bootstrap/providers.php b/osse-core/bootstrap/providers.php similarity index 100% rename from bootstrap/providers.php rename to osse-core/bootstrap/providers.php diff --git a/composer.json b/osse-core/composer.json similarity index 100% rename from composer.json rename to osse-core/composer.json diff --git a/composer.lock b/osse-core/composer.lock similarity index 100% rename from composer.lock rename to osse-core/composer.lock diff --git a/config/app.php b/osse-core/config/app.php similarity index 97% rename from config/app.php rename to osse-core/config/app.php index 01d6991..90b5937 100644 --- a/config/app.php +++ b/osse-core/config/app.php @@ -123,5 +123,6 @@ 'store' => env('APP_MAINTENANCE_STORE', 'database'), ], - 'client_url' => env('OSSE_SERVER_URL', 'http://localhost:8080') + 'domain' => env('OSSE_DOMAIN', 'osse.localhost'), + 'full_url' => 'https://' . env('OSSE_DOMAIN', 'osse.localhost'), ]; diff --git a/config/auth.php b/osse-core/config/auth.php similarity index 100% rename from config/auth.php rename to osse-core/config/auth.php diff --git a/config/broadcasting.php b/osse-core/config/broadcasting.php similarity index 95% rename from config/broadcasting.php rename to osse-core/config/broadcasting.php index e2bcb7e..86a797d 100644 --- a/config/broadcasting.php +++ b/osse-core/config/broadcasting.php @@ -86,6 +86,7 @@ ], 'osse-broadcast' => [ - 'url' => env('OSSE_BROADCAST_HOST', 'http://localhost:9003') . '/' + 'sse' => config('app.full_url') . '/broadcast/sse', + 'stream' => config('app.full_url') . '/broadcast/stream', ], ]; diff --git a/config/cache.php b/osse-core/config/cache.php similarity index 100% rename from config/cache.php rename to osse-core/config/cache.php diff --git a/config/cors.php b/osse-core/config/cors.php similarity index 74% rename from config/cors.php rename to osse-core/config/cors.php index 2ecb4e5..89ac4ae 100644 --- a/config/cors.php +++ b/osse-core/config/cors.php @@ -19,14 +19,7 @@ 'allowed_methods' => ['*'], - 'allowed_origins' => [ - // Development angular routes - 'http://localhost:4200', - // Production Routes - 'http://' . env('OSSE_HOST', 'localhost'), - 'https://' . env('OSSE_HOST', 'localhost'), - env('OSSE_URL_SERVER', "http://localhost") - ], + 'allowed_origins' => [config('app.domain')], 'allowed_origins_patterns' => [], @@ -37,5 +30,4 @@ 'max_age' => 600, 'supports_credentials' => true, - ]; diff --git a/config/database.php b/osse-core/config/database.php similarity index 100% rename from config/database.php rename to osse-core/config/database.php diff --git a/config/filesystems.php b/osse-core/config/filesystems.php similarity index 100% rename from config/filesystems.php rename to osse-core/config/filesystems.php diff --git a/config/logging.php b/osse-core/config/logging.php similarity index 100% rename from config/logging.php rename to osse-core/config/logging.php diff --git a/config/mail.php b/osse-core/config/mail.php similarity index 100% rename from config/mail.php rename to osse-core/config/mail.php diff --git a/config/queue.php b/osse-core/config/queue.php similarity index 100% rename from config/queue.php rename to osse-core/config/queue.php diff --git a/config/sanctum.php b/osse-core/config/sanctum.php similarity index 88% rename from config/sanctum.php rename to osse-core/config/sanctum.php index f5b60ff..490b97a 100644 --- a/config/sanctum.php +++ b/osse-core/config/sanctum.php @@ -17,12 +17,7 @@ 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( '%s%s', - 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1,' - // Production - . env('OSSE_HOST', 'localhost') . ':' . env('OSSE_SERVER_PORT', 80) . ',' - // Also Production, but without the ports (defaults). Some browsers drop the :80 or :443. - . env('OSSE_HOST', 'localhost') . ',' - . env('OSSE_HOST', 'localhost'), + config('app.domain'), Sanctum::currentApplicationUrlWithPort() ))), diff --git a/config/scan.php b/osse-core/config/scan.php similarity index 100% rename from config/scan.php rename to osse-core/config/scan.php diff --git a/config/services.php b/osse-core/config/services.php similarity index 100% rename from config/services.php rename to osse-core/config/services.php diff --git a/config/session.php b/osse-core/config/session.php similarity index 98% rename from config/session.php rename to osse-core/config/session.php index e22e49b..ced46fd 100644 --- a/config/session.php +++ b/osse-core/config/session.php @@ -156,7 +156,7 @@ | */ - 'domain' => env('OSSE_HOST') ? '.' . env('OSSE_HOST') : env('SESSION_DOMAIN', '.localhost'), + 'domain' => config('app.domain'), /* |-------------------------------------------------------------------------- @@ -169,7 +169,7 @@ | */ - 'secure' => env('SESSION_SECURE_COOKIE', false), + 'secure' => env('SESSION_SECURE_COOKIE', true), /* |-------------------------------------------------------------------------- diff --git a/database/.gitignore b/osse-core/database/.gitignore similarity index 100% rename from database/.gitignore rename to osse-core/database/.gitignore diff --git a/database/factories/AlbumFactory.php b/osse-core/database/factories/AlbumFactory.php similarity index 100% rename from database/factories/AlbumFactory.php rename to osse-core/database/factories/AlbumFactory.php diff --git a/database/factories/ArtistFactory.php b/osse-core/database/factories/ArtistFactory.php similarity index 100% rename from database/factories/ArtistFactory.php rename to osse-core/database/factories/ArtistFactory.php diff --git a/database/factories/CoverArtFactory.php b/osse-core/database/factories/CoverArtFactory.php similarity index 100% rename from database/factories/CoverArtFactory.php rename to osse-core/database/factories/CoverArtFactory.php diff --git a/database/factories/PlaylistFactory.php b/osse-core/database/factories/PlaylistFactory.php similarity index 100% rename from database/factories/PlaylistFactory.php rename to osse-core/database/factories/PlaylistFactory.php diff --git a/database/factories/TrackFactory.php b/osse-core/database/factories/TrackFactory.php similarity index 100% rename from database/factories/TrackFactory.php rename to osse-core/database/factories/TrackFactory.php diff --git a/database/factories/UserFactory.php b/osse-core/database/factories/UserFactory.php similarity index 100% rename from database/factories/UserFactory.php rename to osse-core/database/factories/UserFactory.php diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/osse-core/database/migrations/0001_01_01_000000_create_users_table.php similarity index 100% rename from database/migrations/0001_01_01_000000_create_users_table.php rename to osse-core/database/migrations/0001_01_01_000000_create_users_table.php diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/osse-core/database/migrations/0001_01_01_000001_create_cache_table.php similarity index 100% rename from database/migrations/0001_01_01_000001_create_cache_table.php rename to osse-core/database/migrations/0001_01_01_000001_create_cache_table.php diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/osse-core/database/migrations/0001_01_01_000002_create_jobs_table.php similarity index 100% rename from database/migrations/0001_01_01_000002_create_jobs_table.php rename to osse-core/database/migrations/0001_01_01_000002_create_jobs_table.php diff --git a/database/migrations/2024_12_08_181615_create_personal_access_tokens_table.php b/osse-core/database/migrations/2024_12_08_181615_create_personal_access_tokens_table.php similarity index 100% rename from database/migrations/2024_12_08_181615_create_personal_access_tokens_table.php rename to osse-core/database/migrations/2024_12_08_181615_create_personal_access_tokens_table.php diff --git a/database/migrations/2024_12_08_182623_init_app.php b/osse-core/database/migrations/2024_12_08_182623_init_app.php similarity index 100% rename from database/migrations/2024_12_08_182623_init_app.php rename to osse-core/database/migrations/2024_12_08_182623_init_app.php diff --git a/database/migrations/2025_05_18_193956_create_queue_and_settings.php b/osse-core/database/migrations/2025_05_18_193956_create_queue_and_settings.php similarity index 100% rename from database/migrations/2025_05_18_193956_create_queue_and_settings.php rename to osse-core/database/migrations/2025_05_18_193956_create_queue_and_settings.php diff --git a/database/seeders/DatabaseSeeder.php b/osse-core/database/seeders/DatabaseSeeder.php similarity index 100% rename from database/seeders/DatabaseSeeder.php rename to osse-core/database/seeders/DatabaseSeeder.php diff --git a/docker-compose.yml b/osse-core/docker-compose.yml similarity index 99% rename from docker-compose.yml rename to osse-core/docker-compose.yml index 4b6ec83..e22cb16 100644 --- a/docker-compose.yml +++ b/osse-core/docker-compose.yml @@ -68,4 +68,3 @@ volumes: valkey_data: caddy_data: caddy_config: - diff --git a/docker/Caddyfile-http b/osse-core/docker/Caddyfile-http similarity index 100% rename from docker/Caddyfile-http rename to osse-core/docker/Caddyfile-http diff --git a/docker/Caddyfile-https b/osse-core/docker/Caddyfile-https similarity index 100% rename from docker/Caddyfile-https rename to osse-core/docker/Caddyfile-https diff --git a/docker/Dockerfile b/osse-core/docker/Dockerfile similarity index 100% rename from docker/Dockerfile rename to osse-core/docker/Dockerfile diff --git a/docker/entrypoint.sh b/osse-core/docker/entrypoint.sh similarity index 100% rename from docker/entrypoint.sh rename to osse-core/docker/entrypoint.sh diff --git a/docker/supervisor.conf b/osse-core/docker/supervisor.conf similarity index 100% rename from docker/supervisor.conf rename to osse-core/docker/supervisor.conf diff --git a/package-lock.json b/osse-core/package-lock.json similarity index 100% rename from package-lock.json rename to osse-core/package-lock.json diff --git a/package.json b/osse-core/package.json similarity index 100% rename from package.json rename to osse-core/package.json diff --git a/phpunit.xml b/osse-core/phpunit.xml similarity index 100% rename from phpunit.xml rename to osse-core/phpunit.xml diff --git a/postcss.config.js b/osse-core/postcss.config.js similarity index 100% rename from postcss.config.js rename to osse-core/postcss.config.js diff --git a/production-setup.sh b/osse-core/production-setup.sh similarity index 100% rename from production-setup.sh rename to osse-core/production-setup.sh diff --git a/public/.htaccess b/osse-core/public/.htaccess similarity index 100% rename from public/.htaccess rename to osse-core/public/.htaccess diff --git a/public/index.php b/osse-core/public/index.php similarity index 100% rename from public/index.php rename to osse-core/public/index.php diff --git a/public/robots.txt b/osse-core/public/robots.txt similarity index 100% rename from public/robots.txt rename to osse-core/public/robots.txt diff --git a/resources/css/app.css b/osse-core/resources/css/app.css similarity index 100% rename from resources/css/app.css rename to osse-core/resources/css/app.css diff --git a/resources/js/app.js b/osse-core/resources/js/app.js similarity index 100% rename from resources/js/app.js rename to osse-core/resources/js/app.js diff --git a/resources/js/bootstrap.js b/osse-core/resources/js/bootstrap.js similarity index 100% rename from resources/js/bootstrap.js rename to osse-core/resources/js/bootstrap.js diff --git a/resources/views/welcome.blade.php b/osse-core/resources/views/welcome.blade.php similarity index 100% rename from resources/views/welcome.blade.php rename to osse-core/resources/views/welcome.blade.php diff --git a/routes/api.php b/osse-core/routes/api.php similarity index 92% rename from routes/api.php rename to osse-core/routes/api.php index 6c04f79..fd36914 100644 --- a/routes/api.php +++ b/osse-core/routes/api.php @@ -12,11 +12,13 @@ use Illuminate\Support\Facades\Route; Route::get('/ping', [ConfigController::class, 'ping']); -Route::get('/user', [AuthController::class, 'user'])->middleware('auth:sanctum'); -Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum'); -Route::post('/sse', [AuthController::class, 'authorizeSSE'])->middleware('auth:sanctum'); +// Sanctum protected (authed users only) Route::middleware('auth:sanctum')->group(function () { + Route::get('/user', [AuthController::class, 'user']); + Route::post('/logout', [AuthController::class, 'logout']); + Route::post('/sse', [AuthController::class, 'authorizeSSE']); + Route::get('/config', [ConfigController::class, 'allSettings']); Route::post('/config', [ConfigController::class, 'storeAllSettings']); Route::get('/config/directories', [ConfigController::class, 'directories']); diff --git a/routes/console.php b/osse-core/routes/console.php similarity index 100% rename from routes/console.php rename to osse-core/routes/console.php diff --git a/routes/web.php b/osse-core/routes/web.php similarity index 100% rename from routes/web.php rename to osse-core/routes/web.php diff --git a/storage/app/.gitignore b/osse-core/storage/app/.gitignore similarity index 100% rename from storage/app/.gitignore rename to osse-core/storage/app/.gitignore diff --git a/storage/app/private/.gitignore b/osse-core/storage/app/private/.gitignore similarity index 100% rename from storage/app/private/.gitignore rename to osse-core/storage/app/private/.gitignore diff --git a/storage/app/public/.gitignore b/osse-core/storage/app/public/.gitignore similarity index 100% rename from storage/app/public/.gitignore rename to osse-core/storage/app/public/.gitignore diff --git a/storage/framework/.gitignore b/osse-core/storage/framework/.gitignore similarity index 100% rename from storage/framework/.gitignore rename to osse-core/storage/framework/.gitignore diff --git a/storage/framework/cache/.gitignore b/osse-core/storage/framework/cache/.gitignore similarity index 100% rename from storage/framework/cache/.gitignore rename to osse-core/storage/framework/cache/.gitignore diff --git a/storage/framework/cache/data/.gitignore b/osse-core/storage/framework/cache/data/.gitignore similarity index 100% rename from storage/framework/cache/data/.gitignore rename to osse-core/storage/framework/cache/data/.gitignore diff --git a/storage/framework/sessions/.gitignore b/osse-core/storage/framework/sessions/.gitignore similarity index 100% rename from storage/framework/sessions/.gitignore rename to osse-core/storage/framework/sessions/.gitignore diff --git a/storage/framework/testing/.gitignore b/osse-core/storage/framework/testing/.gitignore similarity index 100% rename from storage/framework/testing/.gitignore rename to osse-core/storage/framework/testing/.gitignore diff --git a/storage/framework/views/.gitignore b/osse-core/storage/framework/views/.gitignore similarity index 100% rename from storage/framework/views/.gitignore rename to osse-core/storage/framework/views/.gitignore diff --git a/storage/logs/.gitignore b/osse-core/storage/logs/.gitignore similarity index 100% rename from storage/logs/.gitignore rename to osse-core/storage/logs/.gitignore diff --git a/systemd/Caddyfile-https-mtls b/osse-core/systemd/Caddyfile-https-mtls similarity index 100% rename from systemd/Caddyfile-https-mtls rename to osse-core/systemd/Caddyfile-https-mtls diff --git a/systemd/osse-broadcast.service b/osse-core/systemd/osse-broadcast.service similarity index 100% rename from systemd/osse-broadcast.service rename to osse-core/systemd/osse-broadcast.service diff --git a/systemd/osse-frankenphp.service b/osse-core/systemd/osse-frankenphp.service similarity index 100% rename from systemd/osse-frankenphp.service rename to osse-core/systemd/osse-frankenphp.service diff --git a/systemd/osse-queue.service b/osse-core/systemd/osse-queue.service similarity index 100% rename from systemd/osse-queue.service rename to osse-core/systemd/osse-queue.service diff --git a/systemd/osse.target b/osse-core/systemd/osse.target similarity index 100% rename from systemd/osse.target rename to osse-core/systemd/osse.target diff --git a/tailwind.config.js b/osse-core/tailwind.config.js similarity index 100% rename from tailwind.config.js rename to osse-core/tailwind.config.js diff --git a/tests/Feature/Http/Controllers/ScanControllerTest.php b/osse-core/tests/Feature/Http/Controllers/ScanControllerTest.php similarity index 100% rename from tests/Feature/Http/Controllers/ScanControllerTest.php rename to osse-core/tests/Feature/Http/Controllers/ScanControllerTest.php diff --git a/tests/Feature/Http/Middleware/RegistrationCheckTest.php b/osse-core/tests/Feature/Http/Middleware/RegistrationCheckTest.php similarity index 100% rename from tests/Feature/Http/Middleware/RegistrationCheckTest.php rename to osse-core/tests/Feature/Http/Middleware/RegistrationCheckTest.php diff --git a/tests/Feature/Jobs/ScanMusicTest.php b/osse-core/tests/Feature/Jobs/ScanMusicTest.php similarity index 100% rename from tests/Feature/Jobs/ScanMusicTest.php rename to osse-core/tests/Feature/Jobs/ScanMusicTest.php diff --git a/tests/TestCase.php b/osse-core/tests/TestCase.php similarity index 100% rename from tests/TestCase.php rename to osse-core/tests/TestCase.php diff --git a/tests/Unit/ExampleTest.php b/osse-core/tests/Unit/ExampleTest.php similarity index 100% rename from tests/Unit/ExampleTest.php rename to osse-core/tests/Unit/ExampleTest.php diff --git a/tests/files/covers/test_cover.mp3 b/osse-core/tests/files/covers/test_cover.mp3 similarity index 100% rename from tests/files/covers/test_cover.mp3 rename to osse-core/tests/files/covers/test_cover.mp3 diff --git a/tests/files/covers/test_same_cover.mp3 b/osse-core/tests/files/covers/test_same_cover.mp3 similarity index 100% rename from tests/files/covers/test_same_cover.mp3 rename to osse-core/tests/files/covers/test_same_cover.mp3 diff --git a/tests/files/has_metadata/track_one.mp3 b/osse-core/tests/files/has_metadata/track_one.mp3 similarity index 100% rename from tests/files/has_metadata/track_one.mp3 rename to osse-core/tests/files/has_metadata/track_one.mp3 diff --git a/tests/files/has_metadata/track_two.mp3 b/osse-core/tests/files/has_metadata/track_two.mp3 similarity index 100% rename from tests/files/has_metadata/track_two.mp3 rename to osse-core/tests/files/has_metadata/track_two.mp3 diff --git a/tests/files/invalid/bad_file.mp3 b/osse-core/tests/files/invalid/bad_file.mp3 similarity index 100% rename from tests/files/invalid/bad_file.mp3 rename to osse-core/tests/files/invalid/bad_file.mp3 diff --git a/tests/files/no_metadata/test_no_metadata.mp3 b/osse-core/tests/files/no_metadata/test_no_metadata.mp3 similarity index 100% rename from tests/files/no_metadata/test_no_metadata.mp3 rename to osse-core/tests/files/no_metadata/test_no_metadata.mp3 diff --git a/vite.config.js b/osse-core/vite.config.js similarity index 100% rename from vite.config.js rename to osse-core/vite.config.js diff --git a/osse-web b/osse-web deleted file mode 160000 index fca7a51..0000000 --- a/osse-web +++ /dev/null @@ -1 +0,0 @@ -Subproject commit fca7a51606aa2815af744acd14d8607c8855b729 diff --git a/osse-web/.editorconfig b/osse-web/.editorconfig new file mode 100644 index 0000000..59d9a3a --- /dev/null +++ b/osse-web/.editorconfig @@ -0,0 +1,16 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/osse-web/.gitignore b/osse-web/.gitignore new file mode 100644 index 0000000..0711527 --- /dev/null +++ b/osse-web/.gitignore @@ -0,0 +1,42 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db diff --git a/osse-web/.postcssrc.json b/osse-web/.postcssrc.json new file mode 100644 index 0000000..e092dc7 --- /dev/null +++ b/osse-web/.postcssrc.json @@ -0,0 +1,5 @@ +{ + "plugins": { + "@tailwindcss/postcss": {} + } +} diff --git a/osse-web/LICENSE b/osse-web/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/osse-web/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/osse-web/angular.json b/osse-web/angular.json new file mode 100644 index 0000000..170ead0 --- /dev/null +++ b/osse-web/angular.json @@ -0,0 +1,117 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "pnpm", + "analytics": false + }, + "newProjectRoot": "projects", + "projects": { + "osse-web": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "inlineStyle": true, + "skipTests": true + }, + "@schematics/angular:service": { + "skipTests": true + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "outputPath": "dist/osse-web", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": [], + "tsConfig": "tsconfig.app.json", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "5kb", + "maximumError": "15kb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "osse-web:build:production" + }, + "development": { + "buildTarget": "osse-web:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular/build:extract-i18n", + "options": { + "buildTarget": "osse-web:build" + } + }, + "test": { + "builder": "@angular/build:unit-test", + "options": {} + } + } + } + }, + "schematics": { + "@schematics/angular:component": { + "type": "component" + }, + "@schematics/angular:directive": { + "type": "directive" + }, + "@schematics/angular:service": { + "type": "service" + }, + "@schematics/angular:guard": { + "typeSeparator": "." + }, + "@schematics/angular:interceptor": { + "typeSeparator": "." + }, + "@schematics/angular:module": { + "typeSeparator": "." + }, + "@schematics/angular:pipe": { + "typeSeparator": "." + }, + "@schematics/angular:resolver": { + "typeSeparator": "." + } + } +} diff --git a/osse-web/package.json b/osse-web/package.json new file mode 100644 index 0000000..c785ee0 --- /dev/null +++ b/osse-web/package.json @@ -0,0 +1,43 @@ +{ + "name": "osse-web", + "version": "0.0.1", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test", + "update": "ng update" + }, + "private": true, + "dependencies": { + "@angular/animations": "^21.0.1", + "@angular/common": "^21.0.1", + "@angular/compiler": "^21.0.1", + "@angular/core": "^21.0.1", + "@angular/forms": "^21.0.1", + "@angular/platform-browser": "^21.0.1", + "@angular/platform-browser-dynamic": "^21.0.1", + "@angular/router": "^21.0.1", + "@mdi/js": "^7.4.47", + "@tailwindcss/postcss": "^4.1.4", + "daisyui": "^5.0.27", + "rxjs": "~7.8.2", + "tslib": "^2.8.1", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@angular/build": "^21.0.1", + "@angular/cli": "^21.0.1", + "@angular/compiler-cli": "^21.0.1", + "@angular/language-service": "^21.0.1", + "@types/jasmine": "~5.1.7", + "autoprefixer": "^10.4.21", + "jasmine-core": "~5.1.2", + "jsdom": "^27.4.0", + "postcss": "^8.5.3", + "tailwindcss": "^4.1.4", + "typescript": "~5.9.3", + "vitest": "^4.0.16" + } +} diff --git a/osse-web/pnpm-lock.yaml b/osse-web/pnpm-lock.yaml new file mode 100644 index 0000000..5b30dde --- /dev/null +++ b/osse-web/pnpm-lock.yaml @@ -0,0 +1,7294 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@angular/animations': + specifier: ^21.0.1 + version: 21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/common': + specifier: ^21.0.1 + version: 21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/compiler': + specifier: ^21.0.1 + version: 21.0.1 + '@angular/core': + specifier: ^21.0.1 + version: 21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/forms': + specifier: ^21.0.1 + version: 21.0.1(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@standard-schema/spec@1.0.0)(rxjs@7.8.2) + '@angular/platform-browser': + specifier: ^21.0.1 + version: 21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/platform-browser-dynamic': + specifier: ^21.0.1 + version: 21.0.1(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@21.0.1)(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))) + '@angular/router': + specifier: ^21.0.1 + version: 21.0.1(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) + '@mdi/js': + specifier: ^7.4.47 + version: 7.4.47 + '@tailwindcss/postcss': + specifier: ^4.1.4 + version: 4.1.4 + daisyui: + specifier: ^5.0.27 + version: 5.0.27 + rxjs: + specifier: ~7.8.2 + version: 7.8.2 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + uuid: + specifier: ^11.1.0 + version: 11.1.0 + devDependencies: + '@angular/build': + specifier: ^21.0.1 + version: 21.0.1(@angular/compiler-cli@21.0.1(@angular/compiler@21.0.1)(typescript@5.9.3))(@angular/compiler@21.0.1)(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@types/node@22.13.10)(chokidar@4.0.3)(jiti@2.6.1)(karma@6.4.4)(less@4.4.2)(lightningcss@1.29.2)(postcss@8.5.3)(tailwindcss@4.1.4)(terser@5.44.0)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.0.16(@types/node@22.13.10)(jiti@2.6.1)(jsdom@27.4.0)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0))(yaml@2.7.0) + '@angular/cli': + specifier: ^21.0.1 + version: 21.0.1(@types/node@22.13.10)(chokidar@4.0.3) + '@angular/compiler-cli': + specifier: ^21.0.1 + version: 21.0.1(@angular/compiler@21.0.1)(typescript@5.9.3) + '@angular/language-service': + specifier: ^21.0.1 + version: 21.0.1 + '@types/jasmine': + specifier: ~5.1.7 + version: 5.1.7 + autoprefixer: + specifier: ^10.4.21 + version: 10.4.21(postcss@8.5.3) + jasmine-core: + specifier: ~5.1.2 + version: 5.1.2 + jsdom: + specifier: ^27.4.0 + version: 27.4.0 + postcss: + specifier: ^8.5.3 + version: 8.5.3 + tailwindcss: + specifier: ^4.1.4 + version: 4.1.4 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.16 + version: 4.0.16(@types/node@22.13.10)(jiti@2.6.1)(jsdom@27.4.0)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0) + +packages: + + '@acemir/cssom@0.9.30': + resolution: {integrity: sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==} + + '@algolia/abtesting@1.6.1': + resolution: {integrity: sha512-wV/gNRkzb7sI9vs1OneG129hwe3Q5zPj7zigz3Ps7M5Lpo2hSorrOnXNodHEOV+yXE/ks4Pd+G3CDFIjFTWhMQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-abtesting@5.40.1': + resolution: {integrity: sha512-cxKNATPY5t+Mv8XAVTI57altkaPH+DZi4uMrnexPxPHODMljhGYY+GDZyHwv9a+8CbZHcY372OkxXrDMZA4Lnw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.40.1': + resolution: {integrity: sha512-XP008aMffJCRGAY8/70t+hyEyvqqV7YKm502VPu0+Ji30oefrTn2al7LXkITz7CK6I4eYXWRhN6NaIUi65F1OA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.40.1': + resolution: {integrity: sha512-gWfQuQUBtzUboJv/apVGZMoxSaB0M4Imwl1c9Ap+HpCW7V0KhjBddqF2QQt5tJZCOFsfNIgBbZDGsEPaeKUosw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.40.1': + resolution: {integrity: sha512-RTLjST/t+lsLMouQ4zeLJq2Ss+UNkLGyNVu+yWHanx6kQ3LT5jv8UvPwyht9s7R6jCPnlSI77WnL80J32ZuyJg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.40.1': + resolution: {integrity: sha512-2FEK6bUomBzEYkTKzD0iRs7Ljtjb45rKK/VSkyHqeJnG+77qx557IeSO0qVFE3SfzapNcoytTofnZum0BQ6r3Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.40.1': + resolution: {integrity: sha512-Nju4NtxAvXjrV2hHZNLKVJLXjOlW6jAXHef/CwNzk1b2qIrCWDO589ELi5ZHH1uiWYoYyBXDQTtHmhaOVVoyXg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.40.1': + resolution: {integrity: sha512-Mw6pAUF121MfngQtcUb5quZVqMC68pSYYjCRZkSITC085S3zdk+h/g7i6FxnVdbSU6OztxikSDMh1r7Z+4iPlA==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.40.1': + resolution: {integrity: sha512-z+BPlhs45VURKJIxsR99NNBWpUEEqIgwt10v/fATlNxc4UlXvALdOsWzaFfe89/lbP5Bu4+mbO59nqBC87ZM/g==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.40.1': + resolution: {integrity: sha512-VJMUMbO0wD8Rd2VVV/nlFtLJsOAQvjnVNGkMkspFiFhpBA7s/xJOb+fJvvqwKFUjbKTUA7DjiSi1ljSMYBasXg==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.40.1': + resolution: {integrity: sha512-ehvJLadKVwTp9Scg9NfzVSlBKH34KoWOQNTaN8i1Ac64AnO6iH2apJVSP6GOxssaghZ/s8mFQsDH3QIZoluFHA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.40.1': + resolution: {integrity: sha512-PbidVsPurUSQIr6X9/7s34mgOMdJnn0i6p+N6Ab+lsNhY5eiu+S33kZEpZwkITYBCIbhzDLOvb7xZD3gDi+USA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.40.1': + resolution: {integrity: sha512-ThZ5j6uOZCF11fMw9IBkhigjOYdXGXQpj6h4k+T9UkZrF2RlKcPynFzDeRgaLdpYk8Yn3/MnFbwUmib7yxj5Lw==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.40.1': + resolution: {integrity: sha512-H1gYPojO6krWHnUXu/T44DrEun/Wl95PJzMXRcM/szstNQczSbwq6wIFJPI9nyE95tarZfUNU3rgorT+wZ6iCQ==} + engines: {node: '>= 14.0.0'} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@angular-devkit/architect@0.2100.1': + resolution: {integrity: sha512-MLxTT6EE7NHuCen9yGdv9iT2vtB/fAdXTRnulOWfVa/SVmGoKawBGCNOAPpI2yA8Fb/D5xlU6ThS1ggDsiCqrQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@angular-devkit/core@21.0.1': + resolution: {integrity: sha512-AGdAu0hV2TLCWYHiyVSxUFbpR2chO+xA4OkRrG2YirQGcqJTmr651C4rWDkheWqeWDxMicZklqKaTw66mNSUkw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/schematics@21.0.1': + resolution: {integrity: sha512-3koB1xJNkqMg7g6JwH2rhQO268WjnPVA852lwoLW7wzSZRpJH0kHtZsnY9FYOC2kbmAGnCWWbnPLJ5/T1wemoA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@angular/animations@21.0.1': + resolution: {integrity: sha512-P7i/jpNnzXwo0vHEG0cDXYojwTz0WQlXJHrmOJzLVveyfcFwgXYXJxhGGUI2+k21YrlJTKkR/4QZTEJ0GP0f8Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/core': 21.0.1 + + '@angular/build@21.0.1': + resolution: {integrity: sha512-AQFZWG5TtujCRs7ncajeBZpl/hLBKkuF0lZSziJL8tsgBru0hz0OobOkEuS/nb3FuCRQfva8YP2EPhLdcuo50g==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + '@angular/compiler': ^21.0.0 + '@angular/compiler-cli': ^21.0.0 + '@angular/core': ^21.0.0 + '@angular/localize': ^21.0.0 + '@angular/platform-browser': ^21.0.0 + '@angular/platform-server': ^21.0.0 + '@angular/service-worker': ^21.0.0 + '@angular/ssr': ^21.0.1 + karma: ^6.4.0 + less: ^4.2.0 + ng-packagr: ^21.0.0 + postcss: ^8.4.0 + tailwindcss: ^2.0.0 || ^3.0.0 || ^4.0.0 + tslib: ^2.3.0 + typescript: '>=5.9 <6.0' + vitest: ^4.0.8 + peerDependenciesMeta: + '@angular/core': + optional: true + '@angular/localize': + optional: true + '@angular/platform-browser': + optional: true + '@angular/platform-server': + optional: true + '@angular/service-worker': + optional: true + '@angular/ssr': + optional: true + karma: + optional: true + less: + optional: true + ng-packagr: + optional: true + postcss: + optional: true + tailwindcss: + optional: true + vitest: + optional: true + + '@angular/cli@21.0.1': + resolution: {integrity: sha512-i0+7jwf19D73yAzR/lL4+eKVhooM+J055qfSaJWL5QLCF9/JSSjMPCG8I/qIGNdVr+lVmWvvxqpt7O7kR3zfUw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + hasBin: true + + '@angular/common@21.0.1': + resolution: {integrity: sha512-EqdTGpFp7PVdTVztO7TB6+QxdzUbYXKKT2jwG2Gg+PIQZ2A8XrLPRmGXyH/DLlc5IhnoJlLbngmBRCLCO4xWog==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/core': 21.0.1 + rxjs: ^6.5.3 || ^7.4.0 + + '@angular/compiler-cli@21.0.1': + resolution: {integrity: sha512-BxGLtL5bxlaaAs/kSN4oyXhMfvzqsj1Gc4Jauz39R4xtgOF5cIvjBtj6dJ9mD3PK0s6zaFi7WYd0YwWkxhjgMA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@angular/compiler': 21.0.1 + typescript: '>=5.9 <6.0' + peerDependenciesMeta: + typescript: + optional: true + + '@angular/compiler@21.0.1': + resolution: {integrity: sha512-YRzHpThgCaC9b3xzK1Wx859ePeHEPR7ewQklUB5TYbpzVacvnJo38PcSAx/nzOmgX9y4mgyros6LzECmBb8d8w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@angular/core@21.0.1': + resolution: {integrity: sha512-z0G9Bwzgqr0fQVbtMgqwl+SbbiqtJD7I2xT6U5p45LetKHojcfigH29dxi/vqALPwEdgb2nSIx7RqVhoyynraQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/compiler': 21.0.1 + rxjs: ^6.5.3 || ^7.4.0 + zone.js: ~0.15.0 || ~0.16.0 + peerDependenciesMeta: + '@angular/compiler': + optional: true + zone.js: + optional: true + + '@angular/forms@21.0.1': + resolution: {integrity: sha512-BVFPuKjxkzjzKMmpc6KxUKICpVs6J2/KzA4HjtPp/UKvdZPe8dj8vIXuc9pGf8DA4XdkjCwvv8szCgzTWi02LQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/common': 21.0.1 + '@angular/core': 21.0.1 + '@angular/platform-browser': 21.0.1 + '@standard-schema/spec': ^1.0.0 + rxjs: ^6.5.3 || ^7.4.0 + + '@angular/language-service@21.0.1': + resolution: {integrity: sha512-+QohcgWbgrsPsHFhbie1ZQaNsnoBpuVK7479WZXPyFiw4PWEceNuF0hSr9yrSNEh/kvgCu9BfJSzVf7w5Yj39A==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@angular/platform-browser-dynamic@21.0.1': + resolution: {integrity: sha512-TzCKf3p1NBK1NYoPJXLScSjVeiQ52DaXf9gweNUGtCmX3EkVKf1sx4Ny1x4DxaTwB5XZn+O+L3nVLstPBj7UGA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/common': 21.0.1 + '@angular/compiler': 21.0.1 + '@angular/core': 21.0.1 + '@angular/platform-browser': 21.0.1 + + '@angular/platform-browser@21.0.1': + resolution: {integrity: sha512-68StH9HILKUqNhQKz6KKNHzpgk1n88CIusWlmJvnb0l6iWGf3ydq5lTMKAKiZQmSDAVP5unTGfNvIkh59GRyVg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/animations': 21.0.1 + '@angular/common': 21.0.1 + '@angular/core': 21.0.1 + peerDependenciesMeta: + '@angular/animations': + optional: true + + '@angular/router@21.0.1': + resolution: {integrity: sha512-EnNbiScESZ0op9XS9qUNncWc1UcSYy90uCbDMVTTChikZt9b+e19OusFMf50zecb96VMMz+BzNY1see7Rmvx4g==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@angular/common': 21.0.1 + '@angular/core': 21.0.1 + '@angular/platform-browser': 21.0.1 + rxjs: ^6.5.3 || ^7.4.0 + + '@asamuzakjp/css-color@4.1.1': + resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} + + '@asamuzakjp/dom-selector@6.7.6': + resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.0': + resolution: {integrity: sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.4': + resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-split-export-declaration@7.24.7': + resolution: {integrity: sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.22': + resolution: {integrity: sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@emnapi/core@1.5.0': + resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} + + '@emnapi/runtime@1.5.0': + resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@esbuild/aix-ppc64@0.25.9': + resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.26.0': + resolution: {integrity: sha512-hj0sKNCQOOo2fgyII3clmJXP28VhgDfU5iy3GNHlWO76KG6N7x4D9ezH5lJtQTG+1J6MFDAJXC1qsI+W+LvZoA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.9': + resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.26.0': + resolution: {integrity: sha512-DDnoJ5eoa13L8zPh87PUlRd/IyFaIKOlRbxiwcSbeumcJ7UZKdtuMCHa1Q27LWQggug6W4m28i4/O2qiQQ5NZQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.9': + resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.26.0': + resolution: {integrity: sha512-C0hkDsYNHZkBtPxxDx177JN90/1MiCpvBNjz1f5yWJo1+5+c5zr8apjastpEG+wtPjo9FFtGG7owSsAxyKiHxA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.9': + resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.26.0': + resolution: {integrity: sha512-bKDkGXGZnj0T70cRpgmv549x38Vr2O3UWLbjT2qmIkdIWcmlg8yebcFWoT9Dku7b5OV3UqPEuNKRzlNhjwUJ9A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.9': + resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.26.0': + resolution: {integrity: sha512-6Z3naJgOuAIB0RLlJkYc81An3rTlQ/IeRdrU3dOea8h/PvZSgitZV+thNuIccw0MuK1GmIAnAmd5TrMZad8FTQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.9': + resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.26.0': + resolution: {integrity: sha512-OPnYj0zpYW0tHusMefyaMvNYQX5pNQuSsHFTHUBNp3vVXupwqpxofcjVsUx11CQhGVkGeXjC3WLjh91hgBG2xw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.9': + resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.26.0': + resolution: {integrity: sha512-jix2fa6GQeZhO1sCKNaNMjfj5hbOvoL2F5t+w6gEPxALumkpOV/wq7oUBMHBn2hY2dOm+mEV/K+xfZy3mrsxNQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.9': + resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.26.0': + resolution: {integrity: sha512-tccJaH5xHJD/239LjbVvJwf6T4kSzbk6wPFerF0uwWlkw/u7HL+wnAzAH5GB2irGhYemDgiNTp8wJzhAHQ64oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.9': + resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.26.0': + resolution: {integrity: sha512-IMJYN7FSkLttYyTbsbme0Ra14cBO5z47kpamo16IwggzzATFY2lcZAwkbcNkWiAduKrTgFJP7fW5cBI7FzcuNQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.9': + resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.26.0': + resolution: {integrity: sha512-JY8NyU31SyRmRpuc5W8PQarAx4TvuYbyxbPIpHAZdr/0g4iBr8KwQBS4kiiamGl2f42BBecHusYCsyxi7Kn8UQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.9': + resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.26.0': + resolution: {integrity: sha512-XITaGqGVLgk8WOHw8We9Z1L0lbLFip8LyQzKYFKO4zFo1PFaaSKsbNjvkb7O8kEXytmSGRkYpE8LLVpPJpsSlw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.9': + resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.26.0': + resolution: {integrity: sha512-MkggfbDIczStUJwq9wU7gQ7kO33d8j9lWuOCDifN9t47+PeI+9m2QVh51EI/zZQ1spZtFMC1nzBJ+qNGCjJnsg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.9': + resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.26.0': + resolution: {integrity: sha512-fUYup12HZWAeccNLhQ5HwNBPr4zXCPgUWzEq2Rfw7UwqwfQrFZ0SR/JljaURR8xIh9t+o1lNUFTECUTmaP7yKA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.9': + resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.26.0': + resolution: {integrity: sha512-MzRKhM0Ip+//VYwC8tialCiwUQ4G65WfALtJEFyU0GKJzfTYoPBw5XNWf0SLbCUYQbxTKamlVwPmcw4DgZzFxg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.9': + resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.26.0': + resolution: {integrity: sha512-QhCc32CwI1I4Jrg1enCv292sm3YJprW8WHHlyxJhae/dVs+KRWkbvz2Nynl5HmZDW/m9ZxrXayHzjzVNvQMGQA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.9': + resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.26.0': + resolution: {integrity: sha512-1D6vi6lfI18aNT1aTf2HV+RIlm6fxtlAp8eOJ4mmnbYmZ4boz8zYDar86sIYNh0wmiLJEbW/EocaKAX6Yso2fw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.9': + resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.26.0': + resolution: {integrity: sha512-rnDcepj7LjrKFvZkx+WrBv6wECeYACcFjdNPvVPojCPJD8nHpb3pv3AuR9CXgdnjH1O23btICj0rsp0L9wAnHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.9': + resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.26.0': + resolution: {integrity: sha512-FSWmgGp0mDNjEXXFcsf12BmVrb+sZBBBlyh3LwB/B9ac3Kkc8x5D2WimYW9N7SUkolui8JzVnVlWh7ZmjCpnxw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.9': + resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.26.0': + resolution: {integrity: sha512-0QfciUDFryD39QoSPUDshj4uNEjQhp73+3pbSAaxjV2qGOEDsM67P7KbJq7LzHoVl46oqhIhJ1S+skKGR7lMXA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.9': + resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.26.0': + resolution: {integrity: sha512-vmAK+nHhIZWImwJ3RNw9hX3fU4UGN/OqbSE0imqljNbUQC3GvVJ1jpwYoTfD6mmXmQaxdJY6Hn4jQbLGJKg5Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.9': + resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.26.0': + resolution: {integrity: sha512-GPXF7RMkJ7o9bTyUsnyNtrFMqgM3X+uM/LWw4CeHIjqc32fm0Ir6jKDnWHpj8xHFstgWDUYseSABK9KCkHGnpg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.9': + resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.26.0': + resolution: {integrity: sha512-nUHZ5jEYqbBthbiBksbmHTlbb5eElyVfs/s1iHQ8rLBq1eWsd5maOnDpCocw1OM8kFK747d1Xms8dXJHtduxSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.9': + resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.26.0': + resolution: {integrity: sha512-TMg3KCTCYYaVO+R6P5mSORhcNDDlemUVnUbb8QkboUtOhb5JWKAzd5uMIMECJQOxHZ/R+N8HHtDF5ylzLfMiLw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.9': + resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.26.0': + resolution: {integrity: sha512-apqYgoAUd6ZCb9Phcs8zN32q6l0ZQzQBdVXOofa6WvHDlSOhwCWgSfVQabGViThS40Y1NA4SCvQickgZMFZRlA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.9': + resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.26.0': + resolution: {integrity: sha512-FGJAcImbJNZzLWu7U6WB0iKHl4RuY4TsXEwxJPl9UZLS47agIZuILZEX3Pagfw7I4J3ddflomt9f0apfaJSbaw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.9': + resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.26.0': + resolution: {integrity: sha512-WAckBKaVnmFqbEhbymrPK7M086DQMpL1XoRbpmN0iW8k5JSXjDRQBhcZNa0VweItknLq9eAeCL34jK7/CDcw7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@exodus/bytes@1.7.0': + resolution: {integrity: sha512-5i+BtvujK/vM07YCGDyz4C4AyDzLmhxHMtM5HpUyPRtJPBdFPsj290ffXW+UXY21/G7GtXeHD2nRmq0T1ShyQQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@exodus/crypto': ^1.0.0-rc.4 + peerDependenciesMeta: + '@exodus/crypto': + optional: true + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.19': + resolution: {integrity: sha512-wQNz9cfcxrtEnUyG5PndC8g3gZ7lGDBzmWiXZkX8ot3vfZ+/BLjR8EvyGX4YzQLeVqtAlY/YScZpW7CW8qMoDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.9.0': + resolution: {integrity: sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.8': + resolution: {integrity: sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.6': + resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.30': + resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} + + '@listr2/prompt-adapter-inquirer@3.0.5': + resolution: {integrity: sha512-WELs+hj6xcilkloBXYf9XXK8tYEnKsgLj01Xl5ONUJpKjmT5hGVUzNUS5tooUxs7pGMrw+jFD/41WpqW4V3LDA==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@inquirer/prompts': '>= 3 < 8' + listr2: 9.0.5 + + '@lmdb/lmdb-darwin-arm64@3.4.3': + resolution: {integrity: sha512-zR6Y45VNtW5s+A+4AyhrJk0VJKhXdkLhrySCpCu7PSdnakebsOzNxf58p5Xoq66vOSuueGAxlqDAF49HwdrSTQ==} + cpu: [arm64] + os: [darwin] + + '@lmdb/lmdb-darwin-x64@3.4.3': + resolution: {integrity: sha512-nfGm5pQksBGfaj9uMbjC0YyQreny/Pl7mIDtHtw6g7WQuCgeLullr9FNRsYyKplaEJBPrCVpEjpAznxTBIrXBw==} + cpu: [x64] + os: [darwin] + + '@lmdb/lmdb-linux-arm64@3.4.3': + resolution: {integrity: sha512-uX9eaPqWb740wg5D3TCvU/js23lSRSKT7lJrrQ8IuEG/VLgpPlxO3lHDywU44yFYdGS7pElBn6ioKFKhvALZlw==} + cpu: [arm64] + os: [linux] + + '@lmdb/lmdb-linux-arm@3.4.3': + resolution: {integrity: sha512-Kjqomp7i0rgSbYSUmv9JnXpS55zYT/YcW3Bdf9oqOTjcH0/8tFAP8MLhu/i9V2pMKIURDZk63Ww49DTK0T3c/Q==} + cpu: [arm] + os: [linux] + + '@lmdb/lmdb-linux-x64@3.4.3': + resolution: {integrity: sha512-7/8l20D55CfwdMupkc3fNxNJdn4bHsti2X0cp6PwiXlLeSFvAfWs5kCCx+2Cyje4l4GtN//LtKWjTru/9hDJQg==} + cpu: [x64] + os: [linux] + + '@lmdb/lmdb-win32-arm64@3.4.3': + resolution: {integrity: sha512-yWVR0e5Gl35EGJBsAuqPOdjtUYuN8CcTLKrqpQFoM+KsMadViVCulhKNhkcjSGJB88Am5bRPjMro4MBB9FS23Q==} + cpu: [arm64] + os: [win32] + + '@lmdb/lmdb-win32-x64@3.4.3': + resolution: {integrity: sha512-1JdBkcO0Vrua4LUgr4jAe4FUyluwCeq/pDkBrlaVjX3/BBWP1TzVjCL+TibWNQtPAL1BITXPAhlK5Ru4FBd/hg==} + cpu: [x64] + os: [win32] + + '@mdi/js@7.4.47': + resolution: {integrity: sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==} + + '@modelcontextprotocol/sdk@1.20.1': + resolution: {integrity: sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==} + engines: {node: '>=18'} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + + '@napi-rs/nice-android-arm-eabi@1.1.1': + resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/nice-android-arm64@1.1.1': + resolution: {integrity: sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/nice-darwin-arm64@1.1.1': + resolution: {integrity: sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/nice-darwin-x64@1.1.1': + resolution: {integrity: sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/nice-freebsd-x64@1.1.1': + resolution: {integrity: sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + resolution: {integrity: sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + resolution: {integrity: sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/nice-linux-arm64-musl@1.1.1': + resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + + '@napi-rs/nice-linux-x64-gnu@1.1.1': + resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/nice-linux-x64-musl@1.1.1': + resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/nice-openharmony-arm64@1.1.1': + resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [openharmony] + + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + resolution: {integrity: sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + resolution: {integrity: sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/nice-win32-x64-msvc@1.1.1': + resolution: {integrity: sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/nice@1.1.1': + resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==} + engines: {node: '>= 10'} + + '@napi-rs/wasm-runtime@1.0.7': + resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} + + '@npmcli/agent@4.0.0': + resolution: {integrity: sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/fs@5.0.0': + resolution: {integrity: sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/git@7.0.1': + resolution: {integrity: sha512-+XTFxK2jJF/EJJ5SoAzXk3qwIDfvFc5/g+bD274LZ7uY7LE8sTfG6Z8rOanPl2ZEvZWqNvmEdtXC25cE54VcoA==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/installed-package-contents@3.0.0': + resolution: {integrity: sha512-fkxoPuFGvxyrH+OQzyTkX2LUEamrF4jZSmxjAtPPHHGO0dqsQ8tTKjnIS8SAnPHdk2I03BDtSMR5K/4loKg79Q==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + '@npmcli/node-gyp@5.0.0': + resolution: {integrity: sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/package-json@7.0.4': + resolution: {integrity: sha512-0wInJG3j/K40OJt/33ax47WfWMzZTm6OQxB9cDhTt5huCP2a9g2GnlsxmfN+PulItNPIpPrZ+kfwwUil7eHcZQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/promise-spawn@8.0.2': + resolution: {integrity: sha512-/bNJhjc+o6qL+Dwz/bqfTQClkEO5nTQ1ZEcdCkAQjhkZMHIh22LPG7fNh1enJP1NKWDqYiiABnjFCY7E0zHYtQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + '@npmcli/promise-spawn@9.0.1': + resolution: {integrity: sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/redact@4.0.0': + resolution: {integrity: sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/run-script@10.0.3': + resolution: {integrity: sha512-ER2N6itRkzWbbtVmZ9WKaWxVlKlOeBFF1/7xx+KA5J1xKa4JjUwBdb6tDpk0v1qA+d+VDwHI9qmLcXSWcmi+Rw==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@oxc-project/types@0.96.0': + resolution: {integrity: sha512-r/xkmoXA0xEpU6UGtn18CNVjXH6erU3KCpCDbpLmbVxBFor1U9MqN5Z2uMmCHJuXjJzlnDR+hWY+yPoLo8oHDw==} + + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@rolldown/binding-android-arm64@1.0.0-beta.47': + resolution: {integrity: sha512-vPP9/MZzESh9QtmvQYojXP/midjgkkc1E4AdnPPAzQXo668ncHJcVLKjJKzoBdsQmaIvNjrMdsCwES8vTQHRQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-beta.47': + resolution: {integrity: sha512-Lc3nrkxeaDVCVl8qR3qoxh6ltDZfkQ98j5vwIr5ALPkgjZtDK4BGCrrBoLpGVMg+csWcaqUbwbKwH5yvVa0oOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-beta.47': + resolution: {integrity: sha512-eBYxQDwP0O33plqNVqOtUHqRiSYVneAknviM5XMawke3mwMuVlAsohtOqEjbCEl/Loi/FWdVeks5WkqAkzkYWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-beta.47': + resolution: {integrity: sha512-Ns+kgp2+1Iq/44bY/Z30DETUSiHY7ZuqaOgD5bHVW++8vme9rdiWsN4yG4rRPXkdgzjvQ9TDHmZZKfY4/G11AA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.47': + resolution: {integrity: sha512-4PecgWCJhTA2EFOlptYJiNyVP2MrVP4cWdndpOu3WmXqWqZUmSubhb4YUAIxAxnXATlGjC1WjxNPhV7ZllNgdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.47': + resolution: {integrity: sha512-CyIunZ6D9U9Xg94roQI1INt/bLkOpPsZjZZkiaAZ0r6uccQdICmC99M9RUPlMLw/qg4yEWLlQhG73W/mG437NA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.47': + resolution: {integrity: sha512-doozc/Goe7qRCSnzfJbFINTHsMktqmZQmweull6hsZZ9sjNWQ6BWQnbvOlfZJe4xE5NxM1NhPnY5Giqnl3ZrYQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.47': + resolution: {integrity: sha512-fodvSMf6Aqwa0wEUSTPewmmZOD44rc5Tpr5p9NkwQ6W1SSpUKzD3SwpJIgANDOhwiYhDuiIaYPGB7Ujkx1q0UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.47': + resolution: {integrity: sha512-Rxm5hYc0mGjwLh5sjlGmMygxAaV2gnsx7CNm2lsb47oyt5UQyPDZf3GP/ct8BEcwuikdqzsrrlIp8+kCSvMFNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.47': + resolution: {integrity: sha512-YakuVe+Gc87jjxazBL34hbr8RJpRuFBhun7NEqoChVDlH5FLhLXjAPHqZd990TVGVNkemourf817Z8u2fONS8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.47': + resolution: {integrity: sha512-ak2GvTFQz3UAOw8cuQq8pWE+TNygQB6O47rMhvevvTzETh7VkHRFtRUwJynX5hwzFvQMP6G0az5JrBGuwaMwYQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.47': + resolution: {integrity: sha512-o5BpmBnXU+Cj+9+ndMcdKjhZlPb79dVPBZnWwMnI4RlNSSq5yOvFZqvfPYbyacvnW03Na4n5XXQAPhu3RydZ0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.47': + resolution: {integrity: sha512-FVOmfyYehNE92IfC9Kgs913UerDog2M1m+FADJypKz0gmRg3UyTt4o1cZMCAl7MiR89JpM9jegNO1nXuP1w1vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.47': + resolution: {integrity: sha512-by/70F13IUE101Bat0oeH8miwWX5mhMFPk1yjCdxoTNHTyTdLgb0THNaebRM6AP7Kz+O3O2qx87sruYuF5UxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.47': + resolution: {integrity: sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==} + + '@rollup/rollup-android-arm-eabi@4.50.0': + resolution: {integrity: sha512-lVgpeQyy4fWN5QYebtW4buT/4kn4p4IJ+kDNB4uYNT5b8c8DLJDg6titg20NIg7E8RWwdWZORW6vUFfrLyG3KQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.50.0': + resolution: {integrity: sha512-2O73dR4Dc9bp+wSYhviP6sDziurB5/HCym7xILKifWdE9UsOe2FtNcM+I4xZjKrfLJnq5UR8k9riB87gauiQtw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.50.0': + resolution: {integrity: sha512-vwSXQN8T4sKf1RHr1F0s98Pf8UPz7pS6P3LG9NSmuw0TVh7EmaE+5Ny7hJOZ0M2yuTctEsHHRTMi2wuHkdS6Hg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.50.0': + resolution: {integrity: sha512-cQp/WG8HE7BCGyFVuzUg0FNmupxC+EPZEwWu2FCGGw5WDT1o2/YlENbm5e9SMvfDFR6FRhVCBePLqj0o8MN7Vw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.50.0': + resolution: {integrity: sha512-UR1uTJFU/p801DvvBbtDD7z9mQL8J80xB0bR7DqW7UGQHRm/OaKzp4is7sQSdbt2pjjSS72eAtRh43hNduTnnQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.50.0': + resolution: {integrity: sha512-G/DKyS6PK0dD0+VEzH/6n/hWDNPDZSMBmqsElWnCRGrYOb2jC0VSupp7UAHHQ4+QILwkxSMaYIbQ72dktp8pKA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.50.0': + resolution: {integrity: sha512-u72Mzc6jyJwKjJbZZcIYmd9bumJu7KNmHYdue43vT1rXPm2rITwmPWF0mmPzLm9/vJWxIRbao/jrQmxTO0Sm9w==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.50.0': + resolution: {integrity: sha512-S4UefYdV0tnynDJV1mdkNawp0E5Qm2MtSs330IyHgaccOFrwqsvgigUD29uT+B/70PDY1eQ3t40+xf6wIvXJyg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.50.0': + resolution: {integrity: sha512-1EhkSvUQXJsIhk4msxP5nNAUWoB4MFDHhtc4gAYvnqoHlaL9V3F37pNHabndawsfy/Tp7BPiy/aSa6XBYbaD1g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.50.0': + resolution: {integrity: sha512-EtBDIZuDtVg75xIPIK1l5vCXNNCIRM0OBPUG+tbApDuJAy9mKago6QxX+tfMzbCI6tXEhMuZuN1+CU8iDW+0UQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.50.0': + resolution: {integrity: sha512-BGYSwJdMP0hT5CCmljuSNx7+k+0upweM2M4YGfFBjnFSZMHOLYR0gEEj/dxyYJ6Zc6AiSeaBY8dWOa11GF/ppQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.50.0': + resolution: {integrity: sha512-I1gSMzkVe1KzAxKAroCJL30hA4DqSi+wGc5gviD0y3IL/VkvcnAqwBf4RHXHyvH66YVHxpKO8ojrgc4SrWAnLg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.50.0': + resolution: {integrity: sha512-bSbWlY3jZo7molh4tc5dKfeSxkqnf48UsLqYbUhnkdnfgZjgufLS/NTA8PcP/dnvct5CCdNkABJ56CbclMRYCA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.50.0': + resolution: {integrity: sha512-LSXSGumSURzEQLT2e4sFqFOv3LWZsEF8FK7AAv9zHZNDdMnUPYH3t8ZlaeYYZyTXnsob3htwTKeWtBIkPV27iQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.50.0': + resolution: {integrity: sha512-CxRKyakfDrsLXiCyucVfVWVoaPA4oFSpPpDwlMcDFQvrv3XY6KEzMtMZrA+e/goC8xxp2WSOxHQubP8fPmmjOQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.50.0': + resolution: {integrity: sha512-8PrJJA7/VU8ToHVEPu14FzuSAqVKyo5gg/J8xUerMbyNkWkO9j2ExBho/68RnJsMGNJq4zH114iAttgm7BZVkA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.50.0': + resolution: {integrity: sha512-SkE6YQp+CzpyOrbw7Oc4MgXFvTw2UIBElvAvLCo230pyxOLmYwRPwZ/L5lBe/VW/qT1ZgND9wJfOsdy0XptRvw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.50.0': + resolution: {integrity: sha512-PZkNLPfvXeIOgJWA804zjSFH7fARBBCpCXxgkGDRjjAhRLOR8o0IGS01ykh5GYfod4c2yiiREuDM8iZ+pVsT+Q==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.50.0': + resolution: {integrity: sha512-q7cIIdFvWQoaCbLDUyUc8YfR3Jh2xx3unO8Dn6/TTogKjfwrax9SyfmGGK6cQhKtjePI7jRfd7iRYcxYs93esg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.50.0': + resolution: {integrity: sha512-XzNOVg/YnDOmFdDKcxxK410PrcbcqZkBmz+0FicpW5jtjKQxcW1BZJEQOF0NJa6JO7CZhett8GEtRN/wYLYJuw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.50.0': + resolution: {integrity: sha512-xMmiWRR8sp72Zqwjgtf3QbZfF1wdh8X2ABu3EaozvZcyHJeU0r+XAnXdKgs4cCAp6ORoYoCygipYP1mjmbjrsg==} + cpu: [x64] + os: [win32] + + '@schematics/angular@21.0.1': + resolution: {integrity: sha512-m7Z/gykPxOyC5Gs9nkFkGwYTc5xLNLcVkjjZPcYszycwsWBohDREjQLZzRG86AauWFYy8mBUrTF9CD63ZqYHeQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@sigstore/bundle@4.0.0': + resolution: {integrity: sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/core@3.0.0': + resolution: {integrity: sha512-NgbJ+aW9gQl/25+GIEGYcCyi8M+ng2/5X04BMuIgoDfgvp18vDcoNHOQjQsG9418HGNYRxG3vfEXaR1ayD37gg==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/protobuf-specs@0.5.0': + resolution: {integrity: sha512-MM8XIwUjN2bwvCg1QvrMtbBmpcSHrkhFSCu1D11NyPvDQ25HEc4oG5/OcQfd/Tlf/OxmKWERDj0zGE23jQaMwA==} + engines: {node: ^18.17.0 || >=20.5.0} + + '@sigstore/sign@4.0.1': + resolution: {integrity: sha512-KFNGy01gx9Y3IBPG/CergxR9RZpN43N+lt3EozEfeoyqm8vEiLxwRl3ZO5sPx3Obv1ix/p7FWOlPc2Jgwfp9PA==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/tuf@4.0.0': + resolution: {integrity: sha512-0QFuWDHOQmz7t66gfpfNO6aEjoFrdhkJaej/AOqb4kqWZVbPWFZifXZzkxyQBB1OwTbkhdT3LNpMFxwkTvf+2w==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/verify@3.0.0': + resolution: {integrity: sha512-moXtHH33AobOhTZF8xcX1MpOFqdvfCk7v6+teJL8zymBiDXwEsQH6XG9HGx2VIxnJZNm4cNSzflTLDnQLmIdmw==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + + '@tailwindcss/node@4.1.4': + resolution: {integrity: sha512-MT5118zaiO6x6hNA04OWInuAiP1YISXql8Z+/Y8iisV5nuhM8VXlyhRuqc2PEviPszcXI66W44bCIk500Oolhw==} + + '@tailwindcss/oxide-android-arm64@4.1.4': + resolution: {integrity: sha512-xMMAe/SaCN/vHfQYui3fqaBDEXMu22BVwQ33veLc8ep+DNy7CWN52L+TTG9y1K397w9nkzv+Mw+mZWISiqhmlA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.4': + resolution: {integrity: sha512-JGRj0SYFuDuAGilWFBlshcexev2hOKfNkoX+0QTksKYq2zgF9VY/vVMq9m8IObYnLna0Xlg+ytCi2FN2rOL0Sg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.4': + resolution: {integrity: sha512-sdDeLNvs3cYeWsEJ4H1DvjOzaGios4QbBTNLVLVs0XQ0V95bffT3+scptzYGPMjm7xv4+qMhCDrkHwhnUySEzA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.4': + resolution: {integrity: sha512-VHxAqxqdghM83HslPhRsNhHo91McsxRJaEnShJOMu8mHmEj9Ig7ToHJtDukkuLWLzLboh2XSjq/0zO6wgvykNA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.4': + resolution: {integrity: sha512-OTU/m/eV4gQKxy9r5acuesqaymyeSCnsx1cFto/I1WhPmi5HDxX1nkzb8KYBiwkHIGg7CTfo/AcGzoXAJBxLfg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.4': + resolution: {integrity: sha512-hKlLNvbmUC6z5g/J4H+Zx7f7w15whSVImokLPmP6ff1QqTVE+TxUM9PGuNsjHvkvlHUtGTdDnOvGNSEUiXI1Ww==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.4': + resolution: {integrity: sha512-X3As2xhtgPTY/m5edUtddmZ8rCruvBvtxYLMw9OsZdH01L2gS2icsHRwxdU0dMItNfVmrBezueXZCHxVeeb7Aw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.4': + resolution: {integrity: sha512-2VG4DqhGaDSmYIu6C4ua2vSLXnJsb/C9liej7TuSO04NK+JJJgJucDUgmX6sn7Gw3Cs5ZJ9ZLrnI0QRDOjLfNQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.4': + resolution: {integrity: sha512-v+mxVgH2kmur/X5Mdrz9m7TsoVjbdYQT0b4Z+dr+I4RvreCNXyCFELZL/DO0M1RsidZTrm6O1eMnV6zlgEzTMQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.4': + resolution: {integrity: sha512-2TLe9ir+9esCf6Wm+lLWTMbgklIjiF0pbmDnwmhR9MksVOq+e8aP3TSsXySnBDDvTTVd/vKu1aNttEGj3P6l8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.4': + resolution: {integrity: sha512-VlnhfilPlO0ltxW9/BgfLI5547PYzqBMPIzRrk4W7uupgCt8z6Trw/tAj6QUtF2om+1MH281Pg+HHUJoLesmng==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.4': + resolution: {integrity: sha512-+7S63t5zhYjslUGb8NcgLpFXD+Kq1F/zt5Xv5qTv7HaFTG/DHyHD9GA6ieNAxhgyA4IcKa/zy7Xx4Oad2/wuhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.4': + resolution: {integrity: sha512-p5wOpXyOJx7mKh5MXh5oKk+kqcz8T+bA3z/5VWWeQwFrmuBItGwz8Y2CHk/sJ+dNb9B0nYFfn0rj/cKHZyjahQ==} + engines: {node: '>= 10'} + + '@tailwindcss/postcss@4.1.4': + resolution: {integrity: sha512-bjV6sqycCEa+AQSt2Kr7wpGF1bOZJ5wsqnLEkqSbM/JEHxx/yhMH8wHmdkPyApF9xhHeMSwnnkDUUMMM/hYnXw==} + + '@tufjs/canonical-json@2.0.0': + resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@tufjs/models@4.0.0': + resolution: {integrity: sha512-h5x5ga/hh82COe+GoD4+gKUeV4T3iaYOxqLt41GRKApinPI7DMidhCmNVTjKfhCWFJIGXaFJee07XczdT4jdZQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/cors@2.8.17': + resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/jasmine@5.1.7': + resolution: {integrity: sha512-DVOfk9FaClQfNFpSfaML15jjB5cjffDMvjtph525sroR5BEAW2uKnTOYUTqTFuZFjNvH0T5XMIydvIctnUKufw==} + + '@types/node@22.13.10': + resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==} + + '@vitejs/plugin-basic-ssl@2.1.0': + resolution: {integrity: sha512-dOxxrhgyDIEUADhb/8OlV9JIqYLgos03YorAueTIeOUskLJSEsfwCByjbu98ctXitUN3znXKp0bYD/WHSudCeA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + peerDependencies: + vite: ^6.0.0 || ^7.0.0 + + '@vitest/expect@4.0.16': + resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} + + '@vitest/mocker@4.0.16': + resolution: {integrity: sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.16': + resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} + + '@vitest/runner@4.0.16': + resolution: {integrity: sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==} + + '@vitest/snapshot@4.0.16': + resolution: {integrity: sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==} + + '@vitest/spy@4.0.16': + resolution: {integrity: sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==} + + '@vitest/utils@4.0.16': + resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} + + '@yarnpkg/lockfile@1.1.0': + resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} + + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.3: + resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + algoliasearch@5.40.1: + resolution: {integrity: sha512-iUNxcXUNg9085TJx0HJLjqtDE0r1RZ0GOGrt8KNQqQT5ugu8lZsHuMUYW/e0lHhq6xBvmktU9Bw4CXP9VQeKrg==} + engines: {node: '>= 14.0.0'} + + ansi-escapes@7.0.0: + resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + autoprefixer@10.4.21: + resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + + baseline-browser-mapping@2.8.32: + resolution: {integrity: sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==} + hasBin: true + + beasties@0.3.5: + resolution: {integrity: sha512-NaWu+f4YrJxEttJSm16AzMIFtVldCvaJ68b1L098KpqXmxt9xOLtKoLkKxb8ekhOrLqEJAbvT6n6SEvB/sac7A==} + engines: {node: '>=14.0.0'} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + body-parser@2.2.0: + resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + engines: {node: '>=18'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.24.4: + resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + browserslist@4.28.0: + resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cacache@20.0.3: + resolution: {integrity: sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==} + engines: {node: ^20.17.0 || >=22.9.0} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001705: + resolution: {integrity: sha512-S0uyMMiYvA7CxNgomYBwwwPUnWzFD83f3B1ce5jHUfHTH//QL6hHsreI8RVC5606R4ssqravelYO5TU6t8sEyg==} + + caniuse-lite@1.0.30001757: + resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@3.3.0: + resolution: {integrity: sha512-/+40ljC3ONVnYIttjMWrlL51nItDAbBrq2upN8BPyvGU/2n5Oxw3tbNwORCaNuNqLJnxGqOfjUuhsv7l5Q4IsQ==} + engines: {node: '>=18.20'} + + cli-truncate@5.1.1: + resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} + engines: {node: '>=20'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + connect@3.7.0: + resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + engines: {node: '>= 0.10.0'} + + content-disposition@1.0.0: + resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + copy-anything@2.0.6: + resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-select@6.0.0: + resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} + + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@7.0.0: + resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} + engines: {node: '>= 6'} + + cssstyle@5.3.6: + resolution: {integrity: sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A==} + engines: {node: '>=20'} + + custom-event@1.0.1: + resolution: {integrity: sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==} + + daisyui@5.0.27: + resolution: {integrity: sha512-XrpqgfpGaZJvTPg9pS9Rq6xbYpmMnR0a7AKqyVPZceJzjAs5HH3rfkRkiuGin0+KC2Adnu+WLHU7UDxAtCMyAw==} + + data-urls@6.0.0: + resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} + engines: {node: '>=20'} + + date-format@4.0.14: + resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==} + engines: {node: '>=4.0'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + engines: {node: '>=8'} + + di@0.0.1: + resolution: {integrity: sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==} + + dom-serialize@2.2.1: + resolution: {integrity: sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.119: + resolution: {integrity: sha512-Ku4NMzUjz3e3Vweh7PhApPrZSS4fyiCIbcIrG9eKrriYVLmbMepETR/v6SU7xPm98QTqMSYiCwfO89QNjXLkbQ==} + + electron-to-chromium@1.5.262: + resolution: {integrity: sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==} + + emoji-regex@10.4.0: + resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.4: + resolution: {integrity: sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==} + engines: {node: '>=10.2.0'} + + enhanced-resolve@5.18.1: + resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} + engines: {node: '>=10.13.0'} + + ent@2.2.2: + resolution: {integrity: sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==} + engines: {node: '>= 0.4'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.0: + resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + errno@0.1.8: + resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} + hasBin: true + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild@0.25.9: + resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.26.0: + resolution: {integrity: sha512-3Hq7jri+tRrVWha+ZeIVhl4qJRha/XjRNSopvTsOaCvfPHrflTYTcUFcEjMKdxofsXXsdc4zjg5NOTnL4Gl57Q==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + exponential-backoff@3.1.2: + resolution: {integrity: sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==} + + express-rate-limit@7.5.1: + resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.1.0: + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + engines: {node: '>= 18'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-uri@3.0.6: + resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + + finalhandler@2.1.0: + resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} + engines: {node: '>= 0.8'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-minipass@3.0.3: + resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.3.0: + resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + glob@13.0.0: + resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} + engines: {node: 20 || >=22} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hosted-git-info@9.0.0: + resolution: {integrity: sha512-gEf705MZLrDPkbbhi8PnoO4ZwYgKoNL+ISZ3AjZMht2r3N5tuTwncyDi6Fv2/qDnMmZxgs0yI8WDOyR8q3G+SQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + htmlparser2@10.0.0: + resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} + + http-cache-semantics@4.1.1: + resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.0: + resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + engines: {node: '>=0.10.0'} + + ignore-walk@8.0.0: + resolution: {integrity: sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==} + engines: {node: ^20.17.0 || >=22.9.0} + + image-size@0.5.5: + resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} + engines: {node: '>=0.10.0'} + hasBin: true + + immutable@5.0.3: + resolution: {integrity: sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@5.0.0: + resolution: {integrity: sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==} + engines: {node: ^18.17.0 || >=20.5.0} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + ip-address@9.0.5: + resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@5.0.0: + resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-what@3.14.1: + resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==} + + isbinaryfile@4.0.10: + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.1: + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} + engines: {node: '>=16'} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jasmine-core@5.1.2: + resolution: {integrity: sha512-2oIUMGn00FdUiqz6epiiJr7xcFyNYj3rDcfmnzfkBnHyBQ3cBQUs4mmyGsOb7TTLb9kxk7dBcmEmqhDKkBoDyA==} + + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsbn@1.1.0: + resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} + + jsdom@27.4.0: + resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@5.0.0: + resolution: {integrity: sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonparse@1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} + engines: {'0': node >= 0.2.0} + + karma@6.4.4: + resolution: {integrity: sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==} + engines: {node: '>= 10'} + hasBin: true + + less@4.4.2: + resolution: {integrity: sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==} + engines: {node: '>=14'} + hasBin: true + + lightningcss-darwin-arm64@1.29.2: + resolution: {integrity: sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.29.2: + resolution: {integrity: sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.29.2: + resolution: {integrity: sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.29.2: + resolution: {integrity: sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.29.2: + resolution: {integrity: sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.29.2: + resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.29.2: + resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.29.2: + resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.29.2: + resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.29.2: + resolution: {integrity: sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.29.2: + resolution: {integrity: sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==} + engines: {node: '>= 12.0.0'} + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + + lmdb@3.4.3: + resolution: {integrity: sha512-GWV1kVi6uhrXWqe+3NXWO73OYe8fto6q8JMo0HOpk1vf8nEyFWgo4CSNJpIFzsOxOrysVUlcO48qRbQfmKd1gA==} + hasBin: true + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + log4js@6.9.1: + resolution: {integrity: sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==} + engines: {node: '>=8.0'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.1.0: + resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + engines: {node: 20 || >=22} + + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.19: + resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + + make-fetch-happen@15.0.3: + resolution: {integrity: sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==} + engines: {node: ^20.17.0 || >=22.9.0} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.1.1: + resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} + engines: {node: 20 || >=22} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass-collect@2.0.1: + resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass-fetch@5.0.0: + resolution: {integrity: sha512-fiCdUALipqgPWrOVTz9fw0XhcazULXOSU6ie40DDbX1F49p1dBrSRBuswndTx1x3vEb/g0FT7vC4c4C2u/mh3A==} + engines: {node: ^20.17.0 || >=22.9.0} + + minipass-flush@1.0.5: + resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@1.0.3: + resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.0.1: + resolution: {integrity: sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==} + engines: {node: '>= 18'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.2: + resolution: {integrity: sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@3.3.10: + resolution: {integrity: sha512-vSJJTG+t/dIKAUhUDw/dLdZ9s//5OxcHqLaDWWrW4Cdq7o6tdLIczUkMXt2MBNmk6sJRZBZRXVixs7URY1CmIg==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + needle@3.3.1: + resolution: {integrity: sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==} + engines: {node: '>= 4.4.x'} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-addon-api@6.1.0: + resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + node-gyp@12.1.0: + resolution: {integrity: sha512-W+RYA8jBnhSr2vrTtlPYPc1K+CSjGpVDRZxcqJcERZ8ND3A1ThWPHRwctTx3qC3oW99jt726jhdz3Y6ky87J4g==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + npm-bundled@4.0.0: + resolution: {integrity: sha512-IxaQZDMsqfQ2Lz37VvyyEtKLe8FsRZuysmedy/N06TU1RyVppYKXrO4xIhR0F+7ubIBox6Q7nir6fQI3ej39iA==} + engines: {node: ^18.17.0 || >=20.5.0} + + npm-install-checks@8.0.0: + resolution: {integrity: sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-normalize-package-bin@4.0.0: + resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==} + engines: {node: ^18.17.0 || >=20.5.0} + + npm-normalize-package-bin@5.0.0: + resolution: {integrity: sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-package-arg@13.0.1: + resolution: {integrity: sha512-6zqls5xFvJbgFjB1B2U6yITtyGBjDBORB7suI4zA4T/sZ1OmkMFlaQSNB/4K0LtXNA1t4OprAFxPisadK5O2ag==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-packlist@10.0.3: + resolution: {integrity: sha512-zPukTwJMOu5X5uvm0fztwS5Zxyvmk38H/LfidkOMt3gbZVCyro2cD/ETzwzVPcWZA3JOyPznfUN/nkyFiyUbxg==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-pick-manifest@11.0.3: + resolution: {integrity: sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + npm-registry-fetch@19.1.1: + resolution: {integrity: sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==} + engines: {node: ^20.17.0 || >=22.9.0} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + ora@9.0.0: + resolution: {integrity: sha512-m0pg2zscbYgWbqRR6ABga5c3sZdEon7bSgjnlXC64kxtxLOyjRcbbUkLj7HFyy/FTD+P2xdBWu8snGhYI0jc4A==} + engines: {node: '>=20'} + + ordered-binary@1.5.3: + resolution: {integrity: sha512-oGFr3T+pYdTGJ+YFEILMpS3es+GiIbs9h/XQrclBXUtd44ey7XwfsMzM31f64I1SQOawDoDr/D823kNCADI8TA==} + + p-map@7.0.3: + resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==} + engines: {node: '>=18'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + pacote@21.0.3: + resolution: {integrity: sha512-itdFlanxO0nmQv4ORsvA9K1wv40IPfB9OmWqfaJWvoJ30VKyHsqNgDVeG+TVhI7Gk7XW8slUy7cA9r6dF5qohw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + parse-node-version@1.0.1: + resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} + engines: {node: '>= 0.10'} + + parse5-html-rewriting-stream@8.0.0: + resolution: {integrity: sha512-wzh11mj8KKkno1pZEu+l2EVeWsuKDfR5KNWZOTsslfUX8lPDZx77m9T0kIoAVkFtD1nx6YF8oh4BnPHvxMtNMw==} + + parse5-sax-parser@8.0.0: + resolution: {integrity: sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==} + + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} + + path-to-regexp@8.2.0: + resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} + engines: {node: '>=16'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + piscina@5.1.3: + resolution: {integrity: sha512-0u3N7H4+hbr40KjuVn2uNhOcthu/9usKhnw5vT3J7ply79v3D3M8naI00el9Klcy16x557VsEkkUQaHCWFXC/g==} + engines: {node: '>=20.x'} + + pkce-challenge@5.0.0: + resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} + engines: {node: '>=16.20.0'} + + postcss-media-query-parser@0.2.3: + resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.3: + resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + proc-log@5.0.0: + resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + prr@1.0.1: + resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + + punycode@1.4.1: + resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qjobs@1.2.0: + resolution: {integrity: sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==} + engines: {node: '>=0.9'} + + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + + raw-body@3.0.0: + resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} + engines: {node: '>= 0.8'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + + rolldown@1.0.0-beta.47: + resolution: {integrity: sha512-Mid74GckX1OeFAOYz9KuXeWYhq3xkXbMziYIC+ULVdUzPTG9y70OBSBQDQn9hQP8u/AfhuYw1R0BSg15nBI4Dg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.50.0: + resolution: {integrity: sha512-/Zl4D8zPifNmyGzJS+3kVoyXeDeT/GrsJM94sACNg9RtUE0hrHa1bNPtRSrfHTMH5HjRzce6K7rlTh3Khiw+pw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sass@1.93.2: + resolution: {integrity: sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==} + engines: {node: '>=14.0.0'} + hasBin: true + + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.0: + resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} + engines: {node: '>= 18'} + + serve-static@2.2.0: + resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sigstore@4.0.0: + resolution: {integrity: sha512-Gw/FgHtrLM9WP8P5lLcSGh9OQcrTruWCELAiS48ik1QbL0cH+dfjomiRTUE9zzz+D1N6rOLkwXUvVmXZAsNE0Q==} + engines: {node: ^20.17.0 || >=22.9.0} + + slice-ansi@7.1.0: + resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} + engines: {node: '>=18'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socket.io-adapter@2.5.5: + resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==} + + socket.io-parser@4.2.4: + resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.1: + resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==} + engines: {node: '>=10.2.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.4: + resolution: {integrity: sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.21: + resolution: {integrity: sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + ssri@12.0.0: + resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} + engines: {node: ^18.17.0 || >=20.5.0} + + ssri@13.0.0: + resolution: {integrity: sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==} + engines: {node: ^20.17.0 || >=22.9.0} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + streamroller@3.1.5: + resolution: {integrity: sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==} + engines: {node: '>=8.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.1.0: + resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} + engines: {node: '>=20'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwindcss@4.1.4: + resolution: {integrity: sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==} + + tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + + tar@7.4.3: + resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} + engines: {node: '>=18'} + + tar@7.5.2: + resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==} + engines: {node: '>=18'} + + terser@5.44.0: + resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} + engines: {node: '>=10'} + hasBin: true + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.14: + resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + engines: {node: '>=12.0.0'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + hasBin: true + + tmp@0.2.3: + resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + engines: {node: '>=14.14'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tuf-js@4.0.0: + resolution: {integrity: sha512-Lq7ieeGvXDXwpoSmOSgLWVdsGGV9J4a77oDTAPe/Ltrqnnm/ETaRlBAQTH5JatEh8KXuE6sddf9qAv1Q2282Hg==} + engines: {node: ^20.17.0 || >=22.9.0} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-parser-js@0.7.40: + resolution: {integrity: sha512-us1E3K+3jJppDBa3Tl0L3MOJiGhe1C6P0+nIvQAFYbxlMAx0h81eOwLmU57xgqToduDDPx3y5QsdjPfDu+FgOQ==} + hasBin: true + + undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + + undici@7.16.0: + resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + engines: {node: '>=20.18.1'} + + unique-filename@5.0.0: + resolution: {integrity: sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==} + engines: {node: ^20.17.0 || >=22.9.0} + + unique-slug@6.0.0: + resolution: {integrity: sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==} + engines: {node: ^20.17.0 || >=22.9.0} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + update-browserslist-db@1.1.4: + resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + + validate-npm-package-name@6.0.0: + resolution: {integrity: sha512-d7KLgL1LD3U3fgnvWEY1cQXoO/q6EQ1BSz48Sa149V/5zVTAbgmZIpyI8TRi6U9/JNyeYLlTKsEMPtLC27RFUg==} + engines: {node: ^18.17.0 || >=20.5.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@7.2.2: + resolution: {integrity: sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + 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 + + vitest@4.0.16: + resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.16 + '@vitest/browser-preview': 4.0.16 + '@vitest/browser-webdriverio': 4.0.16 + '@vitest/ui': 4.0.16 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + void-elements@2.0.1: + resolution: {integrity: sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==} + engines: {node: '>=0.10.0'} + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + watchpack@2.4.4: + resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} + engines: {node: '>=10.13.0'} + + weak-lru-cache@1.2.2: + resolution: {integrity: sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==} + + webidl-conversions@8.0.0: + resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} + engines: {node: '>=20'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + which@6.0.0: + resolution: {integrity: sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml@2.7.0: + resolution: {integrity: sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==} + engines: {node: '>= 14'} + hasBin: true + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + + zod-to-json-schema@3.24.6: + resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==} + peerDependencies: + zod: ^3.24.1 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zone.js@0.15.0: + resolution: {integrity: sha512-9oxn0IIjbCZkJ67L+LkhYWRyAy7axphb3VgE2MBDlOqnmHMPWGYMxJxBYFueFq/JGY2GMwS0rU+UCLunEmy5UA==} + +snapshots: + + '@acemir/cssom@0.9.30': {} + + '@algolia/abtesting@1.6.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/client-abtesting@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/client-analytics@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/client-common@5.40.1': {} + + '@algolia/client-insights@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/client-personalization@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/client-query-suggestions@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/client-search@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/ingestion@1.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/monitoring@1.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/recommend@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + '@algolia/requester-browser-xhr@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + + '@algolia/requester-fetch@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + + '@algolia/requester-node-http@5.40.1': + dependencies: + '@algolia/client-common': 5.40.1 + + '@alloc/quick-lru@5.2.0': {} + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 + + '@angular-devkit/architect@0.2100.1(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 21.0.1(chokidar@4.0.3) + rxjs: 7.8.2 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/core@21.0.1(chokidar@4.0.3)': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + jsonc-parser: 3.3.1 + picomatch: 4.0.3 + rxjs: 7.8.2 + source-map: 0.7.6 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/schematics@21.0.1(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 21.0.1(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.19 + ora: 9.0.0 + rxjs: 7.8.2 + transitivePeerDependencies: + - chokidar + + '@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))': + dependencies: + '@angular/core': 21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0) + tslib: 2.8.1 + + '@angular/build@21.0.1(@angular/compiler-cli@21.0.1(@angular/compiler@21.0.1)(typescript@5.9.3))(@angular/compiler@21.0.1)(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@types/node@22.13.10)(chokidar@4.0.3)(jiti@2.6.1)(karma@6.4.4)(less@4.4.2)(lightningcss@1.29.2)(postcss@8.5.3)(tailwindcss@4.1.4)(terser@5.44.0)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.0.16(@types/node@22.13.10)(jiti@2.6.1)(jsdom@27.4.0)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0))(yaml@2.7.0)': + dependencies: + '@ampproject/remapping': 2.3.0 + '@angular-devkit/architect': 0.2100.1(chokidar@4.0.3) + '@angular/compiler': 21.0.1 + '@angular/compiler-cli': 21.0.1(@angular/compiler@21.0.1)(typescript@5.9.3) + '@babel/core': 7.28.4 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-split-export-declaration': 7.24.7 + '@inquirer/confirm': 5.1.19(@types/node@22.13.10) + '@vitejs/plugin-basic-ssl': 2.1.0(vite@7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0)) + beasties: 0.3.5 + browserslist: 4.28.0 + esbuild: 0.26.0 + https-proxy-agent: 7.0.6 + istanbul-lib-instrument: 6.0.3 + jsonc-parser: 3.3.1 + listr2: 9.0.5 + magic-string: 0.30.19 + mrmime: 2.0.1 + parse5-html-rewriting-stream: 8.0.0 + picomatch: 4.0.3 + piscina: 5.1.3 + rolldown: 1.0.0-beta.47 + sass: 1.93.2 + semver: 7.7.3 + source-map-support: 0.5.21 + tinyglobby: 0.2.15 + tslib: 2.8.1 + typescript: 5.9.3 + undici: 7.16.0 + vite: 7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0) + watchpack: 2.4.4 + optionalDependencies: + '@angular/core': 21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/platform-browser': 21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)) + karma: 6.4.4 + less: 4.4.2 + lmdb: 3.4.3 + postcss: 8.5.3 + tailwindcss: 4.1.4 + vitest: 4.0.16(@types/node@22.13.10)(jiti@2.6.1)(jsdom@27.4.0)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0) + transitivePeerDependencies: + - '@types/node' + - chokidar + - jiti + - lightningcss + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + '@angular/cli@21.0.1(@types/node@22.13.10)(chokidar@4.0.3)': + dependencies: + '@angular-devkit/architect': 0.2100.1(chokidar@4.0.3) + '@angular-devkit/core': 21.0.1(chokidar@4.0.3) + '@angular-devkit/schematics': 21.0.1(chokidar@4.0.3) + '@inquirer/prompts': 7.9.0(@types/node@22.13.10) + '@listr2/prompt-adapter-inquirer': 3.0.5(@inquirer/prompts@7.9.0(@types/node@22.13.10))(@types/node@22.13.10)(listr2@9.0.5) + '@modelcontextprotocol/sdk': 1.20.1 + '@schematics/angular': 21.0.1(chokidar@4.0.3) + '@yarnpkg/lockfile': 1.1.0 + algoliasearch: 5.40.1 + ini: 5.0.0 + jsonc-parser: 3.3.1 + listr2: 9.0.5 + npm-package-arg: 13.0.1 + pacote: 21.0.3 + parse5-html-rewriting-stream: 8.0.0 + resolve: 1.22.11 + semver: 7.7.3 + yargs: 18.0.0 + zod: 3.25.76 + transitivePeerDependencies: + - '@types/node' + - chokidar + - supports-color + + '@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2)': + dependencies: + '@angular/core': 21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0) + rxjs: 7.8.2 + tslib: 2.8.1 + + '@angular/compiler-cli@21.0.1(@angular/compiler@21.0.1)(typescript@5.9.3)': + dependencies: + '@angular/compiler': 21.0.1 + '@babel/core': 7.28.4 + '@jridgewell/sourcemap-codec': 1.5.0 + chokidar: 4.0.3 + convert-source-map: 1.9.0 + reflect-metadata: 0.2.2 + semver: 7.7.2 + tslib: 2.8.1 + yargs: 18.0.0 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@angular/compiler@21.0.1': + dependencies: + tslib: 2.8.1 + + '@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)': + dependencies: + rxjs: 7.8.2 + tslib: 2.8.1 + optionalDependencies: + '@angular/compiler': 21.0.1 + zone.js: 0.15.0 + + '@angular/forms@21.0.1(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@standard-schema/spec@1.0.0)(rxjs@7.8.2)': + dependencies: + '@angular/common': 21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/platform-browser': 21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)) + '@standard-schema/spec': 1.0.0 + rxjs: 7.8.2 + tslib: 2.8.1 + + '@angular/language-service@21.0.1': {} + + '@angular/platform-browser-dynamic@21.0.1(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@21.0.1)(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))': + dependencies: + '@angular/common': 21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/compiler': 21.0.1 + '@angular/core': 21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/platform-browser': 21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)) + tslib: 2.8.1 + + '@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))': + dependencies: + '@angular/common': 21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0) + tslib: 2.8.1 + optionalDependencies: + '@angular/animations': 21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)) + + '@angular/router@21.0.1(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2)': + dependencies: + '@angular/common': 21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/platform-browser': 21.0.1(@angular/animations@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.0.1(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.0.1(@angular/compiler@21.0.1)(rxjs@7.8.2)(zone.js@0.15.0)) + rxjs: 7.8.2 + tslib: 2.8.1 + + '@asamuzakjp/css-color@4.1.1': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 11.2.4 + + '@asamuzakjp/dom-selector@6.7.6': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.4 + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.0': {} + + '@babel/core@7.28.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.1 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.28.5 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-split-export-declaration@7.24.7': + dependencies: + '@babel/types': 7.28.5 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@colors/colors@1.5.0': + optional: true + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.22': {} + + '@csstools/css-tokenizer@3.0.4': {} + + '@emnapi/core@1.5.0': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.5.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.9': + optional: true + + '@esbuild/aix-ppc64@0.26.0': + optional: true + + '@esbuild/android-arm64@0.25.9': + optional: true + + '@esbuild/android-arm64@0.26.0': + optional: true + + '@esbuild/android-arm@0.25.9': + optional: true + + '@esbuild/android-arm@0.26.0': + optional: true + + '@esbuild/android-x64@0.25.9': + optional: true + + '@esbuild/android-x64@0.26.0': + optional: true + + '@esbuild/darwin-arm64@0.25.9': + optional: true + + '@esbuild/darwin-arm64@0.26.0': + optional: true + + '@esbuild/darwin-x64@0.25.9': + optional: true + + '@esbuild/darwin-x64@0.26.0': + optional: true + + '@esbuild/freebsd-arm64@0.25.9': + optional: true + + '@esbuild/freebsd-arm64@0.26.0': + optional: true + + '@esbuild/freebsd-x64@0.25.9': + optional: true + + '@esbuild/freebsd-x64@0.26.0': + optional: true + + '@esbuild/linux-arm64@0.25.9': + optional: true + + '@esbuild/linux-arm64@0.26.0': + optional: true + + '@esbuild/linux-arm@0.25.9': + optional: true + + '@esbuild/linux-arm@0.26.0': + optional: true + + '@esbuild/linux-ia32@0.25.9': + optional: true + + '@esbuild/linux-ia32@0.26.0': + optional: true + + '@esbuild/linux-loong64@0.25.9': + optional: true + + '@esbuild/linux-loong64@0.26.0': + optional: true + + '@esbuild/linux-mips64el@0.25.9': + optional: true + + '@esbuild/linux-mips64el@0.26.0': + optional: true + + '@esbuild/linux-ppc64@0.25.9': + optional: true + + '@esbuild/linux-ppc64@0.26.0': + optional: true + + '@esbuild/linux-riscv64@0.25.9': + optional: true + + '@esbuild/linux-riscv64@0.26.0': + optional: true + + '@esbuild/linux-s390x@0.25.9': + optional: true + + '@esbuild/linux-s390x@0.26.0': + optional: true + + '@esbuild/linux-x64@0.25.9': + optional: true + + '@esbuild/linux-x64@0.26.0': + optional: true + + '@esbuild/netbsd-arm64@0.25.9': + optional: true + + '@esbuild/netbsd-arm64@0.26.0': + optional: true + + '@esbuild/netbsd-x64@0.25.9': + optional: true + + '@esbuild/netbsd-x64@0.26.0': + optional: true + + '@esbuild/openbsd-arm64@0.25.9': + optional: true + + '@esbuild/openbsd-arm64@0.26.0': + optional: true + + '@esbuild/openbsd-x64@0.25.9': + optional: true + + '@esbuild/openbsd-x64@0.26.0': + optional: true + + '@esbuild/openharmony-arm64@0.25.9': + optional: true + + '@esbuild/openharmony-arm64@0.26.0': + optional: true + + '@esbuild/sunos-x64@0.25.9': + optional: true + + '@esbuild/sunos-x64@0.26.0': + optional: true + + '@esbuild/win32-arm64@0.25.9': + optional: true + + '@esbuild/win32-arm64@0.26.0': + optional: true + + '@esbuild/win32-ia32@0.25.9': + optional: true + + '@esbuild/win32-ia32@0.26.0': + optional: true + + '@esbuild/win32-x64@0.25.9': + optional: true + + '@esbuild/win32-x64@0.26.0': + optional: true + + '@exodus/bytes@1.7.0': {} + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@22.13.10)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.13.10) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/confirm@5.1.19(@types/node@22.13.10)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/type': 3.0.10(@types/node@22.13.10) + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/confirm@5.1.21(@types/node@22.13.10)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/type': 3.0.10(@types/node@22.13.10) + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/core@10.3.2(@types/node@22.13.10)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.13.10) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/editor@4.2.23(@types/node@22.13.10)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/external-editor': 1.0.3(@types/node@22.13.10) + '@inquirer/type': 3.0.10(@types/node@22.13.10) + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/expand@4.0.23(@types/node@22.13.10)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/type': 3.0.10(@types/node@22.13.10) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/external-editor@1.0.3(@types/node@22.13.10)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.0 + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@22.13.10)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/type': 3.0.10(@types/node@22.13.10) + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/number@3.0.23(@types/node@22.13.10)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/type': 3.0.10(@types/node@22.13.10) + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/password@4.0.23(@types/node@22.13.10)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/type': 3.0.10(@types/node@22.13.10) + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/prompts@7.9.0(@types/node@22.13.10)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@22.13.10) + '@inquirer/confirm': 5.1.21(@types/node@22.13.10) + '@inquirer/editor': 4.2.23(@types/node@22.13.10) + '@inquirer/expand': 4.0.23(@types/node@22.13.10) + '@inquirer/input': 4.3.1(@types/node@22.13.10) + '@inquirer/number': 3.0.23(@types/node@22.13.10) + '@inquirer/password': 4.0.23(@types/node@22.13.10) + '@inquirer/rawlist': 4.1.11(@types/node@22.13.10) + '@inquirer/search': 3.2.2(@types/node@22.13.10) + '@inquirer/select': 4.4.2(@types/node@22.13.10) + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/rawlist@4.1.11(@types/node@22.13.10)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/type': 3.0.10(@types/node@22.13.10) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/search@3.2.2(@types/node@22.13.10)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.13.10) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/select@4.4.2(@types/node@22.13.10)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.13.10) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.13.10) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/type@3.0.10(@types/node@22.13.10)': + optionalDependencies: + '@types/node': 22.13.10 + + '@inquirer/type@3.0.8(@types/node@22.13.10)': + optionalDependencies: + '@types/node': 22.13.10 + + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + + '@istanbuljs/schema@0.1.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.30 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.6': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 + optional: true + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.30': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@listr2/prompt-adapter-inquirer@3.0.5(@inquirer/prompts@7.9.0(@types/node@22.13.10))(@types/node@22.13.10)(listr2@9.0.5)': + dependencies: + '@inquirer/prompts': 7.9.0(@types/node@22.13.10) + '@inquirer/type': 3.0.8(@types/node@22.13.10) + listr2: 9.0.5 + transitivePeerDependencies: + - '@types/node' + + '@lmdb/lmdb-darwin-arm64@3.4.3': + optional: true + + '@lmdb/lmdb-darwin-x64@3.4.3': + optional: true + + '@lmdb/lmdb-linux-arm64@3.4.3': + optional: true + + '@lmdb/lmdb-linux-arm@3.4.3': + optional: true + + '@lmdb/lmdb-linux-x64@3.4.3': + optional: true + + '@lmdb/lmdb-win32-arm64@3.4.3': + optional: true + + '@lmdb/lmdb-win32-x64@3.4.3': + optional: true + + '@mdi/js@7.4.47': {} + + '@modelcontextprotocol/sdk@1.20.1': + dependencies: + ajv: 6.12.6 + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.1.0 + express-rate-limit: 7.5.1(express@5.1.0) + pkce-challenge: 5.0.0 + raw-body: 3.0.0 + zod: 3.25.76 + zod-to-json-schema: 3.24.6(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + + '@napi-rs/nice-android-arm-eabi@1.1.1': + optional: true + + '@napi-rs/nice-android-arm64@1.1.1': + optional: true + + '@napi-rs/nice-darwin-arm64@1.1.1': + optional: true + + '@napi-rs/nice-darwin-x64@1.1.1': + optional: true + + '@napi-rs/nice-freebsd-x64@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm64-musl@1.1.1': + optional: true + + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-x64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-x64-musl@1.1.1': + optional: true + + '@napi-rs/nice-openharmony-arm64@1.1.1': + optional: true + + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + optional: true + + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + optional: true + + '@napi-rs/nice-win32-x64-msvc@1.1.1': + optional: true + + '@napi-rs/nice@1.1.1': + optionalDependencies: + '@napi-rs/nice-android-arm-eabi': 1.1.1 + '@napi-rs/nice-android-arm64': 1.1.1 + '@napi-rs/nice-darwin-arm64': 1.1.1 + '@napi-rs/nice-darwin-x64': 1.1.1 + '@napi-rs/nice-freebsd-x64': 1.1.1 + '@napi-rs/nice-linux-arm-gnueabihf': 1.1.1 + '@napi-rs/nice-linux-arm64-gnu': 1.1.1 + '@napi-rs/nice-linux-arm64-musl': 1.1.1 + '@napi-rs/nice-linux-ppc64-gnu': 1.1.1 + '@napi-rs/nice-linux-riscv64-gnu': 1.1.1 + '@napi-rs/nice-linux-s390x-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-musl': 1.1.1 + '@napi-rs/nice-openharmony-arm64': 1.1.1 + '@napi-rs/nice-win32-arm64-msvc': 1.1.1 + '@napi-rs/nice-win32-ia32-msvc': 1.1.1 + '@napi-rs/nice-win32-x64-msvc': 1.1.1 + optional: true + + '@napi-rs/wasm-runtime@1.0.7': + dependencies: + '@emnapi/core': 1.5.0 + '@emnapi/runtime': 1.5.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@npmcli/agent@4.0.0': + dependencies: + agent-base: 7.1.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 11.2.4 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + '@npmcli/fs@5.0.0': + dependencies: + semver: 7.7.3 + + '@npmcli/git@7.0.1': + dependencies: + '@npmcli/promise-spawn': 9.0.1 + ini: 6.0.0 + lru-cache: 11.2.4 + npm-pick-manifest: 11.0.3 + proc-log: 6.1.0 + promise-retry: 2.0.1 + semver: 7.7.3 + which: 6.0.0 + + '@npmcli/installed-package-contents@3.0.0': + dependencies: + npm-bundled: 4.0.0 + npm-normalize-package-bin: 4.0.0 + + '@npmcli/node-gyp@5.0.0': {} + + '@npmcli/package-json@7.0.4': + dependencies: + '@npmcli/git': 7.0.1 + glob: 13.0.0 + hosted-git-info: 9.0.0 + json-parse-even-better-errors: 5.0.0 + proc-log: 6.1.0 + semver: 7.7.3 + validate-npm-package-license: 3.0.4 + + '@npmcli/promise-spawn@8.0.2': + dependencies: + which: 5.0.0 + + '@npmcli/promise-spawn@9.0.1': + dependencies: + which: 6.0.0 + + '@npmcli/redact@4.0.0': {} + + '@npmcli/run-script@10.0.3': + dependencies: + '@npmcli/node-gyp': 5.0.0 + '@npmcli/package-json': 7.0.4 + '@npmcli/promise-spawn': 9.0.1 + node-gyp: 12.1.0 + proc-log: 6.1.0 + which: 6.0.0 + transitivePeerDependencies: + - supports-color + + '@oxc-project/types@0.96.0': {} + + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + optional: true + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@rolldown/binding-android-arm64@1.0.0-beta.47': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-beta.47': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.47': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.47': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.47': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.47': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.47': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.47': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.47': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.47': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.47': + dependencies: + '@napi-rs/wasm-runtime': 1.0.7 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.47': + optional: true + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.47': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.47': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.47': {} + + '@rollup/rollup-android-arm-eabi@4.50.0': + optional: true + + '@rollup/rollup-android-arm64@4.50.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.50.0': + optional: true + + '@rollup/rollup-darwin-x64@4.50.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.50.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.50.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.50.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.50.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.50.0': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.50.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.50.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.50.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.50.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.50.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.50.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.50.0': + optional: true + + '@schematics/angular@21.0.1(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 21.0.1(chokidar@4.0.3) + '@angular-devkit/schematics': 21.0.1(chokidar@4.0.3) + jsonc-parser: 3.3.1 + transitivePeerDependencies: + - chokidar + + '@sigstore/bundle@4.0.0': + dependencies: + '@sigstore/protobuf-specs': 0.5.0 + + '@sigstore/core@3.0.0': {} + + '@sigstore/protobuf-specs@0.5.0': {} + + '@sigstore/sign@4.0.1': + dependencies: + '@sigstore/bundle': 4.0.0 + '@sigstore/core': 3.0.0 + '@sigstore/protobuf-specs': 0.5.0 + make-fetch-happen: 15.0.3 + proc-log: 5.0.0 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@sigstore/tuf@4.0.0': + dependencies: + '@sigstore/protobuf-specs': 0.5.0 + tuf-js: 4.0.0 + transitivePeerDependencies: + - supports-color + + '@sigstore/verify@3.0.0': + dependencies: + '@sigstore/bundle': 4.0.0 + '@sigstore/core': 3.0.0 + '@sigstore/protobuf-specs': 0.5.0 + + '@socket.io/component-emitter@3.1.2': + optional: true + + '@standard-schema/spec@1.0.0': {} + + '@tailwindcss/node@4.1.4': + dependencies: + enhanced-resolve: 5.18.1 + jiti: 2.4.2 + lightningcss: 1.29.2 + tailwindcss: 4.1.4 + + '@tailwindcss/oxide-android-arm64@4.1.4': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.4': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.4': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.4': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.4': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.4': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.4': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.4': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.4': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.4': + optional: true + + '@tailwindcss/oxide@4.1.4': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.4 + '@tailwindcss/oxide-darwin-arm64': 4.1.4 + '@tailwindcss/oxide-darwin-x64': 4.1.4 + '@tailwindcss/oxide-freebsd-x64': 4.1.4 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.4 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.4 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.4 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.4 + '@tailwindcss/oxide-linux-x64-musl': 4.1.4 + '@tailwindcss/oxide-wasm32-wasi': 4.1.4 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.4 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.4 + + '@tailwindcss/postcss@4.1.4': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.1.4 + '@tailwindcss/oxide': 4.1.4 + postcss: 8.5.3 + tailwindcss: 4.1.4 + + '@tufjs/canonical-json@2.0.0': {} + + '@tufjs/models@4.0.0': + dependencies: + '@tufjs/canonical-json': 2.0.0 + minimatch: 9.0.5 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/cors@2.8.17': + dependencies: + '@types/node': 22.13.10 + optional: true + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/jasmine@5.1.7': {} + + '@types/node@22.13.10': + dependencies: + undici-types: 6.20.0 + optional: true + + '@vitejs/plugin-basic-ssl@2.1.0(vite@7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0))': + dependencies: + vite: 7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0) + + '@vitest/expect@4.0.16': + dependencies: + '@standard-schema/spec': 1.0.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.16(vite@7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0))': + dependencies: + '@vitest/spy': 4.0.16 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0) + + '@vitest/pretty-format@4.0.16': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.16': + dependencies: + '@vitest/utils': 4.0.16 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.16': {} + + '@vitest/utils@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + tinyrainbow: 3.0.3 + + '@yarnpkg/lockfile@1.1.0': {} + + abbrev@4.0.0: {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + optional: true + + accepts@2.0.0: + dependencies: + mime-types: 3.0.1 + negotiator: 1.0.0 + + acorn@8.15.0: + optional: true + + agent-base@7.1.3: {} + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.0.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + algoliasearch@5.40.1: + dependencies: + '@algolia/abtesting': 1.6.1 + '@algolia/client-abtesting': 5.40.1 + '@algolia/client-analytics': 5.40.1 + '@algolia/client-common': 5.40.1 + '@algolia/client-insights': 5.40.1 + '@algolia/client-personalization': 5.40.1 + '@algolia/client-query-suggestions': 5.40.1 + '@algolia/client-search': 5.40.1 + '@algolia/ingestion': 1.40.1 + '@algolia/monitoring': 1.40.1 + '@algolia/recommend': 5.40.1 + '@algolia/requester-browser-xhr': 5.40.1 + '@algolia/requester-fetch': 5.40.1 + '@algolia/requester-node-http': 5.40.1 + + ansi-escapes@7.0.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.1: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + optional: true + + assertion-error@2.0.1: {} + + autoprefixer@10.4.21(postcss@8.5.3): + dependencies: + browserslist: 4.24.4 + caniuse-lite: 1.0.30001705 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.5.3 + postcss-value-parser: 4.2.0 + + balanced-match@1.0.2: {} + + base64id@2.0.0: + optional: true + + baseline-browser-mapping@2.8.32: {} + + beasties@0.3.5: + dependencies: + css-select: 6.0.0 + css-what: 7.0.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + htmlparser2: 10.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-media-query-parser: 0.2.3 + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + binary-extensions@2.3.0: + optional: true + + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + optional: true + + body-parser@2.2.0: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.1 + http-errors: 2.0.0 + iconv-lite: 0.6.3 + on-finished: 2.4.1 + qs: 6.14.0 + raw-body: 3.0.0 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + boolbase@1.0.0: {} + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + optional: true + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + optional: true + + browserslist@4.24.4: + dependencies: + caniuse-lite: 1.0.30001705 + electron-to-chromium: 1.5.119 + node-releases: 2.0.19 + update-browserslist-db: 1.1.3(browserslist@4.24.4) + + browserslist@4.28.0: + dependencies: + baseline-browser-mapping: 2.8.32 + caniuse-lite: 1.0.30001757 + electron-to-chromium: 1.5.262 + node-releases: 2.0.27 + update-browserslist-db: 1.1.4(browserslist@4.28.0) + + buffer-from@1.1.2: {} + + bytes@3.1.2: {} + + cacache@20.0.3: + dependencies: + '@npmcli/fs': 5.0.0 + fs-minipass: 3.0.3 + glob: 13.0.0 + lru-cache: 11.1.0 + minipass: 7.1.2 + minipass-collect: 2.0.1 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + p-map: 7.0.3 + ssri: 13.0.0 + unique-filename: 5.0.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001705: {} + + caniuse-lite@1.0.30001757: {} + + chai@6.2.2: {} + + chalk@5.6.2: {} + + chardet@2.1.1: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + optional: true + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chownr@3.0.0: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@3.3.0: {} + + cli-truncate@5.1.1: + dependencies: + slice-ansi: 7.1.0 + string-width: 8.1.0 + + cli-width@4.1.0: {} + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + optional: true + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + commander@2.20.3: + optional: true + + concat-map@0.0.1: + optional: true + + connect@3.7.0: + dependencies: + debug: 2.6.9 + finalhandler: 1.1.2 + parseurl: 1.3.3 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + optional: true + + content-disposition@1.0.0: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@1.9.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + copy-anything@2.0.6: + dependencies: + is-what: 3.14.1 + optional: true + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-select@6.0.0: + dependencies: + boolbase: 1.0.0 + css-what: 7.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + css-what@7.0.0: {} + + cssstyle@5.3.6: + dependencies: + '@asamuzakjp/css-color': 4.1.1 + '@csstools/css-syntax-patches-for-csstree': 1.0.22 + css-tree: 3.1.0 + lru-cache: 11.2.4 + + custom-event@1.0.1: + optional: true + + daisyui@5.0.27: {} + + data-urls@6.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + + date-format@4.0.14: + optional: true + + debug@2.6.9: + dependencies: + ms: 2.0.0 + optional: true + + debug@4.3.7: + dependencies: + ms: 2.1.3 + optional: true + + debug@4.4.0: + dependencies: + ms: 2.1.3 + optional: true + + debug@4.4.1: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: + optional: true + + detect-libc@1.0.3: + optional: true + + detect-libc@2.0.3: {} + + di@0.0.1: + optional: true + + dom-serialize@2.2.1: + dependencies: + custom-event: 1.0.1 + ent: 2.2.2 + extend: 3.0.2 + void-elements: 2.0.1 + optional: true + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.119: {} + + electron-to-chromium@1.5.262: {} + + emoji-regex@10.4.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@1.0.2: + optional: true + + encodeurl@2.0.0: {} + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + optional: true + + engine.io-parser@5.2.3: + optional: true + + engine.io@6.6.4: + dependencies: + '@types/cors': 2.8.17 + '@types/node': 22.13.10 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.5 + debug: 4.3.7 + engine.io-parser: 5.2.3 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + optional: true + + enhanced-resolve@5.18.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + + ent@2.2.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + punycode: 1.4.1 + safe-regex-test: 1.1.0 + optional: true + + entities@4.5.0: {} + + entities@6.0.0: {} + + env-paths@2.2.1: {} + + environment@1.1.0: {} + + err-code@2.0.3: {} + + errno@0.1.8: + dependencies: + prr: 1.0.1 + optional: true + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.25.9: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.9 + '@esbuild/android-arm': 0.25.9 + '@esbuild/android-arm64': 0.25.9 + '@esbuild/android-x64': 0.25.9 + '@esbuild/darwin-arm64': 0.25.9 + '@esbuild/darwin-x64': 0.25.9 + '@esbuild/freebsd-arm64': 0.25.9 + '@esbuild/freebsd-x64': 0.25.9 + '@esbuild/linux-arm': 0.25.9 + '@esbuild/linux-arm64': 0.25.9 + '@esbuild/linux-ia32': 0.25.9 + '@esbuild/linux-loong64': 0.25.9 + '@esbuild/linux-mips64el': 0.25.9 + '@esbuild/linux-ppc64': 0.25.9 + '@esbuild/linux-riscv64': 0.25.9 + '@esbuild/linux-s390x': 0.25.9 + '@esbuild/linux-x64': 0.25.9 + '@esbuild/netbsd-arm64': 0.25.9 + '@esbuild/netbsd-x64': 0.25.9 + '@esbuild/openbsd-arm64': 0.25.9 + '@esbuild/openbsd-x64': 0.25.9 + '@esbuild/openharmony-arm64': 0.25.9 + '@esbuild/sunos-x64': 0.25.9 + '@esbuild/win32-arm64': 0.25.9 + '@esbuild/win32-ia32': 0.25.9 + '@esbuild/win32-x64': 0.25.9 + + esbuild@0.26.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.26.0 + '@esbuild/android-arm': 0.26.0 + '@esbuild/android-arm64': 0.26.0 + '@esbuild/android-x64': 0.26.0 + '@esbuild/darwin-arm64': 0.26.0 + '@esbuild/darwin-x64': 0.26.0 + '@esbuild/freebsd-arm64': 0.26.0 + '@esbuild/freebsd-x64': 0.26.0 + '@esbuild/linux-arm': 0.26.0 + '@esbuild/linux-arm64': 0.26.0 + '@esbuild/linux-ia32': 0.26.0 + '@esbuild/linux-loong64': 0.26.0 + '@esbuild/linux-mips64el': 0.26.0 + '@esbuild/linux-ppc64': 0.26.0 + '@esbuild/linux-riscv64': 0.26.0 + '@esbuild/linux-s390x': 0.26.0 + '@esbuild/linux-x64': 0.26.0 + '@esbuild/netbsd-arm64': 0.26.0 + '@esbuild/netbsd-x64': 0.26.0 + '@esbuild/openbsd-arm64': 0.26.0 + '@esbuild/openbsd-x64': 0.26.0 + '@esbuild/openharmony-arm64': 0.26.0 + '@esbuild/sunos-x64': 0.26.0 + '@esbuild/win32-arm64': 0.26.0 + '@esbuild/win32-ia32': 0.26.0 + '@esbuild/win32-x64': 0.26.0 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + etag@1.8.1: {} + + eventemitter3@4.0.7: + optional: true + + eventemitter3@5.0.1: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + expect-type@1.3.0: {} + + exponential-backoff@3.1.2: {} + + express-rate-limit@7.5.1(express@5.1.0): + dependencies: + express: 5.1.0 + + express@5.1.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.0 + content-disposition: 1.0.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.1 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.0 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + mime-types: 3.0.1 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.0 + statuses: 2.0.1 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend@3.0.2: + optional: true + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-uri@3.0.6: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + optional: true + + finalhandler@1.1.2: + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + optional: true + + finalhandler@2.1.0: + dependencies: + debug: 4.4.1 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + flatted@3.3.3: + optional: true + + follow-redirects@1.15.9: + optional: true + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + forwarded@0.2.0: {} + + fraction.js@4.3.7: {} + + fresh@2.0.0: {} + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + optional: true + + fs-minipass@3.0.3: + dependencies: + minipass: 7.1.2 + + fs.realpath@1.0.0: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.3.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + optional: true + + glob-to-regexp@0.4.1: {} + + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@13.0.0: + dependencies: + minimatch: 10.1.1 + minipass: 7.1.2 + path-scurry: 2.0.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + optional: true + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + optional: true + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hosted-git-info@9.0.0: + dependencies: + lru-cache: 11.1.0 + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.7.0 + transitivePeerDependencies: + - '@exodus/crypto' + + htmlparser2@10.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 6.0.0 + + http-cache-semantics@4.1.1: {} + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.3 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + http-proxy@1.18.1: + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.9 + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + optional: true + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + optional: true + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.0: + dependencies: + safer-buffer: 2.1.2 + + ignore-walk@8.0.0: + dependencies: + minimatch: 10.1.1 + + image-size@0.5.5: + optional: true + + immutable@5.0.3: {} + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + optional: true + + inherits@2.0.4: {} + + ini@5.0.0: {} + + ini@6.0.0: {} + + ip-address@9.0.5: + dependencies: + jsbn: 1.1.0 + sprintf-js: 1.1.3 + + ipaddr.js@1.9.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + optional: true + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: + optional: true + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@5.0.0: + dependencies: + get-east-asian-width: 1.3.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + optional: true + + is-interactive@2.0.0: {} + + is-number@7.0.0: + optional: true + + is-potential-custom-element-name@1.0.1: {} + + is-promise@4.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + optional: true + + is-unicode-supported@2.1.0: {} + + is-what@3.14.1: + optional: true + + isbinaryfile@4.0.10: + optional: true + + isexe@2.0.0: {} + + isexe@3.1.1: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.28.4 + '@babel/parser': 7.28.5 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jasmine-core@5.1.2: {} + + jiti@2.4.2: {} + + jiti@2.6.1: + optional: true + + js-tokens@4.0.0: {} + + jsbn@1.1.0: {} + + jsdom@27.4.0: + dependencies: + '@acemir/cssom': 0.9.30 + '@asamuzakjp/dom-selector': 6.7.6 + '@exodus/bytes': 1.7.0 + cssstyle: 5.3.6 + data-urls: 6.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.0 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.18.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@exodus/crypto' + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-parse-even-better-errors@5.0.0: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + jsonc-parser@3.3.1: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + optional: true + + jsonparse@1.3.1: {} + + karma@6.4.4: + dependencies: + '@colors/colors': 1.5.0 + body-parser: 1.20.3 + braces: 3.0.3 + chokidar: 3.6.0 + connect: 3.7.0 + di: 0.0.1 + dom-serialize: 2.2.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + http-proxy: 1.18.1 + isbinaryfile: 4.0.10 + lodash: 4.17.21 + log4js: 6.9.1 + mime: 2.6.0 + minimatch: 3.1.2 + mkdirp: 0.5.6 + qjobs: 1.2.0 + range-parser: 1.2.1 + rimraf: 3.0.2 + socket.io: 4.8.1 + source-map: 0.6.1 + tmp: 0.2.3 + ua-parser-js: 0.7.40 + yargs: 16.2.0 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + optional: true + + less@4.4.2: + dependencies: + copy-anything: 2.0.6 + parse-node-version: 1.0.1 + tslib: 2.8.1 + optionalDependencies: + errno: 0.1.8 + graceful-fs: 4.2.11 + image-size: 0.5.5 + make-dir: 2.1.0 + mime: 1.6.0 + needle: 3.3.1 + source-map: 0.6.1 + optional: true + + lightningcss-darwin-arm64@1.29.2: + optional: true + + lightningcss-darwin-x64@1.29.2: + optional: true + + lightningcss-freebsd-x64@1.29.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.29.2: + optional: true + + lightningcss-linux-arm64-gnu@1.29.2: + optional: true + + lightningcss-linux-arm64-musl@1.29.2: + optional: true + + lightningcss-linux-x64-gnu@1.29.2: + optional: true + + lightningcss-linux-x64-musl@1.29.2: + optional: true + + lightningcss-win32-arm64-msvc@1.29.2: + optional: true + + lightningcss-win32-x64-msvc@1.29.2: + optional: true + + lightningcss@1.29.2: + dependencies: + detect-libc: 2.0.3 + optionalDependencies: + lightningcss-darwin-arm64: 1.29.2 + lightningcss-darwin-x64: 1.29.2 + lightningcss-freebsd-x64: 1.29.2 + lightningcss-linux-arm-gnueabihf: 1.29.2 + lightningcss-linux-arm64-gnu: 1.29.2 + lightningcss-linux-arm64-musl: 1.29.2 + lightningcss-linux-x64-gnu: 1.29.2 + lightningcss-linux-x64-musl: 1.29.2 + lightningcss-win32-arm64-msvc: 1.29.2 + lightningcss-win32-x64-msvc: 1.29.2 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.1.1 + colorette: 2.0.20 + eventemitter3: 5.0.1 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.0 + + lmdb@3.4.3: + dependencies: + msgpackr: 1.11.2 + node-addon-api: 6.1.0 + node-gyp-build-optional-packages: 5.2.2 + ordered-binary: 1.5.3 + weak-lru-cache: 1.2.2 + optionalDependencies: + '@lmdb/lmdb-darwin-arm64': 3.4.3 + '@lmdb/lmdb-darwin-x64': 3.4.3 + '@lmdb/lmdb-linux-arm': 3.4.3 + '@lmdb/lmdb-linux-arm64': 3.4.3 + '@lmdb/lmdb-linux-x64': 3.4.3 + '@lmdb/lmdb-win32-arm64': 3.4.3 + '@lmdb/lmdb-win32-x64': 3.4.3 + optional: true + + lodash@4.17.21: + optional: true + + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.0.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.0 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.0 + + log4js@6.9.1: + dependencies: + date-format: 4.0.14 + debug: 4.4.0 + flatted: 3.3.3 + rfdc: 1.4.1 + streamroller: 3.1.5 + transitivePeerDependencies: + - supports-color + optional: true + + lru-cache@10.4.3: {} + + lru-cache@11.1.0: {} + + lru-cache@11.2.4: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.19: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-dir@2.1.0: + dependencies: + pify: 4.0.1 + semver: 5.7.2 + optional: true + + make-fetch-happen@15.0.3: + dependencies: + '@npmcli/agent': 4.0.0 + cacache: 20.0.3 + http-cache-semantics: 4.1.1 + minipass: 7.1.2 + minipass-fetch: 5.0.0 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + negotiator: 1.0.0 + proc-log: 6.1.0 + promise-retry: 2.0.1 + ssri: 13.0.0 + transitivePeerDependencies: + - supports-color + + math-intrinsics@1.1.0: {} + + mdn-data@2.12.2: {} + + media-typer@0.3.0: + optional: true + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + optional: true + + mime-db@1.52.0: + optional: true + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + optional: true + + mime-types@3.0.1: + dependencies: + mime-db: 1.54.0 + + mime@1.6.0: + optional: true + + mime@2.6.0: + optional: true + + mimic-function@5.0.1: {} + + minimatch@10.1.1: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + optional: true + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + minimist@1.2.8: + optional: true + + minipass-collect@2.0.1: + dependencies: + minipass: 7.1.2 + + minipass-fetch@5.0.0: + dependencies: + minipass: 7.1.2 + minipass-sized: 1.0.3 + minizlib: 3.0.1 + optionalDependencies: + encoding: 0.1.13 + + minipass-flush@1.0.5: + dependencies: + minipass: 3.3.6 + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + + minipass-sized@1.0.3: + dependencies: + minipass: 3.3.6 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@7.1.2: {} + + minizlib@3.0.1: + dependencies: + minipass: 7.1.2 + rimraf: 5.0.10 + + minizlib@3.1.0: + dependencies: + minipass: 7.1.2 + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + optional: true + + mkdirp@3.0.1: {} + + mrmime@2.0.1: {} + + ms@2.0.0: + optional: true + + ms@2.1.3: {} + + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.2: + optionalDependencies: + msgpackr-extract: 3.0.3 + optional: true + + mute-stream@2.0.0: {} + + nanoid@3.3.10: {} + + nanoid@3.3.11: {} + + needle@3.3.1: + dependencies: + iconv-lite: 0.6.3 + sax: 1.4.1 + optional: true + + negotiator@0.6.3: + optional: true + + negotiator@1.0.0: {} + + node-addon-api@6.1.0: + optional: true + + node-addon-api@7.1.1: + optional: true + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.0.3 + optional: true + + node-gyp@12.1.0: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.2 + graceful-fs: 4.2.11 + make-fetch-happen: 15.0.3 + nopt: 9.0.0 + proc-log: 6.1.0 + semver: 7.7.3 + tar: 7.5.2 + tinyglobby: 0.2.14 + which: 6.0.0 + transitivePeerDependencies: + - supports-color + + node-releases@2.0.19: {} + + node-releases@2.0.27: {} + + nopt@9.0.0: + dependencies: + abbrev: 4.0.0 + + normalize-path@3.0.0: + optional: true + + normalize-range@0.1.2: {} + + npm-bundled@4.0.0: + dependencies: + npm-normalize-package-bin: 4.0.0 + + npm-install-checks@8.0.0: + dependencies: + semver: 7.7.3 + + npm-normalize-package-bin@4.0.0: {} + + npm-normalize-package-bin@5.0.0: {} + + npm-package-arg@13.0.1: + dependencies: + hosted-git-info: 9.0.0 + proc-log: 5.0.0 + semver: 7.7.3 + validate-npm-package-name: 6.0.0 + + npm-packlist@10.0.3: + dependencies: + ignore-walk: 8.0.0 + proc-log: 6.1.0 + + npm-pick-manifest@11.0.3: + dependencies: + npm-install-checks: 8.0.0 + npm-normalize-package-bin: 5.0.0 + npm-package-arg: 13.0.1 + semver: 7.7.3 + + npm-registry-fetch@19.1.1: + dependencies: + '@npmcli/redact': 4.0.0 + jsonparse: 1.3.1 + make-fetch-happen: 15.0.3 + minipass: 7.1.2 + minipass-fetch: 5.0.0 + minizlib: 3.0.1 + npm-package-arg: 13.0.1 + proc-log: 6.1.0 + transitivePeerDependencies: + - supports-color + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + obug@2.1.1: {} + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + optional: true + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + ora@9.0.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.3.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.2.2 + string-width: 8.1.0 + strip-ansi: 7.1.2 + + ordered-binary@1.5.3: + optional: true + + p-map@7.0.3: {} + + package-json-from-dist@1.0.1: {} + + pacote@21.0.3: + dependencies: + '@npmcli/git': 7.0.1 + '@npmcli/installed-package-contents': 3.0.0 + '@npmcli/package-json': 7.0.4 + '@npmcli/promise-spawn': 8.0.2 + '@npmcli/run-script': 10.0.3 + cacache: 20.0.3 + fs-minipass: 3.0.3 + minipass: 7.1.2 + npm-package-arg: 13.0.1 + npm-packlist: 10.0.3 + npm-pick-manifest: 11.0.3 + npm-registry-fetch: 19.1.1 + proc-log: 5.0.0 + promise-retry: 2.0.1 + sigstore: 4.0.0 + ssri: 12.0.0 + tar: 7.4.3 + transitivePeerDependencies: + - supports-color + + parse-node-version@1.0.1: + optional: true + + parse5-html-rewriting-stream@8.0.0: + dependencies: + entities: 6.0.0 + parse5: 8.0.0 + parse5-sax-parser: 8.0.0 + + parse5-sax-parser@8.0.0: + dependencies: + parse5: 8.0.0 + + parse5@8.0.0: + dependencies: + entities: 6.0.0 + + parseurl@1.3.3: {} + + path-is-absolute@1.0.1: + optional: true + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-scurry@2.0.1: + dependencies: + lru-cache: 11.1.0 + minipass: 7.1.2 + + path-to-regexp@8.2.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: + optional: true + + picomatch@4.0.3: {} + + pify@4.0.1: + optional: true + + piscina@5.1.3: + optionalDependencies: + '@napi-rs/nice': 1.1.1 + + pkce-challenge@5.0.0: {} + + postcss-media-query-parser@0.2.3: {} + + postcss-value-parser@4.2.0: {} + + postcss@8.5.3: + dependencies: + nanoid: 3.3.10 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proc-log@5.0.0: {} + + proc-log@6.1.0: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + prr@1.0.1: + optional: true + + punycode@1.4.1: + optional: true + + punycode@2.3.1: {} + + qjobs@1.2.0: + optional: true + + qs@6.13.0: + dependencies: + side-channel: 1.1.0 + optional: true + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + optional: true + + raw-body@3.0.0: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.6.3 + unpipe: 1.0.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + optional: true + + readdirp@4.1.2: {} + + reflect-metadata@0.2.2: {} + + require-directory@2.1.1: + optional: true + + require-from-string@2.0.2: {} + + requires-port@1.0.0: + optional: true + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + retry@0.12.0: {} + + rfdc@1.4.1: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + optional: true + + rimraf@5.0.10: + dependencies: + glob: 10.4.5 + + rolldown@1.0.0-beta.47: + dependencies: + '@oxc-project/types': 0.96.0 + '@rolldown/pluginutils': 1.0.0-beta.47 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.47 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.47 + '@rolldown/binding-darwin-x64': 1.0.0-beta.47 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.47 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.47 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.47 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.47 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.47 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.47 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.47 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.47 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.47 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.47 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.47 + + rollup@4.50.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.50.0 + '@rollup/rollup-android-arm64': 4.50.0 + '@rollup/rollup-darwin-arm64': 4.50.0 + '@rollup/rollup-darwin-x64': 4.50.0 + '@rollup/rollup-freebsd-arm64': 4.50.0 + '@rollup/rollup-freebsd-x64': 4.50.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.50.0 + '@rollup/rollup-linux-arm-musleabihf': 4.50.0 + '@rollup/rollup-linux-arm64-gnu': 4.50.0 + '@rollup/rollup-linux-arm64-musl': 4.50.0 + '@rollup/rollup-linux-loongarch64-gnu': 4.50.0 + '@rollup/rollup-linux-ppc64-gnu': 4.50.0 + '@rollup/rollup-linux-riscv64-gnu': 4.50.0 + '@rollup/rollup-linux-riscv64-musl': 4.50.0 + '@rollup/rollup-linux-s390x-gnu': 4.50.0 + '@rollup/rollup-linux-x64-gnu': 4.50.0 + '@rollup/rollup-linux-x64-musl': 4.50.0 + '@rollup/rollup-openharmony-arm64': 4.50.0 + '@rollup/rollup-win32-arm64-msvc': 4.50.0 + '@rollup/rollup-win32-ia32-msvc': 4.50.0 + '@rollup/rollup-win32-x64-msvc': 4.50.0 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.1 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.2.0 + transitivePeerDependencies: + - supports-color + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + optional: true + + safer-buffer@2.1.2: {} + + sass@1.93.2: + dependencies: + chokidar: 4.0.3 + immutable: 5.0.3 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.1 + + sax@1.4.1: + optional: true + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + semver@5.7.2: + optional: true + + semver@6.3.1: {} + + semver@7.7.2: {} + + semver@7.7.3: {} + + send@1.2.0: + dependencies: + debug: 4.4.1 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.0 + mime-types: 3.0.1 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.0: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.0 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + sigstore@4.0.0: + dependencies: + '@sigstore/bundle': 4.0.0 + '@sigstore/core': 3.0.0 + '@sigstore/protobuf-specs': 0.5.0 + '@sigstore/sign': 4.0.1 + '@sigstore/tuf': 4.0.0 + '@sigstore/verify': 3.0.0 + transitivePeerDependencies: + - supports-color + + slice-ansi@7.1.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 5.0.0 + + smart-buffer@4.2.0: {} + + socket.io-adapter@2.5.5: + dependencies: + debug: 4.3.7 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + optional: true + + socket.io-parser@4.2.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + optional: true + + socket.io@4.8.1: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.3.7 + engine.io: 6.6.4 + socket.io-adapter: 2.5.5 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + optional: true + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.3 + debug: 4.4.1 + socks: 2.8.4 + transitivePeerDependencies: + - supports-color + + socks@2.8.4: + dependencies: + ip-address: 9.0.5 + smart-buffer: 4.2.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.6: {} + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.21 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.21 + + spdx-license-ids@3.0.21: {} + + sprintf-js@1.1.3: {} + + ssri@12.0.0: + dependencies: + minipass: 7.1.2 + + ssri@13.0.0: + dependencies: + minipass: 7.1.2 + + stackback@0.0.2: {} + + statuses@1.5.0: + optional: true + + statuses@2.0.1: {} + + std-env@3.10.0: {} + + stdin-discarder@0.2.2: {} + + streamroller@3.1.5: + dependencies: + date-format: 4.0.14 + debug: 4.4.0 + fs-extra: 8.1.0 + transitivePeerDependencies: + - supports-color + optional: true + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.4.0 + get-east-asian-width: 1.3.0 + strip-ansi: 7.1.0 + + string-width@8.1.0: + dependencies: + get-east-asian-width: 1.3.0 + strip-ansi: 7.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.1.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + tailwindcss@4.1.4: {} + + tapable@2.2.1: {} + + tar@7.4.3: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.0.1 + mkdirp: 3.0.1 + yallist: 5.0.0 + + tar@7.5.2: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.1.0 + yallist: 5.0.0 + + terser@5.44.0: + dependencies: + '@jridgewell/source-map': 0.3.6 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + optional: true + + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.14: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + + tldts-core@7.0.19: {} + + tldts@7.0.19: + dependencies: + tldts-core: 7.0.19 + + tmp@0.2.3: + optional: true + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + optional: true + + toidentifier@1.0.1: {} + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.19 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tslib@2.8.1: {} + + tuf-js@4.0.0: + dependencies: + '@tufjs/models': 4.0.0 + debug: 4.4.1 + make-fetch-happen: 15.0.3 + transitivePeerDependencies: + - supports-color + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + optional: true + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.1 + + typescript@5.9.3: {} + + ua-parser-js@0.7.40: + optional: true + + undici-types@6.20.0: + optional: true + + undici@7.16.0: {} + + unique-filename@5.0.0: + dependencies: + unique-slug: 6.0.0 + + unique-slug@6.0.0: + dependencies: + imurmurhash: 0.1.4 + + universalify@0.1.2: + optional: true + + unpipe@1.0.0: {} + + update-browserslist-db@1.1.3(browserslist@4.24.4): + dependencies: + browserslist: 4.24.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + update-browserslist-db@1.1.4(browserslist@4.28.0): + dependencies: + browserslist: 4.28.0 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + utils-merge@1.0.1: + optional: true + + uuid@11.1.0: {} + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + + validate-npm-package-name@6.0.0: {} + + vary@1.1.2: {} + + vite@7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0): + dependencies: + esbuild: 0.25.9 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.50.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.13.10 + fsevents: 2.3.3 + jiti: 2.6.1 + less: 4.4.2 + lightningcss: 1.29.2 + sass: 1.93.2 + terser: 5.44.0 + yaml: 2.7.0 + + vitest@4.0.16(@types/node@22.13.10)(jiti@2.6.1)(jsdom@27.4.0)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0): + dependencies: + '@vitest/expect': 4.0.16 + '@vitest/mocker': 4.0.16(vite@7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0)) + '@vitest/pretty-format': 4.0.16 + '@vitest/runner': 4.0.16 + '@vitest/snapshot': 4.0.16 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.2.2(@types/node@22.13.10)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.29.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.13.10 + jsdom: 27.4.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + void-elements@2.0.1: + optional: true + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + watchpack@2.4.4: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + weak-lru-cache@1.2.2: + optional: true + + webidl-conversions@8.0.0: {} + + whatwg-mimetype@4.0.0: {} + + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@5.0.0: + dependencies: + isexe: 3.1.1 + + which@6.0.0: + dependencies: + isexe: 3.1.1 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.2.0 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + ws@8.17.1: + optional: true + + ws@8.18.3: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yallist@5.0.0: {} + + yaml@2.7.0: + optional: true + + yargs-parser@20.2.9: + optional: true + + yargs-parser@22.0.0: {} + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + optional: true + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yoctocolors-cjs@2.1.3: {} + + yoctocolors@2.1.2: {} + + zod-to-json-schema@3.24.6(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} + + zone.js@0.15.0: + optional: true diff --git a/osse-web/src/app/albums/albums.component.css b/osse-web/src/app/albums/albums.component.css new file mode 100644 index 0000000..8ca5372 --- /dev/null +++ b/osse-web/src/app/albums/albums.component.css @@ -0,0 +1,6 @@ +/* On mobile, always show buttons */ +@media (hover: none) and (pointer: coarse) { + .touch-show { + opacity: 1 !important; + } +} diff --git a/osse-web/src/app/albums/albums.component.html b/osse-web/src/app/albums/albums.component.html new file mode 100644 index 0000000..5f75888 --- /dev/null +++ b/osse-web/src/app/albums/albums.component.html @@ -0,0 +1,55 @@ + +
+

{{loading() ? 'Loading' : albums().length}} Albums

+
+
+ + +
+
+
+@if (!loading()) { +
+ @for (album of filteredAlbums(); track $index) { + + } @empty { +
+

No Albums Exist...

+
+ } +
+} @else { +
+

Loading Albums

+
+} diff --git a/osse-web/src/app/albums/albums.component.ts b/osse-web/src/app/albums/albums.component.ts new file mode 100644 index 0000000..c8ad547 --- /dev/null +++ b/osse-web/src/app/albums/albums.component.ts @@ -0,0 +1,68 @@ +import { ChangeDetectionStrategy, Component, OnInit, WritableSignal, signal } from '@angular/core'; +import { ConfigService } from '../shared/services/config/config.service'; +import { RouterLink } from '@angular/router'; +import { Album } from '../shared/services/album/Album'; +import { HeaderComponent } from '../shared/ui/header/header.component'; +import { OsseAlbum } from '../shared/services/album/osse-album'; +import { fetcher } from '../shared/util/fetcher'; +import { IconComponent } from '../shared/ui/icon/icon.component'; +import { mdiPlaylistPlay, mdiSearchWeb } from '@mdi/js'; +import { TrackService } from '../shared/services/track/track.service'; +import { ToastService } from '../toast-container/toast.service'; + +@Component({ + selector: 'app-albums', + imports: [RouterLink, HeaderComponent, IconComponent], + templateUrl: './albums.component.html', + styleUrl: `./albums.component.css`, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AlbumsComponent implements OnInit { + albums: WritableSignal = signal([]); + filteredAlbums: WritableSignal = signal([]); + coverUrlBase: WritableSignal = signal(this.configService.get('apiURL') + "tracks/ID/cover"); + loading: WritableSignal = signal(true); + + search = mdiSearchWeb; + play = mdiPlaylistPlay; + + constructor( + private configService: ConfigService, + private trackService: TrackService, + private notificationService: ToastService + ) { } + + public filterAlbums(event: any) { + if (event.target.value.trim().length == 0) { + this.filteredAlbums.set(this.albums()); + } else { + const regex = new RegExp(event.target.value.trim(), 'i'); + this.filteredAlbums.set(this.albums().filter((a) => regex.test(a.name))); + } + } + + public playAlbum(id: number) { + let album = this.filteredAlbums().find((a) => a.id == id); + if (album) { + for (const track of album.tracks) { + this.trackService.addTrack(track); + } + + if (album.tracks.length > 1) { + this.notificationService.info(`Added ${album.tracks.length} tracks to queue.`); + } else { + this.notificationService.info(`Added track to queue.`); + } + } + } + + async ngOnInit(): Promise { + let request = await fetcher('albums?tracks=true'); + let result: OsseAlbum[] = await request.json(); + + result.sort((a, b) => a.name.localeCompare(b.name)); + this.albums.set(result.map((a) => new Album(a))); + this.filteredAlbums.set(this.albums()); + this.loading.set(false); + } +} diff --git a/osse-web/src/app/albums/view/album-filter.ts b/osse-web/src/app/albums/view/album-filter.ts new file mode 100644 index 0000000..f907002 --- /dev/null +++ b/osse-web/src/app/albums/view/album-filter.ts @@ -0,0 +1,7 @@ +export enum AlbumFilter { + Alphabetical, + TrackNumber, + DiscNumber, + Random, +} + diff --git a/osse-web/src/app/albums/view/album-view.resolver.ts b/osse-web/src/app/albums/view/album-view.resolver.ts new file mode 100644 index 0000000..c889dfc --- /dev/null +++ b/osse-web/src/app/albums/view/album-view.resolver.ts @@ -0,0 +1,21 @@ +import { ResolveFn } from '@angular/router'; +import { Album } from '../../shared/services/album/Album'; +import { fetcher } from '../../shared/util/fetcher'; +import { LoadingService } from '../../shared/ui/loading/loading.service'; +import { inject } from '@angular/core'; + +export const albumViewResolver: ResolveFn = async (route, _state) => { + let loadingService: LoadingService = inject(LoadingService); + loadingService.startLoading(); + let id = route.paramMap.get('id'); + + let request = await fetcher(`albums/${id}?tracks=true`); + if (request.ok) { + let album = await request.json(); + loadingService.endLoading(); + return new Album(album.data); + } + + loadingService.endLoading(); + throw "Not Found" +}; diff --git a/osse-web/src/app/albums/view/view.component.html b/osse-web/src/app/albums/view/view.component.html new file mode 100644 index 0000000..cb4c2c4 --- /dev/null +++ b/osse-web/src/app/albums/view/view.component.html @@ -0,0 +1,71 @@ +
+
+
+ +
+
+ +
+
+

+ {{album().artist[0]?.name ?? albumTrackArtist() ?? "Unknown Artist"}} +

+

{{totalDuration}} Minutes

+ @if (album().album.year) { +

{{album().album.year}}

+ } +
+
+ +
+
+
+
+

{{album().tracks.length}} Tracks

+
+
+ + +
+
+ + +
+
+
+ +
+ + + +
+
+ +
+
+
diff --git a/osse-web/src/app/albums/view/view.component.ts b/osse-web/src/app/albums/view/view.component.ts new file mode 100644 index 0000000..8d456c2 --- /dev/null +++ b/osse-web/src/app/albums/view/view.component.ts @@ -0,0 +1,201 @@ +import { AfterViewInit, ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild, WritableSignal, signal } from '@angular/core'; +import { Album } from '../../shared/services/album/Album'; +import { ConfigService } from '../../shared/services/config/config.service'; +import { TrackService } from '../../shared/services/track/track.service'; +import { Track } from '../../shared/services/track/track'; +import { HeaderComponent } from '../../shared/ui/header/header.component'; +import { ToastService } from '../../toast-container/toast.service'; +import { BackgroundImageService } from '../../shared/ui/background-image.service'; +import { IconComponent } from '../../shared/ui/icon/icon.component'; +import { mdiClose, mdiFilter, mdiPencil, mdiPlaylistPlay, mdiSearchWeb } from '@mdi/js'; +import { AlbumFilter } from './album-filter'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { ModalService } from '../../shared/ui/modal/modal.service'; +import { AlbumArtFullscreenComponent } from '../../shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component'; +import { TrackMatrixComponent } from '../../shared/ui/track-matrix/track-matrix.component'; +import { CommonModule } from '@angular/common'; +import { TrackMatrixMode } from '../../shared/ui/track-matrix/track-matrix-mode.enum'; +import { AddMultipleTracksToPlaylistComponent } from '../../shared/ui/modals/add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component'; +import { Subscription } from 'rxjs'; +import { TrackInfo } from '../../shared/ui/track-matrix/track-info'; + +@Component({ + selector: 'app-view', + imports: [HeaderComponent, IconComponent, FormsModule, TrackMatrixComponent, CommonModule], + templateUrl: './view.component.html', + styles: ``, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ViewComponent implements OnInit, AfterViewInit, OnDestroy { + @ViewChild(TrackMatrixComponent) matrix!: TrackMatrixComponent; + public album!: WritableSignal; + public filteredTracks: WritableSignal = signal([]); + public filterType = AlbumFilter; + public chosenFilter: WritableSignal = signal(AlbumFilter.TrackNumber); + public albumTrackArtist: WritableSignal = signal(''); + public bg = signal(''); + public duration = 0; + public editing: WritableSignal = signal(false); + + search = mdiSearchWeb; + filter = mdiFilter; + pencil = mdiPencil; + close = mdiClose; + play = mdiPlaylistPlay; + + private modalSubscription!: Subscription; + + constructor( + private configService: ConfigService, + private trackService: TrackService, + private notificationService: ToastService, + private backgroundImageService: BackgroundImageService, + private activatedRoute: ActivatedRoute, + private modalService: ModalService + ) { } + + public addAll() { + this.album().tracks.forEach((t) => this.trackService.addTrack(t)); + this.notificationService.info('Added ' + this.album().tracks.length + ' tracks'); + } + + public addTrack(track: Track) { + this.trackService.addTrack(track); + this.notificationService.info('Added ' + track.title); + } + + public get totalDuration() { + let total = 0; + + this.album().tracks.forEach(t => { + total += t.duration; + }); + + return Math.floor(total / 60); + } + + public filterTracks(event: any) { + if (event.target.value.trim().length == 0) { + this.filteredTracks.set(this.album().tracks); + } else { + const regex = new RegExp(event.target.value, 'i'); + this.filteredTracks.set(this.album().tracks.filter((t) => regex.test(t.title))); + } + } + + public sortTracks() { + if (this.chosenFilter() == AlbumFilter.Alphabetical) { + this.filteredTracks().sort((a, b) => { + if (a.title.toLowerCase() < b.title.toLowerCase()) { + return -1; + } + if (a.title.toLowerCase() > b.title.toLowerCase()) { + return 1; + } + return 0; + }); + } else if (this.chosenFilter() == AlbumFilter.Random) { + this.filteredTracks.update(value => { + return value.map(value => ({ value, sort: Math.random() })) + .sort((a, b) => a.sort - b.sort) + .map(({ value }) => value) + }); + } else if (this.chosenFilter() == AlbumFilter.DiscNumber) { + this.filteredTracks.update(v => v.sort((a, b) => (a.discNumber ?? 0) - (b.discNumber ?? 0))); + // To-do: When we store the total discs, sort by disc order and show title + } else { + // Sort by track number + this.filteredTracks.update(v => v.sort((a, b) => (a.trackNumber ?? 0) - (b.trackNumber ?? 0))); + } + } + + public async artistFromTracks() { + let artists = new Map(); + for (let i = 0; i < this.album().tracks.length; i++) { + let track = this.album().tracks[i]; + if (track.artistPrimary() != null) { + artists.set(i, (artists.get(track.artistPrimary()?.id) ?? 0) + 1); + } + } + + if (artists.size == 0) return; + + // Get the artist with the highest track occurence and set it to the album artist + let artist = ([...artists.entries()]).reduce((a, e) => e[1] > a[1] ? e : a); + await this.album().tracks[artist[0]].getArtist(); + this.albumTrackArtist.set((this.album().tracks[artist[0]].artistPrimary())?.name + ' (Inferred)'); + } + + public showAlbumArt() { + let url = this.album().tracks[0]?.coverURL; + + this.modalService.setDynamicModal(AlbumArtFullscreenComponent, [{ + name: 'url', + val: url + }], 'Album Art'); + this.modalService.show(); + } + + public handleModeChange(mode: TrackMatrixMode) { + if (mode == TrackMatrixMode.Select) { + this.editing.set(true); + } else { + this.editing.set(false); + } + } + + public handleEmptySelection() { + this.matrix.setMode(TrackMatrixMode.View); + } + + /** + * Removes all selected tracks. + */ + public clearSelectedTracks() { + this.matrix.clearSelectedTracks(); + } + + public playSelectedTracks() { + let tracks = this.matrix.getSelectedTracks(); + for (const track of tracks) { + this.trackService.addTrack(track); + } + + this.notificationService.info('Added ' + tracks.length + ' tracks.'); + } + + public addSelectedTracksToPlaylist() { + this.modalService.setDynamicModal(AddMultipleTracksToPlaylistComponent, [{ + name: 'tracks', + val: this.matrix.getSelectedTracks() + }], 'Add to Playlist'); + this.modalService.show(); + } + + ngOnInit(): void { + this.album = signal(this.activatedRoute.snapshot.data['album']); + this.filteredTracks.set(this.album().tracks); + this.bg.set(this.configService.get('apiURL') + "tracks/" + (this.album().tracks[0]?.id ?? -1) + '/cover') + + this.backgroundImageService.setBG(this.bg()); + + this.sortTracks(); + this.artistFromTracks(); + + this.duration = this.album().tracks.reduce((acc, t) => t.duration + acc, 0) % 60; + + this.modalSubscription = this.modalService.onClose.subscribe((_) => { + this.clearSelectedTracks(); + }); + } + + ngAfterViewInit(): void { + this.matrix.setVisibleFields(TrackInfo.allFields()); + } + + public ngOnDestroy(): void { + this.modalSubscription.unsubscribe(); + } +} + diff --git a/osse-web/src/app/app.component.css b/osse-web/src/app/app.component.css new file mode 100644 index 0000000..ba5eff5 --- /dev/null +++ b/osse-web/src/app/app.component.css @@ -0,0 +1,19 @@ +#outlet { + --bg: url('#'); +} + +#outlet::before { + content: ""; + position: fixed; + margin-bottom: 20rem; + top: 0; + left: 0; + width: 100%; + height: 100%; + filter: blur(8px) opacity(0.1); + background-repeat: no-repeat; + background-size: cover; + background-attachment: fixed; + z-index: -1; + background-image: var(--bg); +} diff --git a/osse-web/src/app/app.component.html b/osse-web/src/app/app.component.html new file mode 100644 index 0000000..2515e0d --- /dev/null +++ b/osse-web/src/app/app.component.html @@ -0,0 +1,17 @@ + + +
+ +
+
+
+ +
+
+
+ +
+ + diff --git a/osse-web/src/app/app.component.spec.ts b/osse-web/src/app/app.component.spec.ts new file mode 100644 index 0000000..2cd412f --- /dev/null +++ b/osse-web/src/app/app.component.spec.ts @@ -0,0 +1,55 @@ +import { TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; +import { WebAudioService } from './shared/player/web-audio.service'; +import { provideRouter } from '@angular/router'; +import { routes } from './app.routes'; +import { By } from '@angular/platform-browser'; +import { LoadingComponent } from './shared/ui/loading/loading.component'; +import { NavigationComponent } from './navigation/navigation.component'; +import { ToastContainerComponent } from './toast-container/toast-container.component'; +import { PlayerComponent } from './shared/player/player.component'; +import { ModalComponent } from './shared/ui/modal/modal.component'; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + provideRouter(routes), + { + provide: WebAudioService, + useClass: class { + setUp() {}; + setPan(p: number) {}; + getPanValue() { + return 0.5; + } + } + } + ] + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it(`should have the 'osse' title`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app.title).toEqual('Osse'); + }); + + it('should have child components', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.debugElement; + + expect(compiled.query(By.directive(LoadingComponent))).toBeTruthy(); + expect(compiled.query(By.directive(NavigationComponent))).toBeTruthy(); + expect(compiled.query(By.directive(ToastContainerComponent))).toBeTruthy(); + expect(compiled.query(By.directive(PlayerComponent))).toBeTruthy(); + expect(compiled.query(By.directive(ModalComponent))).toBeTruthy(); + }); +}); diff --git a/osse-web/src/app/app.component.ts b/osse-web/src/app/app.component.ts new file mode 100644 index 0000000..2895a98 --- /dev/null +++ b/osse-web/src/app/app.component.ts @@ -0,0 +1,49 @@ +import { Component, ElementRef, Injector, signal, ViewChild, WritableSignal } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { NavigationComponent } from './navigation/navigation.component'; +import { PlayerComponent } from './shared/player/player.component'; +import { ToastContainerComponent } from './toast-container/toast-container.component'; +import { BackgroundImageService } from './shared/ui/background-image.service'; +import { PlayerService } from './shared/player/player.service'; +import { PlaybackState } from './shared/player/state-change'; +import { ModalComponent } from './shared/ui/modal/modal.component'; +import { LocatorService } from './locator.service'; +import { AuthService } from './shared/services/auth/auth.service'; +import { CommonModule } from '@angular/common'; +import { LoadingComponent } from './shared/ui/loading/loading.component'; +import { NetworkService } from './shared/services/network/network.service'; +import { PreloadService } from './shared/player/preload/preload.service'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet, NavigationComponent, PlayerComponent, ToastContainerComponent, ModalComponent, LoadingComponent, CommonModule], + templateUrl: './app.component.html', + styleUrl: './app.component.css' +}) +export class AppComponent { + title = 'Osse'; + playerState: WritableSignal = signal(false); + showPlayer: WritableSignal = signal(false); + @ViewChild('outlet') outlet!: ElementRef; + + constructor( + private backgroundImageService: BackgroundImageService, + private playerService: PlayerService, + private injector: Injector, + private networkService: NetworkService, + private authService: AuthService, + private preloadService: PreloadService + ) { + // Allow shared injector for accessing services + LocatorService.injector = this.injector; + + this.backgroundImageService.bgChanged.subscribe((v) => { + this.outlet.nativeElement.style.setProperty('--bg', `url('${v}')`); + }); + this.playerService.stateChanged.subscribe((s) => { + this.playerState.set(s == PlaybackState.Playing); + }); + this.authService.authStateChanged.subscribe((v) => this.showPlayer.set(v)); + } +} + diff --git a/osse-web/src/app/app.config.ts b/osse-web/src/app/app.config.ts new file mode 100644 index 0000000..068b722 --- /dev/null +++ b/osse-web/src/app/app.config.ts @@ -0,0 +1,18 @@ +import { ApplicationConfig } from '@angular/core'; +import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router'; + +import { routes } from './app.routes'; +import { ConfigService } from './shared/services/config/config.service'; +import { TrackService } from './shared/services/track/track.service'; +import { AuthService } from './shared/services/auth/auth.service'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes, withComponentInputBinding(), withInMemoryScrolling({ + scrollPositionRestoration: "top" + })), + { provide: ConfigService }, + { provide: TrackService }, + { provide: AuthService } + ] +} diff --git a/osse-web/src/app/app.routes.ts b/osse-web/src/app/app.routes.ts new file mode 100644 index 0000000..98fc0cf --- /dev/null +++ b/osse-web/src/app/app.routes.ts @@ -0,0 +1,67 @@ +import { Routes } from '@angular/router'; +import { HomeComponent } from './home/home.component'; +import { albumViewResolver } from './albums/view/album-view.resolver'; +import { LoginComponent } from './login/login.component'; +import { isLoggedIn } from './shared/services/auth/auth.guard'; + +export const routes: Routes = [ + { + path: 'tracks', + loadComponent: () => import('./track-list/track-list.component').then(c => c.TrackListComponent), + canActivate: [isLoggedIn], + title: 'Osse - Track Search' + }, + { + path: 'albums', + loadComponent: () => import('./albums/albums.component').then(c => c.AlbumsComponent), + canActivate: [isLoggedIn], + title: 'Osse - Albums' + }, + { + path: 'albums/view/:id', + loadComponent: () => import('./albums/view/view.component').then(c => c.ViewComponent), + resolve: { + album: albumViewResolver + }, + canActivate: [isLoggedIn], + title: 'Osse - Albums' + }, + { + path: 'playlists', + loadComponent: () => import('./playlist/playlist.component').then(c => c.PlaylistComponent), + canActivate: [isLoggedIn], + title: 'Osse - Playlists' + }, + { + path: 'playlists/view/:id', + loadComponent: () => import('./playlist/playlist-view/playlist-view.component').then(c => c.PlaylistViewComponent), + canActivate: [isLoggedIn], + title: 'Osse - Playlists' + }, + { + path: 'settings', + loadComponent: () => import('./settings/settings.component').then(c => c.SettingsComponent), + canActivate: [isLoggedIn], + title: 'Osse - Settings' + }, + { + path: 'home', + component: HomeComponent, + canActivate: [isLoggedIn], + title: 'Osse - Player' + }, + { + path: 'login', + component: LoginComponent, + title: 'Osse - Login' + }, + { + path: 'register', + loadComponent: () => import('./registration/registration.component').then(c => c.RegistrationComponent), + title: 'Osse - Register' + }, + { + path: "**", + redirectTo: "home", + } +]; diff --git a/osse-web/src/app/home/home.component.html b/osse-web/src/app/home/home.component.html new file mode 100644 index 0000000..df74ed5 --- /dev/null +++ b/osse-web/src/app/home/home.component.html @@ -0,0 +1,62 @@ +
+
+ + +
+ +

{{ artist() || 'Unknown Artist' }}

+ +
+ @if (showVisualizer()) { + + } +
+
+
+
+
+
+
+ @if (tracksCanBeRestored()) { + + } @else { + + } + + + + + +
+
+
+
+ +@if (tracks().length > 0) { +
+ @for (track of tracks(); track track.uuid; let idx = $index) { + + } +
+} diff --git a/osse-web/src/app/home/home.component.ts b/osse-web/src/app/home/home.component.ts new file mode 100644 index 0000000..b61f3fa --- /dev/null +++ b/osse-web/src/app/home/home.component.ts @@ -0,0 +1,219 @@ +import { Component, computed, OnDestroy, OnInit, signal, WritableSignal } from '@angular/core'; +import { TrackComponent } from './track/track.component'; +import { TrackService } from '../shared/services/track/track.service'; +import { Track } from '../shared/services/track/track'; +import { PlayerService } from '../shared/player/player.service'; +import { PlaybackState } from '../shared/player/state-change'; +import { ConfigService } from '../shared/services/config/config.service'; +import { IconComponent } from '../shared/ui/icon/icon.component'; +import { mdiFastForward, mdiInformation, mdiRepeat, mdiRewind, mdiShuffle, mdiSilverwareForkKnife, mdiDeleteSweep, mdiRepeatOff, mdiRepeatOnce, mdiRestore, mdiCog } from '@mdi/js'; +import { ModalService } from '../shared/ui/modal/modal.service'; + +import { Subscription } from 'rxjs'; +import { HeaderComponent } from '../shared/ui/header/header.component'; +import { AlbumArtFullscreenComponent } from '../shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component'; +import { ToastService } from '../toast-container/toast.service'; +import { TrackInfoComponent } from '../shared/ui/modals/track-info/track-info.component'; +import { Repeat } from '../shared/services/track/repeat.enum'; +import { VisualizerComponent } from '../shared/player/visualizer/visualizer.component'; +import { PlayerSettingsComponent } from '../shared/ui/modals/player-settings/player-settings.component'; +import { CommonModule } from '@angular/common'; +import { AddToPlaylistFactoryComponent } from '../shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component'; + +@Component({ + selector: 'app-home', + imports: [CommonModule, IconComponent, TrackComponent, HeaderComponent, VisualizerComponent], + templateUrl: './home.component.html' +}) +export class HomeComponent implements OnInit, OnDestroy { + public bg: WritableSignal = signal("assets/img/osse.webp"); + public tracks: WritableSignal = signal([]); + public playing: boolean = false; + public title: WritableSignal = signal('No Track Found'); + public artist: WritableSignal = signal(''); + public repeat = computed(() => { + switch (this.trackService.repeat()) { + case Repeat.None: return mdiRepeatOff; + case Repeat.Once: return mdiRepeatOnce; + case Repeat.Loop: return mdiRepeat; + } + }); + public repeatActive = computed(() => { + let repeat = this.trackService.repeat(); + return repeat == Repeat.Once || repeat == Repeat.Loop; + }); + public repeatTooltip = computed(() => { + switch (this.trackService.repeat()) { + case Repeat.None: return 'Repeat Off'; + case Repeat.Once: return 'Repeat Once'; + case Repeat.Loop: return 'Repeat Until Stopped'; + } + }); + public tracksCanBeRestored: WritableSignal = signal(false); + public showVisualizer: WritableSignal = signal(true); + + private trackUpdated!: Subscription; + private playbackEnded!: Subscription; + private stateChanged!: Subscription; + + forward = mdiFastForward; + back = mdiRewind; + shuffle = mdiShuffle; + info = mdiInformation; + consume = mdiSilverwareForkKnife; + clear = mdiDeleteSweep; + restore = mdiRestore; + settings = mdiCog; + + constructor( + public trackService: TrackService, + private playerService: PlayerService, + private configService: ConfigService, + private modalService: ModalService, + private notificationService: ToastService + ) { } + + public onPlayerToggle() { + // If no track, don't respond to button click + if (!this.trackService.activeTrack) return; + this.playing = !this.playing; + + if (!this.playing) { + this.playerService.pause(); + return; + } else { + this.playerService.play(); + } + } + + public onNextTrack() { + this.trackService.moveToNextTrack(); + } + + public onPreviousTrack() { + this.trackService.moveToLastTrack(); + } + + public playTrack(index: number) { + this.trackService.moveToTrack(index); + } + + public removeTrack(index: number) { + this.trackService.removeTrack(index); + } + + public addToPlaylist(track: Track) { + this.modalService.setDynamicModal(AddToPlaylistFactoryComponent, [{ + name: 'tracks', + val: [track] + }], 'Add to Playlist'); + this.modalService.show(); + } + + public shuffleTracks() { + this.trackService.shuffle(); + this.tracks.set(this.trackService.tracks); + } + + public toggleConsume() { + this.trackService.consume.update((v) => !v); + } + + public toggleRepeat() { + this.trackService.repeat.update((v) => { + switch (v) { + case Repeat.None: return Repeat.Once; + case Repeat.Once: return Repeat.Loop; + case Repeat.Loop: return Repeat.None; + } + }); + } + + public clearTracks() { + this.trackService.clearTracks(); + this.tracksCanBeRestored.set(true); + } + + public restoreTracks() { + this.trackService.restoreTracks(); + this.tracksCanBeRestored.set(false); + } + + public showAlbumArt() { + if (this.trackService.activeTrack) { + let url = this.trackService.activeTrack?.coverURL; + + this.modalService.setDynamicModal(AlbumArtFullscreenComponent, [{ + name: 'url', + val: url + }], 'Album Art'); + this.modalService.show(); + } else { + this.notificationService.info('You must have a track playing to view album art.'); + } + } + + public get consumeState() { + return this.trackService.consume.asReadonly(); + } + + showTrackInfo() { + if (this.trackService.activeTrack) { + this.modalService.setDynamicModal(TrackInfoComponent, [{ + name: 'trackInfo', + val: this.trackService.activeTrack + }], 'Track Info'); + this.modalService.show(); + } else { + this.notificationService.info('You must have a track playing to view track info.'); + } + } + + public showPlayerSettings() { + this.modalService.setDynamicModal(PlayerSettingsComponent, [{ + 'name': 'visualizerSignal', + 'val': this.showVisualizer + }], 'Player Settings'); + this.modalService.show(); + } + + ngOnInit(): void { + this.tracks.set(this.trackService.tracks); + + this.trackUpdated = this.playerService.trackUpdated.subscribe((val) => { + this.title.set(val.title); + this.artist.set(val.artist?.name || ''); + this.bg.set(val.cover); + }); + this.stateChanged = this.playerService.stateChanged.subscribe((val) => { + if (val == PlaybackState.Paused) { + this.playing = false; + } else { + this.playing = true; + } + }); + this.playbackEnded = this.playerService.playbackEnded.subscribe(_ => { + this.title.set(''); + this.artist.set(''); + this.bg.set("assets/img/osse.webp"); + this.playing = false; + }); + + // Get the initial value of the current track + if (this.trackService.activeTrack) { + this.title.set(this.trackService.activeTrack.title); + this.artist.set(this.trackService.activeTrack.artistPrimary()?.name ?? ''); + this.bg.set(this.trackService.activeTrack.coverURL); + } + + this.showVisualizer.set(this.configService.get('showVisualizer')); + } + + ngOnDestroy(): void { + this.trackUpdated.unsubscribe(); + this.stateChanged.unsubscribe(); + this.playbackEnded.unsubscribe(); + + this.trackService.removeClearedTracks(); + } +} diff --git a/osse-web/src/app/home/track/track.component.html b/osse-web/src/app/home/track/track.component.html new file mode 100644 index 0000000..e0f4dd1 --- /dev/null +++ b/osse-web/src/app/home/track/track.component.html @@ -0,0 +1,46 @@ +
+ @if (mode == 'view') { +
+ +
+
+
+ +
+
+ } @else { +
+ + + + +
+ } +
diff --git a/osse-web/src/app/home/track/track.component.ts b/osse-web/src/app/home/track/track.component.ts new file mode 100644 index 0000000..d9983ca --- /dev/null +++ b/osse-web/src/app/home/track/track.component.ts @@ -0,0 +1,55 @@ +import { Component, EventEmitter, input, Input, InputSignal, Output } from '@angular/core'; +import { Track } from '../../shared/services/track/track'; +import { IconComponent } from '../../shared/ui/icon/icon.component'; +import { mdiClose, mdiDotsVertical, mdiPlay, mdiPlaylistPlus, mdiStar, mdiTrashCan } from '@mdi/js'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-track', + imports: [IconComponent, CommonModule], + templateUrl: './track.component.html' +}) +export class TrackComponent { + @Input() track!: Track; + public activeTrack: InputSignal = input(false); + @Output() onPlay = new EventEmitter(); + @Output() onRemove = new EventEmitter(); + @Output() onPlaylistAdd = new EventEmitter(); + public mode: string = 'view'; + + star = mdiStar; + dots = mdiDotsVertical; + trash = mdiTrashCan; + cancel = mdiClose; + playlist = mdiPlaylistPlus; + play = mdiPlay; + + public toggleView() { + if (this.mode == 'view') { + this.mode = 'act'; + } else { + this.mode = 'view'; + } + } + + public toggleViewWithEvent(ev: Event) { + ev.preventDefault(); + this.toggleView(); + } + + public removeTrack() { + this.onRemove.emit(); + this.toggleView(); + } + + public addToPlaylist() { + this.onPlaylistAdd.emit(this.track); + this.toggleView(); + } + + public playTrack() { + this.onPlay.emit(); + this.toggleView(); + } +} + diff --git a/osse-web/src/app/locator.service.ts b/osse-web/src/app/locator.service.ts new file mode 100644 index 0000000..57b4c81 --- /dev/null +++ b/osse-web/src/app/locator.service.ts @@ -0,0 +1,12 @@ +import { Injectable, Injector } from '@angular/core'; + +/** + * A service that provides services to non angular services/components + */ +@Injectable({ + providedIn: 'root' +}) +export class LocatorService { + public static injector: Injector; + constructor() { } +} diff --git a/osse-web/src/app/login/login.component.html b/osse-web/src/app/login/login.component.html new file mode 100644 index 0000000..147926b --- /dev/null +++ b/osse-web/src/app/login/login.component.html @@ -0,0 +1,43 @@ +
+ + +
+

Welcome to the Osse music server!

+ + @if (serverFound()) { +

Please login.

+ +
+
+ + +
+ +
+ + +
+ +
+ + @if (waitingForResponse()) { + + } +
+
+ } + + @if (!serverFound()) { +

Connecting to API...

+ } + + @if (serverFound()) { +

Don't have an account yet? Create One

+ } +
+
diff --git a/osse-web/src/app/login/login.component.ts b/osse-web/src/app/login/login.component.ts new file mode 100644 index 0000000..2b8c8e3 --- /dev/null +++ b/osse-web/src/app/login/login.component.ts @@ -0,0 +1,68 @@ +import { Component, OnInit, signal, WritableSignal } from '@angular/core'; +import { fetcher } from '../shared/util/fetcher'; +import { HeaderComponent } from '../shared/ui/header/header.component'; +import { FormsModule } from '@angular/forms'; +import { ToastService } from '../toast-container/toast.service'; +import { ConfigService } from '../shared/services/config/config.service'; +import { Router } from '@angular/router'; +import { AuthService } from '../shared/services/auth/auth.service'; + +@Component({ + selector: 'app-login', + imports: [HeaderComponent, FormsModule], + templateUrl: './login.component.html', + styles: `` +}) +export class LoginComponent implements OnInit { + public username: string = ''; + public password: string = ''; + public serverFound: WritableSignal = signal(false); + public waitingForResponse = signal(false); + + constructor( + private notificationService: ToastService, + private configService: ConfigService, + private router: Router, + private authService: AuthService + ) { } + + public async login() { + if (this.username.length == 0 || this.password.length == 0) { + this.notificationService.error('You must enter a username and password.'); + return; + } + + this.waitingForResponse.set(true); + + let res = await fetcher('login', { + method: 'POST', + body: JSON.stringify({ + username: this.username, + password: this.password, + }), + rootURL: this.configService.get('apiURL') + }); + + if (res.ok) { + await this.authService.attemptLogin(); + this.router.navigateByUrl('/home'); + } else { + this.notificationService.error('Login error. Check that the username and password are correct.'); + } + + this.waitingForResponse.set(false); + } + + async ngOnInit() { + // Try to login with the default URL. + try { + await fetch(this.configService.get('apiURL') + 'ping', { + credentials: 'include' + }); + this.serverFound.set(true); + } catch (e) { + // This should only happen in dev. If it fails, show the server URL inputs. + this.notificationService.error('Failed to autodetect server URL. Please enter it.'); + } + } +} diff --git a/osse-web/src/app/navigation/navigation.component.html b/osse-web/src/app/navigation/navigation.component.html new file mode 100644 index 0000000..b47ad98 --- /dev/null +++ b/osse-web/src/app/navigation/navigation.component.html @@ -0,0 +1,46 @@ +
+ +
+ + + + @if (showLogoutButton()) { + + } +
+ +
diff --git a/osse-web/src/app/navigation/navigation.component.ts b/osse-web/src/app/navigation/navigation.component.ts new file mode 100644 index 0000000..3589257 --- /dev/null +++ b/osse-web/src/app/navigation/navigation.component.ts @@ -0,0 +1,41 @@ +import { Component, computed, OnInit, signal, WritableSignal } from '@angular/core'; +import { Router, RouterLink, RouterLinkActive } from '@angular/router'; +import { IconComponent } from '../shared/ui/icon/icon.component'; +import { mdiCog, mdiHome, mdiLogout, mdiMenu, mdiMenuClose } from '@mdi/js'; +import { CommonModule } from '@angular/common'; +import { AuthService } from '../shared/services/auth/auth.service'; +import { ToastService } from '../toast-container/toast.service'; + + +@Component({ + selector: 'app-navigation', + imports: [RouterLink, RouterLinkActive, IconComponent, CommonModule], + templateUrl: './navigation.component.html', + styles: `` +}) +export class NavigationComponent implements OnInit { + gear = mdiCog; + home = mdiHome; + exit = mdiLogout; + mobileMenuOpen: WritableSignal = signal(false); + mobileMenuIcon = computed(() => this.mobileMenuOpen() ? mdiMenuClose : mdiMenu); + showLogoutButton: WritableSignal = signal(false); + + constructor(public authService: AuthService, private router: Router, private notificationService: ToastService) { } + + public toggleMenu() { + this.mobileMenuOpen.update((v) => !v); + } + + public async logout() { + await this.authService.logout(); + this.notificationService.info('Logged out successfully. Have a nice day!'); + this.router.navigateByUrl('login'); + } + + async ngOnInit() { + this.authService.authStateChanged.subscribe((v) => { + this.showLogoutButton.set(v); + }); + } +} diff --git a/osse-web/src/app/playlist/playlist-view/editPlaylistModel.ts b/osse-web/src/app/playlist/playlist-view/editPlaylistModel.ts new file mode 100644 index 0000000..5a17c0f --- /dev/null +++ b/osse-web/src/app/playlist/playlist-view/editPlaylistModel.ts @@ -0,0 +1,3 @@ +export class EditPlaylist { + constructor(public name: string) {} +} \ No newline at end of file diff --git a/osse-web/src/app/playlist/playlist-view/playlist-add-tracks/playlist-add-tracks.component.html b/osse-web/src/app/playlist/playlist-view/playlist-add-tracks/playlist-add-tracks.component.html new file mode 100644 index 0000000..aae1c05 --- /dev/null +++ b/osse-web/src/app/playlist/playlist-view/playlist-add-tracks/playlist-add-tracks.component.html @@ -0,0 +1,27 @@ + + +
+
+
+ +
+
+
+

Select a track to add it to the selection. Add it to the playlist when you have selected all the + tracks you want.

+
+
+ + +
+ + +
diff --git a/osse-web/src/app/playlist/playlist-view/playlist-add-tracks/playlist-add-tracks.component.ts b/osse-web/src/app/playlist/playlist-view/playlist-add-tracks/playlist-add-tracks.component.ts new file mode 100644 index 0000000..3c384cf --- /dev/null +++ b/osse-web/src/app/playlist/playlist-view/playlist-add-tracks/playlist-add-tracks.component.ts @@ -0,0 +1,143 @@ +import { Component, ElementRef, input, output, signal, ViewChild, WritableSignal } from '@angular/core'; +import { Track } from '../../../shared/services/track/track'; +import { TrackMatrixComponent } from '../../../shared/ui/track-matrix/track-matrix.component'; +import { HeaderComponent } from '../../../shared/ui/header/header.component'; +import { CommonModule } from '@angular/common'; +import { IconComponent } from '../../../shared/ui/icon/icon.component'; +import { debounceTime, fromEvent, Subscription } from 'rxjs'; +import { TrackMatrixMode } from '../../../shared/ui/track-matrix/track-matrix-mode.enum'; +import { fetcher } from '../../../shared/util/fetcher'; +import { mdiClose, mdiPlaylistPlay } from '@mdi/js'; + +@Component({ + selector: 'app-playlist-add-tracks', + imports: [CommonModule, TrackMatrixComponent, HeaderComponent, IconComponent], + templateUrl: './playlist-add-tracks.component.html', + styles: `` +}) +export class PlaylistAddTracksComponent { + @ViewChild('search') searchBar!: ElementRef; + @ViewChild(TrackMatrixComponent) matrix!: TrackMatrixComponent; + + public playlistID = input.required({}); + public addTracks = output(); + public loading: WritableSignal = signal(true); + public waitingOnRequest: WritableSignal = signal(false); + public tracks: WritableSignal = signal([]); + private allTracks: Track[] = []; + private timeout: number = 0; + private scrollSubscription!: Subscription; + + playlist = mdiPlaylistPlay; + close = mdiClose; + + constructor() { } + + ngAfterViewInit(): void { + this.matrix.setMode(TrackMatrixMode.Select); + } + + async ngOnInit(): Promise { + // Listen for scroll events + this.scrollSubscription = fromEvent(window, 'scroll') + .pipe(debounceTime(300)) + .subscribe(() => { + const endOfPage = window.innerHeight + window.pageYOffset >= (document.body.offsetHeight * 0.6); + if (endOfPage) { + this.requestTracks(this.searchBar.nativeElement.value); + } + }) + + // On load, get the first 75 tracks + let req = await fetcher('tracks/search'); + this.loading.set(false); + if (!req.ok) return; + + let tracks = await req.json(); + tracks.forEach((track: any) => { + this.allTracks.push(new Track(track)); + }); + this.tracks.set(this.allTracks); + } + + public async addSelectedTracksToPlaylist() { + let tracks = this.matrix.getSelectedTracks(); + + if (tracks.length > 0) { + this.waitingOnRequest.set(true); + let req = await fetcher(`playlists/${this.playlistID()}/track-set`, { + method: 'POST', + body: JSON.stringify({ + 'track-ids': tracks.map((t) => t.id) + }) + }); + + // If success, add tracks to above UI + if (req.ok) { + console.log(tracks); + this.addTracks.emit(tracks); + this.matrix.clearSelectedTracks(); + } + + this.waitingOnRequest.set(false); + } + } + + public async onInput(ev: any) { + // Search for tracks. We made a debounce which waits 500ms before sending. + // Makes it a little easier on the server. + + // If the search input is empty, reset the filter + if (ev.target.value.length == 0) { + this.tracks.set(this.allTracks); + } + + clearTimeout(this.timeout); + this.timeout = setTimeout(async () => { + // Don't search for empty string + if (ev.target.value.trim() == '') return; + this.requestTracks(ev.target.value.trim()); + }, 500); + } + + public async requestTracks(search: string) { + // Find the amount of track that we have that match the regex. + let offset = 0; + + if (search.length == 0) { + offset = this.tracks().length; + } else { + const regex = new RegExp('%' + search + "%"); + this.tracks().forEach(val => { + if (regex.test(val.title)) { + offset += 1; + } + }); + if (offset < 75) { + offset = 0; + } + } + + // Search for tracks + this.loading.set(true); + let req = await fetcher('tracks/search?' + + new URLSearchParams({ + track: search, + track_offset: offset.toString() + }).toString()); + this.loading.set(false); + if (!req.ok && req.status == 200) return; + + let json = await req.json(); + for (let track of json) { + if (this.allTracks.some(v => v.id == track.id)) continue; + this.allTracks.push(new Track(track)); + } + this.tracks.set(this.getMatchingTracks(search)); + } + + public getMatchingTracks(search: string): Track[] { + let regex = new RegExp(search, 'i'); + return this.allTracks.filter((v) => regex.test(v.track.title)); + } +} diff --git a/osse-web/src/app/playlist/playlist-view/playlist-view.component.html b/osse-web/src/app/playlist/playlist-view/playlist-view.component.html new file mode 100644 index 0000000..a62f171 --- /dev/null +++ b/osse-web/src/app/playlist/playlist-view/playlist-view.component.html @@ -0,0 +1,75 @@ +
+ + +
+ + + +
+ + + + @if (playlist()) { +
+
+
+ + + +
+ +

Click on a track to add it to the queue. Right click (or hold) a track to enable additional options. +

+ +
+ +
+ +
+ +
+
+
+ +
+
+ + +
+ +

The playlist name must be at + least 1 character.

+ + +
+
+ Delete +
+

Deleting a playlist is a permanant action that cannot be undone.

+ +
+ +
+ } +
diff --git a/osse-web/src/app/playlist/playlist-view/playlist-view.component.ts b/osse-web/src/app/playlist/playlist-view/playlist-view.component.ts new file mode 100644 index 0000000..59ec700 --- /dev/null +++ b/osse-web/src/app/playlist/playlist-view/playlist-view.component.ts @@ -0,0 +1,156 @@ +import { Component, computed, Input, numberAttribute, signal, ViewChild, WritableSignal } from '@angular/core'; +import { Playlist } from '../../shared/services/playlist/Playlist'; +import { HeaderComponent } from '../../shared/ui/header/header.component'; +import { Router } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { EditPlaylist } from './editPlaylistModel'; +import { FormsModule } from '@angular/forms'; +import { IconComponent } from '../../shared/ui/icon/icon.component'; +import { mdiClose, mdiPencil, mdiPlaylistPlay, mdiTrashCan } from '@mdi/js'; +import { PlaylistService } from '../../shared/services/playlist/playlist.service'; +import { TrackService } from '../../shared/services/track/track.service'; +import { ToastService } from '../../toast-container/toast.service'; +import { Track } from '../../shared/services/track/track'; +import { fetcher } from '../../shared/util/fetcher'; +import { TrackMatrixComponent } from '../../shared/ui/track-matrix/track-matrix.component'; +import { TrackMatrixMode } from '../../shared/ui/track-matrix/track-matrix-mode.enum'; +import { PlaylistAddTracksComponent } from './playlist-add-tracks/playlist-add-tracks.component'; + +@Component({ + selector: 'app-playlist-view', + imports: [HeaderComponent, IconComponent, CommonModule, FormsModule, TrackMatrixComponent, PlaylistAddTracksComponent], + templateUrl: './playlist-view.component.html', + styles: `` +}) +export class PlaylistViewComponent { + @ViewChild(TrackMatrixComponent) tracks!: TrackMatrixComponent; + @Input({ transform: numberAttribute }) + set id(id: number) { + this.getPlaylist(id); + } + + pencil = mdiPencil; + trash = mdiTrashCan; + play = mdiPlaylistPlay; + close = mdiClose; + + public playlist = signal(null); + public showTrackSelectionMenu: WritableSignal = signal(false); + public ready: WritableSignal = signal(false); + public waitingOnRequest: WritableSignal = signal(false); + public activeTab = signal('view'); + public showViewTab = computed(() => this.activeTab() == 'view'); + public showAddTracksTab = computed(() => this.activeTab() == 'addTracks'); + public showModifyTab = computed(() => this.activeTab() == 'modify'); + public model = new EditPlaylist(''); + + constructor( + private router: Router, + private playlistService: PlaylistService, + private trackService: TrackService, + private notificationService: ToastService + ) { } + + public delete() { + if (confirm(`Are you sure you want to delete ${this.playlist()!.name}?`)) { + fetcher('playlists/' + this.playlist()?.id, { + method: 'DELETE', + }).then((_r) => { + this.router.navigate(['/playlists']); + }) + } + } + + public async edit() { + let req = await fetcher('playlists/' + this.playlist()?.id, { + method: 'PATCH', + body: JSON.stringify({ + name: this.model.name + }), + headers: [ + ['Content-Type', 'application/json'] + ] + }); + + if (req.ok) { + this.getPlaylist(this.playlist()?.id as number); + this.notificationService.info('Playlist renamed successfully.'); + this.activeTab.set('view'); + } + } + + public addTrack(track: Track) { + this.trackService.addTrack(track); + this.notificationService.info('Added ' + track.title); + } + + public addTracksToQueue() { + let tracks = this.playlist()!.tracks; + for (let track of tracks) { + this.trackService.addTrack(track); + } + + this.notificationService.info('Added ' + tracks.length + ' tracks'); + } + + private async getPlaylist(id: number) { + this.playlist.set(await this.playlistService.getPlaylist(id)); + this.model.name = this.playlist()?.name ?? ''; + this.ready.set(true); + } + + public onTrackMatrixModeChange(mode: TrackMatrixMode) { + this.showTrackSelectionMenu.set(mode == TrackMatrixMode.Select); + } + + public closePlaylistTrackSelector() { + this.tracks.setMode(TrackMatrixMode.View); + this.tracks.clearSelectedTracks(); + } + + public async removeTracksFromPlaylist() { + let tracks = this.tracks.getSelectedTracks(); + if (tracks.length == 0) { + return; + } + + this.waitingOnRequest.set(true); + let req = await fetcher(`playlists/${this.playlist()?.id}/track-set`, { + method: 'DELETE', + body: JSON.stringify({ + 'track-ids': tracks.map((t) => t.id) + }) + }); + + if (req.ok) { + this.playlist.update((p) => { + p!.tracks = p!.tracks.filter((t) => !tracks.some((t2) => t2.id == t.id)) + return p; + }); + + this.notificationService.info('Removed ' + tracks.length + ' tracks from ' + this.playlist()!.name); + this.tracks.clearSelectedTracks(); + this.tracks.setMode(TrackMatrixMode.View); + } + + this.waitingOnRequest.set(false); + } + + addTracksToPlaylist(tracks: Track[]) { + this.playlist.update((p) => { + p!.tracks.push(...tracks); + return p; + }) + this.notificationService.info('Added ' + tracks.length + ' tracks to ' + this.playlist()!.name); + this.activeTab.set('view'); + this.tracks.clearSelectedTracks(); + this.tracks.setMode(TrackMatrixMode.View); + } + + playAll() { + let tracks = this.tracks.tracks(); + tracks.forEach((t) => this.trackService.addTrack(t)); + this.notificationService.info('Added ' + tracks.length + ' tracks.'); + } +} + diff --git a/osse-web/src/app/playlist/playlist.component.html b/osse-web/src/app/playlist/playlist.component.html new file mode 100644 index 0000000..48b83f0 --- /dev/null +++ b/osse-web/src/app/playlist/playlist.component.html @@ -0,0 +1,21 @@ +
+ + +
+
+ +
+ @for (playlist of playlists(); track $index) { +
+

+ {{playlist.name}} + {{playlist.count}} tracks +

+
+ } @empty { +

No Playlists were found.

+ } +
+
diff --git a/osse-web/src/app/playlist/playlist.component.ts b/osse-web/src/app/playlist/playlist.component.ts new file mode 100644 index 0000000..ab7a80d --- /dev/null +++ b/osse-web/src/app/playlist/playlist.component.ts @@ -0,0 +1,52 @@ +import { ChangeDetectionStrategy, Component, OnInit, WritableSignal, signal } from '@angular/core'; +import { Playlist } from '../shared/services/playlist/Playlist'; +import { OssePlaylist } from '../shared/services/playlist/osse-playlist'; +import { Router, RouterLink } from '@angular/router'; +import { HeaderComponent } from '../shared/ui/header/header.component'; +import { IconComponent } from '../shared/ui/icon/icon.component'; +import { mdiPlus, mdiRefresh } from '@mdi/js'; +import { fetcher } from '../shared/util/fetcher'; + +@Component({ + selector: 'app-playlist', + imports: [RouterLink, HeaderComponent, IconComponent], + templateUrl: './playlist.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styles: `` +}) +export class PlaylistComponent implements OnInit { + public playlists: WritableSignal = signal([]); + plus = mdiPlus; + refresh = mdiRefresh; + + constructor( + private router: Router + ) { } + + ngOnInit(): void { + this.refreshPlaylistList(); + } + + public async createPlaylist() { + let request = await fetcher('playlists', { + method: 'POST', + body: JSON.stringify({ + 'name': "Default" + }), + headers: [ + ['Content-Type', 'application/json'] + ] + }); + + if (request.ok) { + let res = await request.json(); + this.router.navigateByUrl("playlists/view/" + res.id); + } + } + + public async refreshPlaylistList() { + let request = await fetcher('playlists'); + let result = await request.json(); + this.playlists.set(result.map((p: OssePlaylist) => new Playlist(p))); + } +} diff --git a/osse-web/src/app/registration/registration.component.html b/osse-web/src/app/registration/registration.component.html new file mode 100644 index 0000000..cb0b5c9 --- /dev/null +++ b/osse-web/src/app/registration/registration.component.html @@ -0,0 +1,41 @@ +
+ + +
+

Welcome to the Osse music server!

+ + @if (serverFound()) { +

Please create your account.

+ +
+
+ + +
+ +
+ + +
+ +
+ + @if (waitingForResponse()) { + + } +
+
+ } + + @if (!serverFound()) { +

Connecting to API...

+ } + + @if (serverFound()) { +

Already have an account? Login

+ } +
+
diff --git a/osse-web/src/app/registration/registration.component.ts b/osse-web/src/app/registration/registration.component.ts new file mode 100644 index 0000000..93a8b84 --- /dev/null +++ b/osse-web/src/app/registration/registration.component.ts @@ -0,0 +1,75 @@ +import { Component, OnInit, signal, WritableSignal } from '@angular/core'; +import { HeaderComponent } from '../shared/ui/header/header.component'; +import { ToastService } from '../toast-container/toast.service'; +import { ConfigService } from '../shared/services/config/config.service'; +import { Router } from '@angular/router'; +import { AuthService } from '../shared/services/auth/auth.service'; +import { FormsModule } from '@angular/forms'; +import { fetcher } from '../shared/util/fetcher'; + +@Component({ + selector: 'app-registration', + imports: [HeaderComponent, FormsModule], + templateUrl: './registration.component.html', + styles: `` +}) +export class RegistrationComponent implements OnInit { + public username: string = ''; + public password: string = ''; + public serverFound: WritableSignal = signal(false); + public waitingForResponse = signal(false); + + constructor( + private notificationService: ToastService, + private configService: ConfigService, + private router: Router, + private authService: AuthService + ) { } + + public async register() { + if (this.username.length == 0 || this.password.length == 0) { + this.notificationService.error('You must enter a username and password.'); + return; + } + + this.waitingForResponse.set(true); + + let res = await fetcher('register', { + method: 'POST', + body: JSON.stringify({ + username: this.username, + password: this.password, + }), + rootURL: this.configService.get('apiURL') + }); + + this.waitingForResponse.set(false); + + if (res.status == 403) { + this.notificationService.error('Account creation error. Check that registration is enabled in your server settings. '); + return; + } else if (res.status == 422) { + this.notificationService.error('Account creation error. Check that the username is not in use and that the username and password are at least 1 character. '); + return; + } + + if (res.ok) { + this.notificationService.info('Account created successfully! Welcome to Osse, ' + this.username + '.'); + await this.authService.attemptLogin(); + this.router.navigateByUrl('/home'); + } else { + this.notificationService.error('Login error.'); + } + } + + async ngOnInit() { + // Try to login with the default URL. + try { + await fetch(this.configService.get('apiURL') + 'api/ping'); + this.serverFound.set(true); + } catch (e) { + // This should only happen in dev. If it fails, show the server URL inputs. + this.notificationService.error('Failed to autodetect server URL. Please enter it.'); + } + } +} diff --git a/osse-web/src/app/settings/scan-progress.interface.ts b/osse-web/src/app/settings/scan-progress.interface.ts new file mode 100644 index 0000000..c40c2a0 --- /dev/null +++ b/osse-web/src/app/settings/scan-progress.interface.ts @@ -0,0 +1,7 @@ +export interface ScanProgress { + active: boolean; + // If active is true, the below fields are present. + total_directories?: number; + finished_count?: number + nextDir?: string | null; +} diff --git a/osse-web/src/app/settings/settings-logs/settings-logs.component.html b/osse-web/src/app/settings/settings-logs/settings-logs.component.html new file mode 100644 index 0000000..dedaa57 --- /dev/null +++ b/osse-web/src/app/settings/settings-logs/settings-logs.component.html @@ -0,0 +1,14 @@ + + +

Below are the current application logs. The last 500 lines are shown.

+ +@if (!showLogs()) { +
+ +
+} @else { + +
{{logs()}}
+} diff --git a/osse-web/src/app/settings/settings-logs/settings-logs.component.ts b/osse-web/src/app/settings/settings-logs/settings-logs.component.ts new file mode 100644 index 0000000..b70ca18 --- /dev/null +++ b/osse-web/src/app/settings/settings-logs/settings-logs.component.ts @@ -0,0 +1,46 @@ +import { Component, OnInit, signal, WritableSignal } from '@angular/core'; +import { HeaderComponent } from '../../shared/ui/header/header.component'; +import { fetcher } from '../../shared/util/fetcher'; +import { ToastService } from '../../toast-container/toast.service'; + +@Component({ + selector: 'app-settings-logs', + imports: [HeaderComponent], + templateUrl: './settings-logs.component.html', + styles: `` +}) +export class SettingsLogsComponent implements OnInit { + public showLogs: WritableSignal = signal(false); + public logs: WritableSignal = signal(''); + + constructor(private notificationService: ToastService) { } + + viewLogs() { + this.showLogs.set(true); + } + + private async requestLogs() { + let res = await fetcher('config/logs'); + + if (res.ok) { + this.logs.set(await res.text()); + return true; + } + + return false; + } + + public async refreshLogs() { + let success = await this.requestLogs(); + if (success) { + this.notificationService.info('Logs updated.'); + } else { + this.notificationService.error('Failed to access logs.'); + } + + } + + ngOnInit(): void { + this.requestLogs().then((r) => !r ? this.notificationService.error('Failed to access logs.') : null); + } +} diff --git a/osse-web/src/app/settings/settings-preferences/osse-config.ts b/osse-web/src/app/settings/settings-preferences/osse-config.ts new file mode 100644 index 0000000..66e26ef --- /dev/null +++ b/osse-web/src/app/settings/settings-preferences/osse-config.ts @@ -0,0 +1,4 @@ +export type OsseConfigResponse = { + queueEnabled: boolean, + directories: string[] +} diff --git a/osse-web/src/app/settings/settings-preferences/settings-preferences.component.html b/osse-web/src/app/settings/settings-preferences/settings-preferences.component.html new file mode 100644 index 0000000..d5f8215 --- /dev/null +++ b/osse-web/src/app/settings/settings-preferences/settings-preferences.component.html @@ -0,0 +1,98 @@ + + +
+
+
+

Background Image

+

+ When a track or album is playing, Osse will dispay the cover art in the background. + This may cause contrast issues with certain covers. +

+
+ +
+ + +
+
+ +
+
+
+

Visualizer

+

+ When a track is playing, Osse will dispay a music visualizer. This is generated in real-time. + This may cause performance issues with certain devices.

+
+ +
+
+ + +
+ +
+ + + + +
+
+
+ +
+
+
+

+ Save Queue +

+

Osse can save your queue to the server. This allows you to pick up where you left off when Osse is closed and reopened. + This is an account level setting.

+
+ +
+ + +
+
+ +
+ + + +
diff --git a/osse-web/src/app/settings/settings-preferences/settings-preferences.component.ts b/osse-web/src/app/settings/settings-preferences/settings-preferences.component.ts new file mode 100644 index 0000000..fcf3906 --- /dev/null +++ b/osse-web/src/app/settings/settings-preferences/settings-preferences.component.ts @@ -0,0 +1,97 @@ +import { Component, inject, OnInit, signal } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ConfigService } from '../../shared/services/config/config.service'; +import { HeaderComponent } from "../../shared/ui/header/header.component"; +import { IconComponent } from "../../shared/ui/icon/icon.component"; +import { mdiChartBar, mdiContentSave, mdiImage, mdiRestore } from '@mdi/js'; +import { fetcher } from '../../shared/util/fetcher'; +import { ToastService } from '../../toast-container/toast.service'; +import { OsseConfigResponse } from './osse-config'; + +@Component({ + selector: 'app-settings-preferences', + imports: [ReactiveFormsModule, HeaderComponent, IconComponent], + templateUrl: './settings-preferences.component.html', + styles: `` +}) +export class SettingsPreferencesComponent implements OnInit { + private formBuilder = inject(FormBuilder); + preferencesForm = this.formBuilder.group({ + showBackgroundArt: [false], + showVisualizer: [false], + visualizerSamples: [1, [Validators.min(1), Validators.max(10)]], + enableQueue: [false], + }); + + public waitingForResponse = signal(false); + + public visualizerIcon = mdiChartBar; + public imageIcon = mdiImage; + public queueIcon = mdiRestore; + public saveIcon = mdiContentSave; + + constructor(private configService: ConfigService, private notificationService: ToastService) { } + + async onSubmit() { + // Only save valid data + if (!this.preferencesForm.valid) { + return; + } + + this.waitingForResponse.set(true); + + // Save local data first + this.configService.saveMany({ + showCoverBackgrounds: this.preferencesForm.value.showBackgroundArt as boolean, + showVisualizer: this.preferencesForm.value.showVisualizer as boolean, + visualizerSamples: this.preferencesForm.value.visualizerSamples as number, + }); + + // Now, save account data + let res = await fetcher('config', { + method: 'POST', + body: JSON.stringify({ + enableQueue: this.preferencesForm.value.enableQueue ?? false + }) + }); + + if (res.ok) { + this.notificationService.info('Preferences Saved!'); + } else { + this.notificationService.error('Failed to save account preferences. Try saving again.'); + } + + this.waitingForResponse.set(false); + } + + private async requestSettings(): Promise { + let res = await fetcher('config'); + + if (res.ok) { + let response = await res.json(); + return response as OsseConfigResponse; + } else { + this.notificationService.error('Failed to reach server. Check that the URL is correct and that the server is running.'); + throw 'Config Error'; + } + } + + async ngOnInit(): Promise { + this.waitingForResponse.set(true); + + let conf; + try { + conf = await this.requestSettings(); + } catch (error) { } + + // Store the values and set them in the form + this.preferencesForm.setValue({ + showBackgroundArt: this.configService.get('showCoverBackgrounds'), + showVisualizer: this.configService.get('showVisualizer'), + visualizerSamples: this.configService.get('visualizerSamples'), + enableQueue: conf?.queueEnabled ?? false, + }); + + this.waitingForResponse.set(false); + } +} diff --git a/osse-web/src/app/settings/settings-scan-history/history.ts b/osse-web/src/app/settings/settings-scan-history/history.ts new file mode 100644 index 0000000..159e93e --- /dev/null +++ b/osse-web/src/app/settings/settings-scan-history/history.ts @@ -0,0 +1,25 @@ +export interface ScanJob { + id: number; + finished_at: string; + started_at: string; + status: string; + total_dirs: number; + directories: ScanDirectory[]; +} + +export interface ScanDirectory { + id: number; + files_scanned: number; + files_skipped: number; + status: string; + path: string; + errors: ScanError[]; + show: boolean; // This doesn't exist, but we add it client side. +} + +export interface ScanError { + id: number; + scan_directory_id: number; + error: string; + created_at: string; +} diff --git a/osse-web/src/app/settings/settings-scan-history/settings-scan-history.component.html b/osse-web/src/app/settings/settings-scan-history/settings-scan-history.component.html new file mode 100644 index 0000000..c9bc27f --- /dev/null +++ b/osse-web/src/app/settings/settings-scan-history/settings-scan-history.component.html @@ -0,0 +1,33 @@ + + +@if (isLoading()) { +

Fetching history. Please wait...

+} + +
+

Scans are listed with the most recent first. Scans are cleared on a weekly basis.

+ @for (job of jobs(); track $index) { +
+

Scan {{job.id}}: {{job.directories.length}} directories scanned.

+

{{job.started_at}} - {{job.finished_at}}

+ @for (dir of job.directories; track $index) { +
+

{{dir.path}} - {{dir.files_scanned}} files scanned | {{dir.files_skipped}} files skipped. Status: {{dir.status}}

+ @if (dir.errors.length > 0) { + @if (!dir.show) { + + } + @if (dir.show) { + + @for (err of dir.errors; track $index) { +
{{err.error}}
+ } + } + } +
+ } +
+ } @empty { +

No scans were found...

+ } +
diff --git a/osse-web/src/app/settings/settings-scan-history/settings-scan-history.component.ts b/osse-web/src/app/settings/settings-scan-history/settings-scan-history.component.ts new file mode 100644 index 0000000..88832cb --- /dev/null +++ b/osse-web/src/app/settings/settings-scan-history/settings-scan-history.component.ts @@ -0,0 +1,49 @@ +import { Component, OnInit, signal } from '@angular/core'; +import { HeaderComponent } from "../../shared/ui/header/header.component"; +import { fetcher } from '../../shared/util/fetcher'; +import { ToastService } from '../../toast-container/toast.service'; +import { ScanJob } from './history'; + +@Component({ + selector: 'app-settings-scan-history', + imports: [HeaderComponent], + templateUrl: './settings-scan-history.component.html', + styles: `` +}) +export class SettingsScanHistoryComponent implements OnInit { + public isLoading = signal(true); + public jobs = signal([]) + + constructor(private notificationService: ToastService) { } + + public async requestHistory() { + let req = await fetcher('scan/history'); + if (req.ok) { + // Set the directories. + let resp: ScanJob[] = await req.json(); + this.jobs.set(resp.map((j) => { + let start = new Date(j.started_at); + j.started_at = start.toLocaleString('en-US', { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true }); + + if (!j.finished_at) { + j.finished_at = 'now...' + } else { + let end = new Date(j.finished_at); + j.finished_at = end.toLocaleString('en-US', { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true }); + } + + return j; + })); + + // If a scan is active, set that status. + } else { + this.notificationService.error('Something went wrong when requesting the scan history. Try reloading the page.'); + } + + this.isLoading.set(false); + } + + ngOnInit(): void { + this.requestHistory(); + } +} diff --git a/osse-web/src/app/settings/settings-scan/settings-scan.component.html b/osse-web/src/app/settings/settings-scan/settings-scan.component.html new file mode 100644 index 0000000..c427e39 --- /dev/null +++ b/osse-web/src/app/settings/settings-scan/settings-scan.component.html @@ -0,0 +1,129 @@ + + +@if (fetchingScanStatus()) { +

Fetching scan status. Please wait...

+} +@if (!fetchingScanStatus() && !scanInProgress()) { +

+ Scan Settings +

+ +@if (scanCompleted()) { + +} + +

You can start a track scan from the web GUI. This will scan the below directories as listed in + your .env file. Each directory will be scanned recursively. This means each directory should not be the parent of a directory listed here.

+

For example, if I have 20 folders in the "Music" directory, I would simply use the "Music" directory, not 20 separate entries.

+ +
+
+ + + Scan Options + +
+ +
    + @for (dir of rootDirectories(); track $index) { +
  • {{dir}}
  • + } @empty { +
  • No directories found...
  • + } +
+
+ +

+ *A fresh scan will empty the entire database before scanning. You should only use this option if a metadata update has occured (such as adding new fields to tracks), or if you are having issues with the scanner. +

+} + +@if (!fetchingScanStatus() && scanInProgress()) { +

+ Active Scan +

+ + + +
+
+ + +
+

Scanned {{amountOfDirectoriesComplete()}} of {{totalAmountOfDirectoriesToScan()}} directories.

+ + + @if (waitingForCancelConfirmation()) { +

Scan cancel request has been sent. It will be cancelled before the next directory is scanned.

+ } +
+ +

The directories will be scanned in the below order. Depending the the speed of the storage drive and + network connection, this may take some time.

+ +
    + @for (dir of scanProgress(); track $index) { +
  • + @if (!$first) { +
    + } +
    {{dir.path}}
    +
    + @if (dirIsScanning(dir.status)) { + + + + } @else { + + + + } +
    +

    Scanned {{dir.filesScanned}} files and skipped {{dir.filesSkipped}}.

    + @if (!$last) { +
    + } +
  • + } +
+} + +@if (scanLogs()) { +

+ Scan Log +

+ +

+ Below is a log of all directory events in the current scan. +

+ +
{{scanLogs()}}
+} + +@if (scanErrorMessages()) { +

+ Scan Errors +

+ +

+ Below is a list of all errors that have occured during the current scan. + In most cases, these simply affect a single file and the rest of the directory will process normally. +

+ +
{{scanErrorMessages()}}
+} diff --git a/osse-web/src/app/settings/settings-scan/settings-scan.component.ts b/osse-web/src/app/settings/settings-scan/settings-scan.component.ts new file mode 100644 index 0000000..42f5c38 --- /dev/null +++ b/osse-web/src/app/settings/settings-scan/settings-scan.component.ts @@ -0,0 +1,213 @@ +import { Component, computed, OnDestroy, OnInit, signal } from '@angular/core'; +import { HeaderComponent } from '../../shared/ui/header/header.component'; +import { ScanChannels, ScanDirectory, ScanDirectoryStatus } from '../../shared/services/echo/channels/scan'; +import { merge, Subscription } from 'rxjs'; +import { EchoService } from '../../shared/services/echo/echo.service'; +import { ToastService } from '../../toast-container/toast.service'; +import { fetcher } from '../../shared/util/fetcher'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-settings-scan', + imports: [HeaderComponent, CommonModule, FormsModule], + templateUrl: './settings-scan.component.html', + styles: `` +}) +export class SettingsScanComponent implements OnInit, OnDestroy { + public fetchingScanStatus = signal(true); + + public rootDirectories = signal([]); + public waitingForScanConfirmation = signal(false); + + public scanInProgress = signal(false); + public scanProgress = signal([]); + + public waitingForCancelConfirmation = signal(false); + + public scanErrorMessages = signal(""); + public scanCompleted = signal(false); + public scanLogs = signal(''); + + public freshScan = false; + /** + * Counts how many directories are in a complete state (scanned/errored) + */ + public amountOfDirectoriesComplete = computed(() => { + return this.scanProgress().filter((d) => d.status == ScanDirectoryStatus.Scanned || d.status == ScanDirectoryStatus.Errored).length; + }); + /** + * Percent of how many directories are in a complete state (scanned/errored) + */ + public percentOfScanComplete = computed(() => { + let dirs = this.scanProgress(); + // Ensure there's at least 1 directory to avoid division by zero + if (dirs.length === 0) return 0; + + const completeDirs = dirs.filter((d) => d.status == ScanDirectoryStatus.Scanned || d.status == ScanDirectoryStatus.Errored); + + return Math.floor((completeDirs.length / dirs.length) * 100); + }); + /** + * Counts the total amount of directories to scan. + */ + public totalAmountOfDirectoriesToScan = computed(() => this.scanProgress().length); + + constructor(private echoService: EchoService, private notificationService: ToastService) { } + + /** + * This subscription links the child subscriptions and allows unsubscribing from them all at once on deInit. + */ + private subscription!: Subscription; + + public async requestScan() { + this.waitingForScanConfirmation.set(true); + + let scanURL = this.freshScan ? 'scan/fresh' : 'scan'; + + let req = await fetcher(scanURL, { + method: 'POST' + }); + + if (!req.ok) { + this.notificationService.error('Failed to start scan. Please check that all directories exist, are readable, free of typos, and mounted (if a network/removeable disk)'); + this.waitingForScanConfirmation.set(false); + } + } + + public async cancelScan() { + this.waitingForCancelConfirmation.set(true); + + fetcher('scan/cancel', { + method: 'POST' + }); + } + + public dirIsScanning(status: ScanDirectoryStatus) { + return status == ScanDirectoryStatus.Scanning; + } + + public dirScanned(status: ScanDirectoryStatus) { + return status == ScanDirectoryStatus.Scanned || status == ScanDirectoryStatus.Errored; + } + + public dirScannedOrScanning(status: ScanDirectoryStatus) { + return this.dirScanned(status) || status == ScanDirectoryStatus.Scanning; + } + + public getScanColor(status: ScanDirectoryStatus) { + switch (status) { + case ScanDirectoryStatus.Scanning: + return "aqua"; + case ScanDirectoryStatus.Scanned: + return "green"; + case ScanDirectoryStatus.Errored: + return "red"; + case ScanDirectoryStatus.Pending: + return "white"; + } + } + + + private async requestScanProgress() { + let req = await fetcher('scan'); + if (req.ok) { + // Set the directories. + let resp = await req.json(); + this.rootDirectories.set(resp.rootDirectories); + + if (resp.active) { + this.scanInProgress.set(true); + this.scanProgress.set(resp.directories); + + let messages = []; + // Generate the scan log. + for (const dir of resp.directories) { + if (dir.status != 'scanned') { + messages.push(`${dir.path} has a status of ${dir.status}`); + } else { + messages.push(`Scanned ${dir.files_scanned} files and skipped ${dir.files_skipped} in ${dir.path} - Status ${dir.status}`); + } + } + + this.scanLogs.set(messages.join('\n')); + } + + // If a scan is active, set that status. + } else { + this.notificationService.error('Something went wrong when requesting the scan status. Try reloading the page.') + } + + this.fetchingScanStatus.set(false); + } + + async ngOnInit(): Promise { + // Request scan progress, but don't wait for it. + this.requestScanProgress(); + + const scanStarted$ = this.echoService.subscribeToEvent(ScanChannels.ScanStarted, (data) => { + this.notificationService.info(`Started scanning ${data.directories.length} directories.`); + this.waitingForScanConfirmation.set(false); + this.scanProgress.set(data.directories); + this.scanInProgress.set(true); + this.scanCompleted.set(false); + this.scanLogs.set('Scan Started...'); + }); + const scanProgressed$ = this.echoService.subscribeToEvent(ScanChannels.ScanProgressed, (data) => { + this.scanProgress.update((scanProgress) => { + // On progress update, set the new progress. There should always be a match + let indexOfProgressDir = -1; + return scanProgress.map((dir, index) => { + if (dir.id == data.directoryID) { + dir.status = data.status; + dir.filesScanned = data.filesScanned; + dir.filesSkipped = data.filesSkipped; + indexOfProgressDir = index; + return dir; + } + + // If its not the match, check if its the item after the match. + // If so, set the scanning status. + if (indexOfProgressDir != -1 && indexOfProgressDir + 1 == index) { + dir.status = ScanDirectoryStatus.Scanning; + } + + return dir; + }); + }) + + this.scanLogs.update((l) => l + `\nScanned dir ${data.directoryName} with ${data.filesScanned} files scanned and ${data.filesSkipped} files skipped.`); + }); + const scanCompleted$ = this.echoService.subscribeToEvent(ScanChannels.ScanCompleted, (data) => { + this.notificationService.info(`Finished scanning ${data.directoryCount} directories.`) + this.scanInProgress.set(false); + this.scanProgress.set([]); + this.scanCompleted.set(true); + this.scanLogs.update((l) => l + '\nScan Complete...'); + }); + + const scanError$ = this.echoService.subscribeToEvent(ScanChannels.ScanError, (data) => { + this.notificationService.error('A scan error has occured. Continuing...'); + this.scanErrorMessages.update((e) => e + data.message + '\n'); + }); + const scanFailed$ = this.echoService.subscribeToEvent(ScanChannels.ScanFailed, (data) => { + this.notificationService.error('Scan Failed! The scan will be cancelled at the current directory.'); + this.scanErrorMessages.update((e) => e + data.message + '\n'); + this.scanInProgress.set(false); + this.scanLogs.update((l) => l + '\nScan Failed...'); + }); + const scanCancelled$ = this.echoService.subscribeToEvent(ScanChannels.ScanCancelled, (data) => { + this.notificationService.info(`Scan has been cancelled. ${data.directoriesScannedBeforeCancellation} directories were scanned in.`); + this.scanInProgress.set(false); + this.waitingForCancelConfirmation.set(false); + this.scanProgress.set([]); + this.scanLogs.update((l) => l + '\nScan Cancelled...'); + }); + + this.subscription = merge(scanStarted$, scanProgressed$, scanCompleted$, scanError$, scanFailed$, scanCancelled$).subscribe(); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } +} diff --git a/osse-web/src/app/settings/settings.component.html b/osse-web/src/app/settings/settings.component.html new file mode 100644 index 0000000..fb11ff5 --- /dev/null +++ b/osse-web/src/app/settings/settings.component.html @@ -0,0 +1,42 @@ + + +
+ +
+ + + + +
+ @if (activeTab() === 'scan') { +
+ +
+ } @else if (activeTab() === 'scan-history') { + + } @else if (activeTab() === 'preferences') { +
+ +
+ } @else if (activeTab() === 'logs') { +
+ +
+ } +
diff --git a/osse-web/src/app/settings/settings.component.ts b/osse-web/src/app/settings/settings.component.ts new file mode 100644 index 0000000..5939ea7 --- /dev/null +++ b/osse-web/src/app/settings/settings.component.ts @@ -0,0 +1,42 @@ +import { ChangeDetectionStrategy, Component, ElementRef, OnInit, ViewChild, WritableSignal, signal } from '@angular/core'; +import { HeaderComponent } from '../shared/ui/header/header.component'; +import { ToastService } from '../toast-container/toast.service'; +import { fetcher } from '../shared/util/fetcher'; +import { CommonModule } from '@angular/common'; +import { SettingsLogsComponent } from './settings-logs/settings-logs.component'; +import { SettingsScanComponent } from "./settings-scan/settings-scan.component"; +import { SettingsScanHistoryComponent } from './settings-scan-history/settings-scan-history.component'; +import { SettingsPreferencesComponent } from "./settings-preferences/settings-preferences.component"; + +@Component({ + selector: 'app-settings', + imports: [HeaderComponent, CommonModule, SettingsLogsComponent, SettingsScanComponent, SettingsScanHistoryComponent, SettingsPreferencesComponent], + templateUrl: './settings.component.html', + styles: ``, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SettingsComponent implements OnInit { + @ViewChild('samples') sampleElement!: ElementRef; + public activeTab = signal('scan'); + + public directories: WritableSignal = signal([]); + + constructor( + private notificationService: ToastService, + ) { } + + public async requestSettings() { + let res = await fetcher('config'); + + if (res.ok) { + let response = await res.json(); + this.directories.set(response.directories); + } else { + this.notificationService.error('Failed to reach server. Check that the URL is correct and that the server is running.'); + } + } + + async ngOnInit(): Promise { + await this.requestSettings(); + } +} diff --git a/osse-web/src/app/shared/player/buffer-update.interface.ts b/osse-web/src/app/shared/player/buffer-update.interface.ts new file mode 100644 index 0000000..f9d3101 --- /dev/null +++ b/osse-web/src/app/shared/player/buffer-update.interface.ts @@ -0,0 +1,9 @@ +export interface BufferUpdate { + /** + * Duration from the audio player. + * This is usually inaccurate at the early parts of the song when it is not entirely downloaded. + * It is usually accurate at the end when the entire song is downloaded. + */ + duration: number; + buffered: TimeRanges; +} diff --git a/osse-web/src/app/shared/player/clear-queue-controls/clear-queue-controls.component.html b/osse-web/src/app/shared/player/clear-queue-controls/clear-queue-controls.component.html new file mode 100644 index 0000000..5b0908f --- /dev/null +++ b/osse-web/src/app/shared/player/clear-queue-controls/clear-queue-controls.component.html @@ -0,0 +1,6 @@ +
+ +
diff --git a/osse-web/src/app/shared/player/clear-queue-controls/clear-queue-controls.component.ts b/osse-web/src/app/shared/player/clear-queue-controls/clear-queue-controls.component.ts new file mode 100644 index 0000000..994c5f7 --- /dev/null +++ b/osse-web/src/app/shared/player/clear-queue-controls/clear-queue-controls.component.ts @@ -0,0 +1,20 @@ +import { Component } from '@angular/core'; +import { TrackService } from '../../services/track/track.service'; +import { mdiDeleteSweep } from '@mdi/js'; +import { IconComponent } from '../../ui/icon/icon.component'; + +@Component({ + selector: 'app-clear-queue-controls', + imports: [IconComponent], + templateUrl: './clear-queue-controls.component.html', + styles: `` +}) +export class ClearQueueControlsComponent { + constructor(private trackService: TrackService) { } + + clear = mdiDeleteSweep; + + clearQueue() { + this.trackService.clearTracks(); + } +} diff --git a/osse-web/src/app/shared/player/duration/duration.component.html b/osse-web/src/app/shared/player/duration/duration.component.html new file mode 100644 index 0000000..417d6d3 --- /dev/null +++ b/osse-web/src/app/shared/player/duration/duration.component.html @@ -0,0 +1 @@ +

{{duration()}}

diff --git a/osse-web/src/app/shared/player/duration/duration.component.ts b/osse-web/src/app/shared/player/duration/duration.component.ts new file mode 100644 index 0000000..b6bdf6b --- /dev/null +++ b/osse-web/src/app/shared/player/duration/duration.component.ts @@ -0,0 +1,21 @@ +import { Component, computed } from '@angular/core'; +import { PlayerService } from '../player.service'; +import { getNicelyFormattedTime } from '../../util/time'; + +@Component({ + selector: 'app-duration', + imports: [], + templateUrl: './duration.component.html', + styles: `` +}) +export class DurationComponent { + public duration = computed(() => { + let currentTime = this.playerService.currentTime(); + let totalTime = this.playerService.duration(); + + return getNicelyFormattedTime(currentTime) + ' / ' + getNicelyFormattedTime(totalTime); + }) + + + constructor(private playerService: PlayerService) { } +} diff --git a/osse-web/src/app/shared/player/jump-controls/jump-controls.component.html b/osse-web/src/app/shared/player/jump-controls/jump-controls.component.html new file mode 100644 index 0000000..e3ea2e2 --- /dev/null +++ b/osse-web/src/app/shared/player/jump-controls/jump-controls.component.html @@ -0,0 +1,18 @@ +
+ + + + +
diff --git a/osse-web/src/app/shared/player/jump-controls/jump-controls.component.ts b/osse-web/src/app/shared/player/jump-controls/jump-controls.component.ts new file mode 100644 index 0000000..4134d92 --- /dev/null +++ b/osse-web/src/app/shared/player/jump-controls/jump-controls.component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { PlayerService } from '../player.service'; +import { IconComponent } from '../../ui/icon/icon.component'; +import { mdiFastForward10, mdiFastForward30, mdiRewind10, mdiRewind30 } from '@mdi/js'; +import { TrackService } from '../../services/track/track.service'; + +@Component({ + selector: 'app-jump-controls', + imports: [IconComponent], + templateUrl: './jump-controls.component.html', + styles: `` +}) +export class JumpControlsComponent { + constructor(private trackService: TrackService, public playerService: PlayerService) { } + + back10 = mdiRewind10; + back30 = mdiRewind30; + forward10 = mdiFastForward10; + forward30 = mdiFastForward30; + + public jump(duration: number, jumpForward: boolean) { + if (this.trackService.activeTrack) { + this.playerService.jumpDuration(duration, jumpForward); + } + } +} diff --git a/osse-web/src/app/shared/player/media-session.service.ts b/osse-web/src/app/shared/player/media-session.service.ts new file mode 100644 index 0000000..7fd9f2d --- /dev/null +++ b/osse-web/src/app/shared/player/media-session.service.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@angular/core'; +import { PlayerService } from './player.service'; +import { TrackService } from '../services/track/track.service'; +import { PlaybackState } from './state-change'; + +@Injectable({ + providedIn: 'root' +}) +export class MediaSessionService { + constructor( + private playerService: PlayerService, + private trackService: TrackService + ) { + if ("mediaSession" in window.navigator) { + try { + this.listenForMediaEvents(); + } catch (error) { } + try { + this.listenForPlayerEvents(); + } catch (error) { } + } + } + + private listenForMediaEvents() { + navigator.mediaSession.setActionHandler("play", () => { + this.playerService.play(); + }); + navigator.mediaSession.setActionHandler("pause", () => { + this.playerService.pause(); + }); + navigator.mediaSession.setActionHandler("stop", () => { + this.playerService.pause(); + }); + navigator.mediaSession.setActionHandler("previoustrack", () => { + this.trackService.moveToLastTrack(); + }); + navigator.mediaSession.setActionHandler("nexttrack", () => { + this.trackService.moveToNextTrack(); + }); + navigator.mediaSession.setActionHandler('seekforward', (ev) => { + this.playerService.jumpDuration(ev.seekOffset || 10, true); + }); + navigator.mediaSession.setActionHandler('seekbackward', (ev) => { + this.playerService.jumpDuration(ev.seekOffset || 10, false); + }); + } + + private listenForPlayerEvents() { + this.playerService.trackUpdated.subscribe((t) => { + navigator.mediaSession.metadata = new MediaMetadata({ + title: t.title, + artist: t.artist?.name ?? 'Unknown Artist', + artwork: [ + { + src: t.cover + } + ] + }) + }); + + this.playerService.stateChanged.subscribe((s) => { + if (s == PlaybackState.Playing) { + navigator.mediaSession.playbackState = "playing"; + } else { + navigator.mediaSession.playbackState = "paused"; + } + }); + + this.playerService.playbackEnded.subscribe((_) => navigator.mediaSession.playbackState = "none"); + } +} diff --git a/osse-web/src/app/shared/player/pan-controls/pan-controls.component.html b/osse-web/src/app/shared/player/pan-controls/pan-controls.component.html new file mode 100644 index 0000000..a398947 --- /dev/null +++ b/osse-web/src/app/shared/player/pan-controls/pan-controls.component.html @@ -0,0 +1,12 @@ +
+ + +
+

L

+ +

R

+
+
diff --git a/osse-web/src/app/shared/player/pan-controls/pan-controls.component.ts b/osse-web/src/app/shared/player/pan-controls/pan-controls.component.ts new file mode 100644 index 0000000..73faf33 --- /dev/null +++ b/osse-web/src/app/shared/player/pan-controls/pan-controls.component.ts @@ -0,0 +1,57 @@ +import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core'; +import { WebAudioService } from '../web-audio.service'; +import { mdiRestart } from '@mdi/js'; +import { IconComponent } from '../../ui/icon/icon.component'; + +@Component({ + selector: 'app-pan-controls', + imports: [IconComponent], + templateUrl: './pan-controls.component.html', + styles: `` +}) +export class PanControlsComponent implements AfterViewInit { + @ViewChild('pan') panInput!: ElementRef; + reset = mdiRestart; + + constructor(private webAudioService: WebAudioService) { } + + setInitialPan(): void { + this.storeAndSetPan(Number(localStorage.getItem('pan') ?? 0)); + this.panInput.nativeElement.value = String(this.webAudioService.getPanValue()); + } + + onPanChange(event: any) { + this.storeAndSetPan(event.target.value); + } + + adjustPanByScroll(event: any) { + event.preventDefault(); + let currentPan = this.webAudioService.getPanValue(); + + let newPan; + if (event.deltaY > 0) { + newPan = Math.max(-1, currentPan - 0.05); + } else { + newPan = Math.min(1, currentPan + 0.05); + } + + this.panInput.nativeElement.value = String(newPan); + this.storeAndSetPan(newPan); + } + + onPanReset() { + this.panInput.nativeElement.value = "0"; + // The event isn't triggered. + this.storeAndSetPan(0); + } + + private storeAndSetPan(pan: number) { + this.webAudioService.setPan(pan); + localStorage.setItem('pan', pan.toString()); + } + + ngAfterViewInit(): void { + this.setInitialPan(); + } +} + diff --git a/osse-web/src/app/shared/player/player.component.css b/osse-web/src/app/shared/player/player.component.css new file mode 100644 index 0000000..628ba36 --- /dev/null +++ b/osse-web/src/app/shared/player/player.component.css @@ -0,0 +1,717 @@ +#track-title-container { + overflow: hidden; /* Hides the scrollbar */ +} + +#track-title-container p { + animation: scroll-text 10s linear infinite; +} + +@keyframes scroll-text { + 0% { + transform: translateX(100%); + } + 100% { + transform: translateX(-100%); + } +} + +/* Animate the point (cursor) movement */ +#point { + transition-duration: 1s; + transition-property: left; +} + +/* + A linear gradient with variables for each percent + CSS variables and properties support transitions in updated browsers +*/ +#rendered { + background: rgb(2,0,36); + background: linear-gradient( + 90deg, + var(--bar-c-0) 0%, var(--bar-c-1) 1%, var(--bar-c-2) 2%, + var(--bar-c-3) 3%, var(--bar-c-4) 4%, var(--bar-c-5) 5%, + var(--bar-c-6) 6%, var(--bar-c-7) 7%, var(--bar-c-8) 8%, + var(--bar-c-9) 9%, var(--bar-c-10) 10%, var(--bar-c-11) 11%, + var(--bar-c-12) 12%, var(--bar-c-13) 13%, var(--bar-c-14) 14%, + var(--bar-c-15) 15%, var(--bar-c-16) 16%, var(--bar-c-17) 17%, + var(--bar-c-18) 18%, var(--bar-c-19) 19%, var(--bar-c-20) 20%, + var(--bar-c-21) 21%, var(--bar-c-22) 22%, var(--bar-c-23) 23%, + var(--bar-c-24) 24%, var(--bar-c-25) 25%, var(--bar-c-26) 26%, + var(--bar-c-27) 27%, var(--bar-c-28) 28%, var(--bar-c-29) 29%, + var(--bar-c-30) 30%, var(--bar-c-31) 31%, var(--bar-c-32) 32%, + var(--bar-c-33) 33%, var(--bar-c-34) 34%, var(--bar-c-35) 35%, + var(--bar-c-36) 36%, var(--bar-c-37) 37%, var(--bar-c-38) 38%, + var(--bar-c-39) 39%, var(--bar-c-40) 40%, var(--bar-c-41) 41%, + var(--bar-c-42) 42%, var(--bar-c-43) 43%, var(--bar-c-44) 44%, + var(--bar-c-45) 45%, var(--bar-c-46) 46%, var(--bar-c-47) 47%, + var(--bar-c-48) 48%, var(--bar-c-49) 49%, var(--bar-c-50) 50%, + var(--bar-c-51) 51%, var(--bar-c-52) 52%, var(--bar-c-53) 53%, + var(--bar-c-54) 54%, var(--bar-c-55) 55%, var(--bar-c-56) 56%, + var(--bar-c-57) 57%, var(--bar-c-58) 58%, var(--bar-c-59) 59%, + var(--bar-c-60) 60%, var(--bar-c-61) 61%, var(--bar-c-62) 62%, + var(--bar-c-63) 63%, var(--bar-c-64) 64%, var(--bar-c-65) 65%, + var(--bar-c-66) 66%, var(--bar-c-67) 67%, var(--bar-c-68) 68%, + var(--bar-c-69) 69%, var(--bar-c-70) 70%, var(--bar-c-71) 71%, + var(--bar-c-72) 72%, var(--bar-c-73) 73%, var(--bar-c-74) 74%, + var(--bar-c-75) 75%, var(--bar-c-76) 76%, var(--bar-c-77) 77%, + var(--bar-c-78) 78%, var(--bar-c-79) 79%, var(--bar-c-80) 80%, + var(--bar-c-81) 81%, var(--bar-c-82) 82%, var(--bar-c-83) 83%, + var(--bar-c-84) 84%, var(--bar-c-85) 85%, var(--bar-c-86) 86%, + var(--bar-c-87) 87%, var(--bar-c-88) 88%, var(--bar-c-89) 89%, + var(--bar-c-90) 90%, var(--bar-c-91) 91%, var(--bar-c-92) 92%, + var(--bar-c-93) 93%, var(--bar-c-94) 94%, var(--bar-c-95) 95%, + var(--bar-c-96) 96%, var(--bar-c-97) 97%, var(--bar-c-98) 98%, + var(--bar-c-99) 99% + ); + + transition: 500ms ease-in; + transition-property: + --bar-c-0, --bar-c-1, --bar-c-2, + --bar-c-3, --bar-c-4, --bar-c-5, + --bar-c-6, --bar-c-7, --bar-c-8, + --bar-c-9, --bar-c-10, --bar-c-11, + --bar-c-12, --bar-c-13, --bar-c-14, + --bar-c-15, --bar-c-16, --bar-c-17, + --bar-c-18, --bar-c-19, --bar-c-20, + --bar-c-21, --bar-c-22, --bar-c-23, + --bar-c-24, --bar-c-25, --bar-c-26, + --bar-c-27, --bar-c-28, --bar-c-29, + --bar-c-30, --bar-c-31, --bar-c-32, + --bar-c-33, --bar-c-34, --bar-c-35, + --bar-c-36, --bar-c-37, --bar-c-38, + --bar-c-39, --bar-c-40, --bar-c-41, + --bar-c-42, --bar-c-43, --bar-c-44, + --bar-c-45, --bar-c-46, --bar-c-47, + --bar-c-48, --bar-c-49, --bar-c-50, + --bar-c-51, --bar-c-52, --bar-c-53, + --bar-c-54, --bar-c-55, --bar-c-56, + --bar-c-57, --bar-c-58, --bar-c-59, + --bar-c-60, --bar-c-61, --bar-c-62, + --bar-c-63, --bar-c-64, --bar-c-65, + --bar-c-66, --bar-c-67, --bar-c-68, + --bar-c-69, --bar-c-70, --bar-c-71, + --bar-c-72, --bar-c-73, --bar-c-74, + --bar-c-75, --bar-c-76, --bar-c-77, + --bar-c-78, --bar-c-79, --bar-c-80, + --bar-c-81, --bar-c-82, --bar-c-83, + --bar-c-84, --bar-c-85, --bar-c-86, + --bar-c-87, --bar-c-88, --bar-c-89, + --bar-c-90, --bar-c-91, --bar-c-92, + --bar-c-93, --bar-c-94, --bar-c-95, + --bar-c-96, --bar-c-97, --bar-c-98, + --bar-c-99; + } + +@property --point-played { + syntax: ""; + inherits: false; + initial-value: rgb(110, 231, 183); +} + +@property --point-buffered { + syntax: ""; + inherits: false; + initial-value: rgb(156, 163, 175); +} + +/* Define properties */ +@property --bar-c-0 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-1 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-2 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-3 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-4 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-5 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-6 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-7 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-8 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-9 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-10 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-11 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-12 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-13 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-14 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-15 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-16 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-17 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-18 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-19 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-20 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-21 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-22 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-23 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-24 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-25 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-26 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-27 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-28 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-29 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-30 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-31 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-32 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-33 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-34 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-35 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-36 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-37 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-38 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-39 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-40 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-41 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-42 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-43 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-44 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-45 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-46 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-47 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-48 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-49 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-50 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-51 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-52 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-53 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-54 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-55 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-56 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-57 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-58 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-59 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-60 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-61 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-62 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-63 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-64 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-65 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-66 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-67 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-68 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-69 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-70 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-71 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-72 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-73 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-74 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-75 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-76 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-77 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-78 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-79 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-80 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-81 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-82 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-83 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-84 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-85 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-86 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-87 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-88 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-89 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-90 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-91 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-92 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-93 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-94 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-95 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-96 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-97 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-98 { + syntax: ''; + inherits: false; + initial-value: transparent; + } + + @property --bar-c-99 { + syntax: ''; + inherits: false; + initial-value: transparent; + } diff --git a/osse-web/src/app/shared/player/player.component.html b/osse-web/src/app/shared/player/player.component.html new file mode 100644 index 0000000..f79a915 --- /dev/null +++ b/osse-web/src/app/shared/player/player.component.html @@ -0,0 +1,55 @@ +
+
+
+
+ + + +
+
+

+ {{trackTitle()}} +

+
+

{{artistTitle()}}

+
+
+
+
+
+ +
+
+ +
+
+
+
+

{{ currentTime() || '0:00' }}

+
+
+
+ +
+
+
+

{{ totalDuration() || '0:00' }}

+
+ +
+
+ + + +
diff --git a/osse-web/src/app/shared/player/player.component.ts b/osse-web/src/app/shared/player/player.component.ts new file mode 100644 index 0000000..da0c547 --- /dev/null +++ b/osse-web/src/app/shared/player/player.component.ts @@ -0,0 +1,194 @@ +import { AfterViewInit, Component, ElementRef, signal, ViewChild, WritableSignal } from '@angular/core'; +import { PlayerService } from './player.service'; +import { PointState } from './point-state'; +import { RouterLink } from '@angular/router'; +import { IconComponent } from '../ui/icon/icon.component'; +import { mdiDotsVertical } from '@mdi/js'; +import { BufferUpdate } from './buffer-update.interface'; +import { getNicelyFormattedTime } from '../util/time'; +import { MediaSessionService } from './media-session.service'; +import { PopoverControlsComponent } from './popover-controls/popover-controls.component'; +import { CommonModule } from '@angular/common'; +import { TrackControlsComponent } from './track-controls/track-controls.component'; +import { PlayPauseComponent } from './track-controls/play-pause/play-pause.component'; + +@Component({ + selector: 'app-player', + imports: [PopoverControlsComponent, TrackControlsComponent, PlayPauseComponent, IconComponent, RouterLink, CommonModule], + templateUrl: './player.component.html', + styleUrl: `./player.component.css` +}) +export class PlayerComponent implements AfterViewInit { + @ViewChild('progressContainer') container!: ElementRef; + @ViewChild('point') point!: ElementRef; + @ViewChild('rendered') rendered!: ElementRef; + @ViewChild('trackTitleElement') trackTitleElement!: ElementRef; + @ViewChild('popoverControls') popoverControls!: ElementRef; + + public bg: WritableSignal = signal("assets/img/osse.webp"); + public currentTime: WritableSignal = signal(''); + public totalDuration: WritableSignal = signal(''); + public trackTitle: WritableSignal = signal(''); + public artistTitle: WritableSignal = signal(''); + private isDragging = false; + private abortMouseMove = new AbortController(); + private seekDuration = 0; + private resizeTimer = 0; + + verticalDots = mdiDotsVertical; + + constructor( + public playerService: PlayerService, + private mediaSessionService: MediaSessionService + ) { + // Make sure the mouse up is accessible in global contexts + this.onMouseUp = this.onMouseUp.bind(this); + this.onMouseMove = this.onMouseMove.bind(this); + + // Listen for the resize event + window.addEventListener('resize', () => { + this.queueResizeCheck(); + }) + } + + private queueResizeCheck() { + clearTimeout(this.resizeTimer); + this.resizeTimer = setTimeout(() => { + this.setTitleAnimationByScreenSize(); + }, 150); + } + + onBackdropClick(ev: any) { + if (ev.target.id == 'popover') { + this.popoverControls.nativeElement.close(); + } + } + + onMouseDown(ev: MouseEvent) { + this.isDragging = true; + ev.preventDefault(); + + this.setPointState(PointState.Pause); + + document.addEventListener('mousemove', this.onMouseMove, { signal: this.abortMouseMove.signal }); + document.addEventListener('mouseup', this.onMouseUp, { once: true }); + } + + onMouseMove(ev: MouseEvent) { + if (this.isDragging) { + const progressBarRect = this.container.nativeElement.getBoundingClientRect(); + const progressBarWidth = progressBarRect.width; + const newPositionX = ev.clientX - progressBarRect.left; + + // Ensure the new position is within the bounds of the progress bar + const clampedPositionX = Math.max(0, Math.min(progressBarWidth, newPositionX)); + + // Update the position of the progress point + this.point.nativeElement.style.left = clampedPositionX + 'px'; + this.seekDuration = (clampedPositionX / progressBarWidth); + } + } + + onMouseUp(_ev: any) { + this.abortMouseMove.abort(); + this.abortMouseMove = new AbortController(); + this.setPointState(PointState.Play); + this.playerService.play(this.seekDuration * this.playerService.duration()); + this.isDragging = false; + } + + onSetPosition(ev: MouseEvent) { + this.onMouseDown(ev); + this.onMouseMove(ev); + } + + setPointState(state: PointState) { + switch (state) { + case PointState.Pause: + this.point.nativeElement.classList.add('paused'); + this.point.nativeElement.classList.remove('playing'); + break; + case PointState.Play: + this.point.nativeElement.classList.add('playing'); + this.point.nativeElement.classList.remove('paused'); + break; + default: + break; + } + } + + setGradient(start: number, color: string, end?: number) { + if (end == undefined) { + this.rendered.nativeElement.style.setProperty('--bar-c-' + start, color); + } else { + for (let i = start; i < end; i++) { + this.rendered.nativeElement.style.setProperty('--bar-c-' + i, color); + } + } + } + + private onBufferProgress(bufferUpdate: BufferUpdate) { + const { duration, buffered } = bufferUpdate; + + this.point.nativeElement.style.animationDuration = duration + "s"; + + if (duration > 0) { + for (let i = 0; i < buffered.length; i++) { + let start = buffered.start(i) / duration * 100; + let end = buffered.end(i) / duration * 100; + this.setGradient(Math.floor(start), "var(--point-buffered)", Math.floor(end)); + } + } + } + + private setTitleAnimationByScreenSize() { + if (this.trackTitleElement.nativeElement.offsetWidth < this.trackTitleElement.nativeElement.parentElement!.offsetWidth) { + // Pause, set text to be visible + this.trackTitleElement.nativeElement.style.animationPlayState = 'paused'; + let anim = this.trackTitleElement.nativeElement.getAnimations()[0]; + let duration = anim.effect!.getTiming().duration; + anim.currentTime = (duration as number) / 2; + } else { + this.trackTitleElement.nativeElement.style.animationPlayState = 'running'; + } + } + + /** + * When the audio player is fully loaded, send the audio element to player service + */ + ngAfterViewInit(): void { + this.playerService.trackUpdated.subscribe((val) => { + this.totalDuration.set(val.durationFormatted); + this.trackTitle.set(val.title); + this.artistTitle.set(val.artist?.name ?? ''); + // Set the cover bg + this.bg.set(val.cover); + this.setTitleAnimationByScreenSize(); + this.setGradient(0, "transparent", 100); + this.queueResizeCheck(); + }); + + this.playerService.trackPositionUpdate.subscribe((val) => { + this.currentTime.set(getNicelyFormattedTime(val.currentTimeSeconds)) + + // If the user is not seeking, update the position + if (!this.isDragging) { + this.point.nativeElement.style.left = Math.floor((val.currentTimeSeconds / val.totalTimeSeconds) * 100) + "%"; + } + + // Set the duration as we may have a more accurate total duration. + this.totalDuration.set(getNicelyFormattedTime(val.totalTimeSeconds)); + }); + + this.playerService.playbackEnded.subscribe(_ => { + this.totalDuration.set(''); + this.currentTime.set(''); + this.trackTitle.set(''); + this.artistTitle.set(''); + this.bg.set('assets/img/osse.webp'); + this.setGradient(0, "transparent", 100); + }); + + this.playerService.bufferUpdated.subscribe((ev) => this.onBufferProgress(ev)); + } +} diff --git a/osse-web/src/app/shared/player/player.service.ts b/osse-web/src/app/shared/player/player.service.ts new file mode 100644 index 0000000..812f761 --- /dev/null +++ b/osse-web/src/app/shared/player/player.service.ts @@ -0,0 +1,220 @@ +import { EventEmitter, Injectable, signal, WritableSignal } from '@angular/core'; +import { Track } from '../services/track/track'; +import { TrackPlayerInfo, TrackUpdate } from './track-update'; +import { PlaybackState } from './state-change'; +import { ConfigService } from '../services/config/config.service'; +import { BackgroundImageService } from '../ui/background-image.service'; +import { BufferUpdate } from './buffer-update.interface'; +import { TrackPosition } from './track-position.interface'; +import { WebAudioService } from './web-audio.service'; +import { ToastService } from '../../toast-container/toast.service'; +import { fetcher } from '../util/fetcher'; + +@Injectable({ + providedIn: 'root' +}) +export class PlayerService { + /** + * Runs whenever a track is changed. + * This could be a new track, or just loading more buffer data + */ + public trackUpdated = new EventEmitter(); + public trackPositionUpdate = new EventEmitter(); + public stateChanged = new EventEmitter(); + public playbackEnded = new EventEmitter(); + public bufferUpdated = new EventEmitter(); + private audioPlayer = new Audio(); + private track!: Track | null; + private playbackRate: number = 1; + + private durationSignal: WritableSignal = signal(0); + private currenTimeSignal: WritableSignal = signal(0); + private isPlayingSignal: WritableSignal = signal(false); + + constructor( + private configService: ConfigService, + private backgroundImageService: BackgroundImageService, + private webAudioService: WebAudioService, + private notificationService: ToastService + ) { + // Set up web audio + // Cross origin is anonymous becuase it is a different origin, but we don't use credentials (cookies). + this.audioPlayer.crossOrigin = "anonymous"; + this.webAudioService.setUp(this.audioPlayer); + + this.audioPlayer.addEventListener('timeupdate', (_ev) => { + this.currenTimeSignal.set(this.audioPlayer.currentTime); + this.trackPositionUpdate.emit({ + currentTimeSeconds: this.audioPlayer.currentTime, + totalTimeSeconds: Math.max(this.track?.duration ?? 0, isNaN(this.audioPlayer.duration) ? 0 : this.audioPlayer.duration) + }); + }); + + this.audioPlayer.addEventListener('play', (_ev) => { + this.isPlayingSignal.set(true) + this.stateChanged.emit(PlaybackState.Playing); + this.webAudioService.resumeIfSuspended(); + }); + this.audioPlayer.addEventListener('pause', (_ev) => { + this.isPlayingSignal.set(false); + this.stateChanged.emit(PlaybackState.Paused); + }); + this.audioPlayer.addEventListener('ended', (_ev) => { + this.isPlayingSignal.set(false); + this.playbackEnded.emit(); + }); + this.audioPlayer.addEventListener('progress', (_ev) => { + this.durationSignal.update((oldDuration: number) => { + if (isNaN(this.audioPlayer.duration)) { + return oldDuration; + } + + return Math.max(oldDuration, this.audioPlayer.duration); + }); + + this.bufferUpdated.emit({ duration: this.duration(), buffered: this.audioPlayer.buffered }) + }); + this.audioPlayer.addEventListener('error', (_ev) => { + this.notificationService.error('An error occurred while loading the audio file.'); + }); + + this.audioPlayer.preload = "metadata"; + + this.playbackRate = Number(localStorage.getItem('speed') ?? 1); + this.audioPlayer.volume = Number(localStorage.getItem('volume') ?? 1); + } + + public async setTrack(track: Track) { + // Set next track + this.track = track; + + // Set the real duration. Used for calculating buffer percentages later. + // Not all formats list the end duration at the start of the track + this.durationSignal.set(track.duration); + + // Get a token to access the file from the file server. + let req = await fetcher('tracks/' + track.id + '/stream?v=' + track.scannedAt); + if (req.ok) { + this.trackUpdated.emit(new TrackUpdate(this.track, this.buildTrackInfo())); + document.title = "Osse - " + this.track.title; + + let res = await req.json(); + let token = res.token; + let url = res.url; + + this.audioPlayer.src = url + '?token=' + token + '&id=' + this.configService.get('userID') + '&trackID=' + track.id; + // The playback rate is reset when a new track is loaded, set it again. + this.audioPlayer.playbackRate = this.playbackRate; + } else { + this.notificationService.error('Failed to play track.'); + } + } + + public async setTrackAndPlay(track: Track, duration: number = 0) { + await this.setTrack(track); + await this.play(); + + // We do this last. It may slow down the player if it is first since it makes a network request. + // Browsers are async, but our server isn't (yet). + this.setBackgroundImage(); + } + + public setTrackAndBackgroundImage(track: Track) { + this.setTrack(track); + this.setBackgroundImage(); + } + + public setDuration(duration: number) { + this.audioPlayer.currentTime = duration; + } + + private setBackgroundImage() { + this.backgroundImageService.setBG(this.track!.coverURL); + } + + public play(time: number = this.audioPlayer.currentTime) { + this.audioPlayer.currentTime = time; + return new Promise((resolve) => { + this.audioPlayer.play() + .then(resolve) + .catch(resolve); + }); + } + + public pause() { + this.audioPlayer.pause(); + } + + public toggle() { + if (this.isPlayingSignal()) { + this.pause(); + } else { + this.play(); + } + } + + private buildTrackInfo(): TrackPlayerInfo { + return { + time: this.audioPlayer.currentTime, + totalDurationEstimate: this.audioPlayer.duration + } + } + + public clearTrack() { + this.audioPlayer.removeAttribute('src'); + this.audioPlayer.currentTime = 0; + this.track = null; + this.isPlayingSignal.set(false); + this.playbackEnded.emit(); + } + + public setVolume(vol: number) { + this.audioPlayer.volume = vol; + } + + public getVolume(): number { + return this.audioPlayer.volume; + } + + public setSpeed(speed: number) { + this.playbackRate = speed; + this.audioPlayer.playbackRate = this.playbackRate; + } + + public getSpeed() { + return this.playbackRate; + } + + /** + * Skips forard or back in a song. + * Handles going past the end or beggining of the song + */ + public jumpDuration(duration: number, jumpForward = true) { + if (jumpForward) { + this.seek(this.audioPlayer.currentTime + duration); + } else { + this.seek(this.audioPlayer.currentTime - duration); + } + } + + private seek(duration: number) { + // @ts-ignore This is valid because browser support for this function isn't good + if (this.audioPlayer.fastSeek) { + this.audioPlayer.fastSeek(duration); + } else { + this.audioPlayer.currentTime = duration; + } + } + + get duration() { + return this.durationSignal.asReadonly(); + } + + get currentTime() { + return this.currenTimeSignal.asReadonly(); + } + + get isPlaying() { + return this.isPlayingSignal.asReadonly(); + } +} diff --git a/osse-web/src/app/shared/player/point-state.ts b/osse-web/src/app/shared/player/point-state.ts new file mode 100644 index 0000000..a84b631 --- /dev/null +++ b/osse-web/src/app/shared/player/point-state.ts @@ -0,0 +1,4 @@ +export enum PointState { + Play, + Pause +} \ No newline at end of file diff --git a/osse-web/src/app/shared/player/popover-controls/popover-controls.component.html b/osse-web/src/app/shared/player/popover-controls/popover-controls.component.html new file mode 100644 index 0000000..9631f05 --- /dev/null +++ b/osse-web/src/app/shared/player/popover-controls/popover-controls.component.html @@ -0,0 +1,40 @@ +
+

Advanced Controls

+ +
+
+

Duration

+ +
+ +
+

Rewind/Fast Forward

+ +
+ +
+

Volume

+ +
+ +
+

Pan (L/R)

+ +
+ +
+

Speed (0-2)

+ +
+ +
+

Track Controls

+ +
+
+

Clear Queue

+ +
+
+
diff --git a/osse-web/src/app/shared/player/popover-controls/popover-controls.component.ts b/osse-web/src/app/shared/player/popover-controls/popover-controls.component.ts new file mode 100644 index 0000000..c7f4aee --- /dev/null +++ b/osse-web/src/app/shared/player/popover-controls/popover-controls.component.ts @@ -0,0 +1,21 @@ +import { Component, output } from '@angular/core'; +import { VolumeComponent } from '../volume/volume.component'; +import { TrackControlsComponent } from '../track-controls/track-controls.component'; +import { DurationComponent } from '../duration/duration.component'; +import { IconComponent } from '../../ui/icon/icon.component'; +import { mdiClose } from '@mdi/js'; +import { JumpControlsComponent } from '../jump-controls/jump-controls.component'; +import { PanControlsComponent } from '../pan-controls/pan-controls.component'; +import { SpeedControlsComponent } from '../speed-controls/speed-controls.component'; +import { ClearQueueControlsComponent } from "../clear-queue-controls/clear-queue-controls.component"; + +@Component({ + selector: 'app-popover-controls', + imports: [VolumeComponent, TrackControlsComponent, DurationComponent, JumpControlsComponent, PanControlsComponent, SpeedControlsComponent, IconComponent, ClearQueueControlsComponent], + templateUrl: './popover-controls.component.html', + styles: `` +}) +export class PopoverControlsComponent { + public onClose = output(); + close = mdiClose; +} diff --git a/osse-web/src/app/shared/player/preload/preload.service.ts b/osse-web/src/app/shared/player/preload/preload.service.ts new file mode 100644 index 0000000..fe823e5 --- /dev/null +++ b/osse-web/src/app/shared/player/preload/preload.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@angular/core'; +import { PlayerService } from '../player.service'; +import { TrackService } from '../../services/track/track.service'; +import { ConfigService } from '../../services/config/config.service'; +import { fetcher } from '../../util/fetcher'; + +@Injectable({ + providedIn: 'root' +}) +export class PreloadService { + private audioPlayer = new Audio(); + private isPreloadingTrack = false; + + constructor(private playerService: PlayerService, private trackService: TrackService, private configService: ConfigService) { + // Setup the preload element. + this.audioPlayer.muted = true; + this.audioPlayer.autoplay = false; + this.audioPlayer.preload = 'metadata'; + this.audioPlayer.crossOrigin = "anonymous"; + this.audioPlayer.addEventListener('loadedmetadata', () => { + this.audioPlayer.pause(); + }); + + this.playerService.trackPositionUpdate.subscribe((t) => { + // Only check preload if we are not preloading something. + if (this.isPreloadingTrack) { + return; + } + + // If we are 80% through the track, start next preload. + if ((t.currentTimeSeconds / t.totalTimeSeconds) >= 0.8) { + this.preloadNextTrack(); + } + }); + + // On track update, we mark the preload element as able to preload a new track. + this.playerService.trackUpdated.subscribe((t) => { + this.isPreloadingTrack = false; + }); + } + + private async preloadNextTrack() { + this.isPreloadingTrack = true; + + // Get the next track (if any) + let track = this.trackService.getUpcomingTrack(); + if (track) { + // Request authorization to preload. + let req = await fetcher('tracks/' + track.id + '/stream?v=' + track.scannedAt); + if (req.ok) { + let res = await req.json(); + let token = res.token; + let url = res.url; + + this.audioPlayer.src = url + '?token=' + token + '&id=' + this.configService.get('userID') + '&trackID=' + track.id; + this.audioPlayer.load(); + } + } + } +} diff --git a/osse-web/src/app/shared/player/speed-controls/speed-controls.component.html b/osse-web/src/app/shared/player/speed-controls/speed-controls.component.html new file mode 100644 index 0000000..5d4d011 --- /dev/null +++ b/osse-web/src/app/shared/player/speed-controls/speed-controls.component.html @@ -0,0 +1,13 @@ +
+ + +
+

0

+ +

2

+
+
diff --git a/osse-web/src/app/shared/player/speed-controls/speed-controls.component.ts b/osse-web/src/app/shared/player/speed-controls/speed-controls.component.ts new file mode 100644 index 0000000..4588157 --- /dev/null +++ b/osse-web/src/app/shared/player/speed-controls/speed-controls.component.ts @@ -0,0 +1,55 @@ +import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core'; +import { mdiRestart } from '@mdi/js'; +import { PlayerService } from '../player.service'; +import { IconComponent } from '../../ui/icon/icon.component'; + +@Component({ + selector: 'app-speed-controls', + imports: [IconComponent], + templateUrl: './speed-controls.component.html', + styles: `` +}) +export class SpeedControlsComponent implements AfterViewInit { + @ViewChild('speed') speedInput!: ElementRef; + reset = mdiRestart; + + constructor(private playerService: PlayerService) { } + + setInitialSpeed(): void { + this.storeAndSetSpeed(Number(localStorage.getItem('speed') ?? 1)); + this.speedInput.nativeElement.value = String(this.playerService.getSpeed()); + } + + onSpeedChange(event: any) { + this.storeAndSetSpeed(event.target.value); + } + + adjustSpeedByScroll(event: any) { + event.preventDefault(); + let currentSpeed = this.playerService.getSpeed(); + + let newSpeed; if (event.deltaY > 0) { + newSpeed = Math.max(0, currentSpeed - 0.1); + } else { + newSpeed = Math.min(2, currentSpeed + 0.1); + } + + this.speedInput.nativeElement.value = String(newSpeed); + this.storeAndSetSpeed(newSpeed); + } + + onSpeedReset() { + this.speedInput.nativeElement.value = "1"; + // The event isn't triggered. + this.storeAndSetSpeed(1); + } + + private storeAndSetSpeed(speed: number) { + this.playerService.setSpeed(speed); + localStorage.setItem('speed', speed.toString()); + } + + ngAfterViewInit(): void { + this.setInitialSpeed(); + } +} diff --git a/osse-web/src/app/shared/player/state-change.ts b/osse-web/src/app/shared/player/state-change.ts new file mode 100644 index 0000000..5cbf2b8 --- /dev/null +++ b/osse-web/src/app/shared/player/state-change.ts @@ -0,0 +1,4 @@ +export enum PlaybackState { + Paused, + Playing +} \ No newline at end of file diff --git a/osse-web/src/app/shared/player/track-controls/play-pause/play-pause.component.html b/osse-web/src/app/shared/player/track-controls/play-pause/play-pause.component.html new file mode 100644 index 0000000..6549638 --- /dev/null +++ b/osse-web/src/app/shared/player/track-controls/play-pause/play-pause.component.html @@ -0,0 +1,3 @@ + diff --git a/osse-web/src/app/shared/player/track-controls/play-pause/play-pause.component.ts b/osse-web/src/app/shared/player/track-controls/play-pause/play-pause.component.ts new file mode 100644 index 0000000..c0d544f --- /dev/null +++ b/osse-web/src/app/shared/player/track-controls/play-pause/play-pause.component.ts @@ -0,0 +1,23 @@ +import { Component, computed } from '@angular/core'; +import { TrackService } from '../../../services/track/track.service'; +import { PlayerService } from '../../player.service'; +import { IconComponent } from '../../../ui/icon/icon.component'; +import { mdiPause, mdiPlay } from '@mdi/js'; + +@Component({ + selector: 'app-play-pause', + imports: [IconComponent], + templateUrl: './play-pause.component.html', + styles: `` +}) +export class PlayPauseComponent { + public playerIcon = computed(() => this.playerService.isPlaying() ? mdiPause : mdiPlay); + + constructor(private trackService: TrackService, private playerService: PlayerService) { } + public onPlayerToggle() { + // If no track, don't respond to button click + if (!this.trackService.activeTrack) return; + + this.playerService.toggle(); + } +} diff --git a/osse-web/src/app/shared/player/track-controls/track-controls.component.html b/osse-web/src/app/shared/player/track-controls/track-controls.component.html new file mode 100644 index 0000000..e9c6029 --- /dev/null +++ b/osse-web/src/app/shared/player/track-controls/track-controls.component.html @@ -0,0 +1,9 @@ +
+ + + +
diff --git a/osse-web/src/app/shared/player/track-controls/track-controls.component.ts b/osse-web/src/app/shared/player/track-controls/track-controls.component.ts new file mode 100644 index 0000000..dff8adf --- /dev/null +++ b/osse-web/src/app/shared/player/track-controls/track-controls.component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { IconComponent } from '../../ui/icon/icon.component'; +import { mdiFastForward, mdiRewind } from '@mdi/js'; +import { TrackService } from '../../services/track/track.service'; +import { PlayPauseComponent } from './play-pause/play-pause.component'; + +@Component({ + selector: 'app-track-controls', + imports: [IconComponent, PlayPauseComponent], + templateUrl: './track-controls.component.html', + styles: `` +}) +export class TrackControlsComponent { + forward = mdiFastForward; + back = mdiRewind; + + constructor(private trackService: TrackService) { } + + public onNextTrack() { + this.trackService.moveToNextTrack(); + } + + public onPreviousTrack() { + this.trackService.moveToLastTrack(); + } +} diff --git a/osse-web/src/app/shared/player/track-position.interface.ts b/osse-web/src/app/shared/player/track-position.interface.ts new file mode 100644 index 0000000..8b3e479 --- /dev/null +++ b/osse-web/src/app/shared/player/track-position.interface.ts @@ -0,0 +1,4 @@ +export interface TrackPosition { + currentTimeSeconds: number; + totalTimeSeconds: number; +} diff --git a/osse-web/src/app/shared/player/track-update.ts b/osse-web/src/app/shared/player/track-update.ts new file mode 100644 index 0000000..d4b4380 --- /dev/null +++ b/osse-web/src/app/shared/player/track-update.ts @@ -0,0 +1,53 @@ +import { Track } from "../services/track/track"; +import { getNicelyFormattedTime } from "../util/time"; + +export class TrackUpdate { + constructor(private track: Track, private info: TrackPlayerInfo) { } + + get totalSeconds() { + return this.track.duration; + } + + get currentSecond() { + return this.info.time; + } + + get title() { + return this.track.title; + } + + get artist() { + if (this.track.hasArtist()) { + return this.track.artistPrimary(); + } + + return null; + } + + get durationFormatted() { + // Sometimes the audio player duration estimate is undetectable. Return the metadata duration in that case. + if (isNaN(this.info.totalDurationEstimate)) { + return getNicelyFormattedTime(this.track.duration + 1); + } else { + // return the metadata duration or the audio duration, whichever is bigger. + return getNicelyFormattedTime(Math.max(this.track.duration + 1, this.info.totalDurationEstimate)); + } + } + + get timeFormatted() { + return getNicelyFormattedTime(this.info.time); + } + + get id() { + return this.track.track.id; + } + + get cover() { + return this.track.coverURL; + } +} + +export interface TrackPlayerInfo { + time: number; + totalDurationEstimate: number; +} diff --git a/osse-web/src/app/shared/player/visualizer/visualizer.component.html b/osse-web/src/app/shared/player/visualizer/visualizer.component.html new file mode 100644 index 0000000..a9baed8 --- /dev/null +++ b/osse-web/src/app/shared/player/visualizer/visualizer.component.html @@ -0,0 +1,2 @@ + + diff --git a/osse-web/src/app/shared/player/visualizer/visualizer.component.ts b/osse-web/src/app/shared/player/visualizer/visualizer.component.ts new file mode 100644 index 0000000..29376ee --- /dev/null +++ b/osse-web/src/app/shared/player/visualizer/visualizer.component.ts @@ -0,0 +1,85 @@ +import { Component, ElementRef, ViewChild, OnInit, OnDestroy } from '@angular/core'; +import { WebAudioService } from '../web-audio.service'; + +@Component({ + selector: 'app-visualizer', + templateUrl: './visualizer.component.html', +}) +export class VisualizerComponent implements OnInit, OnDestroy { + @ViewChild('canvas', { static: true }) canvas!: ElementRef; + private ctx!: CanvasRenderingContext2D; + private animationFrameId!: number; + private width = 0; + private height = 0; + private resizeObserver!: () => void; + private resizeTimeout = 0; + + constructor(private webAudioService: WebAudioService) { } + + ngOnInit() { + this.ctx = this.canvas.nativeElement.getContext('2d')!; + + // Listen for resize events to rescale canvas + this.resizeObserver = () => { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = setTimeout(() => this.resizeCanvas(), 300); + } + window.addEventListener('resize', this.resizeObserver); + window.addEventListener('fullscreenchange', () => this.resizeCanvas()); + + this.resizeCanvas(); + this.drawVisualizer(); + } + + private resizeCanvas() { + const canvas = this.canvas.nativeElement; + const dpr = window.devicePixelRatio || 1; + + // Set canvas size based on client size and DPR + canvas.width = canvas.clientWidth * dpr; + canvas.height = canvas.clientHeight * dpr; + // Store local copies for easier access in drawing functions + this.width = canvas.width * dpr; + this.height = canvas.height * dpr; + + // Scale context so drawing operations match the high resolution + this.ctx.resetTransform(); // Reset to avoid cumulative scaling + this.ctx.scale(dpr, dpr); + } + + private drawVisualizer() { + this.ctx.clearRect(0, 0, this.width, this.height); + + const draw = () => { + this.animationFrameId = requestAnimationFrame(draw); + this.ctx.clearRect(0, 0, this.width, this.height); + + this.drawFrequencyBars(); + }; + draw(); + } + + private drawFrequencyBars() { + const data = this.webAudioService.getFrequencyData(); + const barWidth = this.canvas.nativeElement.width / data.length; + + data.forEach((value, i) => { + const barHeight = (value / 255) * this.canvas.nativeElement.height; + + // Create gradient from bottom (green) to top (light green) + const gradient = this.ctx.createLinearGradient(0, this.canvas.nativeElement.height - barHeight, 0, this.canvas.nativeElement.height); + gradient.addColorStop(0, 'rgb(52, 211, 153)'); // Bottom color + gradient.addColorStop(1, 'rgb(167, 243, 208)'); // Top color + + this.ctx.fillStyle = gradient; + this.ctx.fillRect(i * barWidth, this.canvas.nativeElement.height - barHeight, barWidth, barHeight); + }); + } + + ngOnDestroy() { + cancelAnimationFrame(this.animationFrameId); + clearTimeout(this.resizeTimeout); + window.removeEventListener('resize', this.resizeObserver); + window.removeEventListener('fullscreenchange', () => this.resizeCanvas()); + } +} diff --git a/osse-web/src/app/shared/player/volume/volume.component.html b/osse-web/src/app/shared/player/volume/volume.component.html new file mode 100644 index 0000000..d397860 --- /dev/null +++ b/osse-web/src/app/shared/player/volume/volume.component.html @@ -0,0 +1,8 @@ +
+ + + +
diff --git a/osse-web/src/app/shared/player/volume/volume.component.ts b/osse-web/src/app/shared/player/volume/volume.component.ts new file mode 100644 index 0000000..b31b1b8 --- /dev/null +++ b/osse-web/src/app/shared/player/volume/volume.component.ts @@ -0,0 +1,90 @@ +import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, ViewChild, WritableSignal, signal } from '@angular/core'; +import { PlayerService } from '../player.service'; +import { mdiVolumeOff, mdiVolumeLow, mdiVolumeHigh } from '@mdi/js'; +import { IconComponent } from '../../ui/icon/icon.component'; + + +@Component({ + selector: 'app-volume', + imports: [IconComponent], + templateUrl: './volume.component.html', + styles: ``, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class VolumeComponent implements AfterViewInit { + @ViewChild('volume') volumeInput!: ElementRef; + volumeIcon = signal(mdiVolumeHigh); + public showVolumeMenu: WritableSignal = signal(false); + + constructor(private playerService: PlayerService) { } + + setInitialVolume(): void { + this.storeAndSetVolume(Number(localStorage.getItem('volume') ?? 1)); + this.volumeInput.nativeElement.value = String(this.playerService.getVolume()); + } + + onVolumeChange(event: any) { + this.storeAndSetVolume(event.target.value); + } + + adjustVolumeByScroll(event: any) { + event.preventDefault(); + let currentVolume = this.playerService.getVolume(); + + let newVolume; + if (event.deltaY > 0) { + newVolume = Math.max(0, currentVolume - 0.05); + } else { + newVolume = Math.min(1, currentVolume + 0.05); + } + + this.volumeInput.nativeElement.value = String(newVolume); + this.storeAndSetVolume(newVolume); + this.setVolumeIcon(); + } + + onVolumeSet() { + this.setVolumeIcon(); + this.showVolumeMenu.set(false); + } + + setVolumeIcon() { + let volume = this.playerService.getVolume(); + if (volume == 0) { + this.volumeIcon.set(mdiVolumeOff); + } else { + if (volume <= 0.5) { + this.volumeIcon.set(mdiVolumeLow); + } else { + this.volumeIcon.set(mdiVolumeHigh); + } + } + } + + onMuteToggle() { + let volume = this.playerService.getVolume(); + if (volume == 0) { + this.storeAndSetVolume(0.5); + } else { + this.storeAndSetVolume(0); + } + + this.setVolumeIcon(); + this.volumeInput.nativeElement.value = String(this.playerService.getVolume()); + } + + public toggleMenu() { + this.showVolumeMenu.set(!this.showVolumeMenu()); + } + + private storeAndSetVolume(volume: number) { + this.playerService.setVolume(volume); + localStorage.setItem('volume', volume.toString()); + } + + ngAfterViewInit(): void { + this.playerService.stateChanged.subscribe((_v) => this.setVolumeIcon()); + this.setInitialVolume(); + } +} + diff --git a/osse-web/src/app/shared/player/web-audio.service.ts b/osse-web/src/app/shared/player/web-audio.service.ts new file mode 100644 index 0000000..adc1a36 --- /dev/null +++ b/osse-web/src/app/shared/player/web-audio.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@angular/core'; +import { ConfigService } from '../services/config/config.service'; + +@Injectable({ + providedIn: 'root' +}) +export class WebAudioService { + private audioContext = new AudioContext(); + private panner = this.audioContext.createStereoPanner(); + private analyser = this.audioContext.createAnalyser(); + + constructor(private configService: ConfigService) { } + + public setUp(audioElement: HTMLAudioElement): HTMLAudioElement { + const source = this.audioContext.createMediaElementSource(audioElement); + + source.connect(this.analyser); + // Visualizer + this.analyser.connect(this.panner); + // Panning + this.panner.pan.value = 0; + this.panner.connect(this.audioContext.destination); + return audioElement; + } + + /** + * Call when playback starts. + * Web audio is init before user interaction. Some browsers suspend it until interaction. + * Once audio starts from a user interaction, we can resume it. + */ + public resumeIfSuspended() { + if (this.audioContext.state == 'suspended') { + this.audioContext.resume(); + } + } + + public setPan(pan: number) { + this.panner.pan.value = pan; + } + + public getPanValue(): number { + return this.panner.pan.value; + } + + public getFrequencyData(): Uint8Array { + const smoothFactor = this.configService.get('visualizerSamples'); + const rawData = new Uint8Array(this.analyser.frequencyBinCount); + this.analyser.getByteFrequencyData(rawData); + + // Downsample by averaging every `smoothFactor` values + const filteredData = new Uint8Array(rawData.length / smoothFactor); + for (let i = 0; i < filteredData.length; i++) { + let sum = 0; + for (let j = 0; j < smoothFactor; j++) { + sum += rawData[i * smoothFactor + j]; + } + filteredData[i] = sum / smoothFactor; + } + + return filteredData; + } +} diff --git a/osse-web/src/app/shared/services/album/Album.ts b/osse-web/src/app/shared/services/album/Album.ts new file mode 100644 index 0000000..f1e739e --- /dev/null +++ b/osse-web/src/app/shared/services/album/Album.ts @@ -0,0 +1,52 @@ +import { LocatorService } from "../../../locator.service"; +import { ApiService } from "../api.service"; +import { Artist } from "../artist/artist"; +import { Track } from "../track/track"; +import { OsseAlbum } from "./osse-album"; + +export class Album { + private trackList: Track[] = []; + private artistInfo: Artist[] = []; + private apiService: ApiService = LocatorService.injector.get(ApiService); + + constructor(public album: OsseAlbum) { + album.tracks?.forEach(track => { + this.trackList.push(new Track(track)); + }); + + this.getArtistIfExists(); + } + + public get id() { + return this.album.id; + } + + public get name() { + return this.album.name; + } + + public get tracks() { + return this.trackList; + } + + public get artist() { + return this.artistInfo; + } + + public get year() { + return this.album.year; + } + + private getArtistIfExists() { + // If we loaded the artists, init the Artist classes. + if (this.album.artists != null) { + this.artistInfo = this.album.artists.map((a) => new Artist(a)); + return; + } + + // If artists exist but were not loaded, load them async + for (let artistId of this.album.artist_ids ?? []) { + this.apiService.getArtist(artistId).then(val => this.artistInfo.push(val as Artist)); + } + } +} diff --git a/osse-web/src/app/shared/services/album/osse-album.ts b/osse-web/src/app/shared/services/album/osse-album.ts new file mode 100644 index 0000000..21fa71c --- /dev/null +++ b/osse-web/src/app/shared/services/album/osse-album.ts @@ -0,0 +1,15 @@ +import { OsseArtist } from "../artist/osse-artist"; +import { OsseTrack } from "../track/osse-track"; + +export interface OsseAlbum { + id: number; + name: string; + artist_ids: number[] | null; + tracks: OsseTrack[]; + year: number | null; + + /** + * Artist data. + */ + artists: OsseArtist[] | null; +} diff --git a/osse-web/src/app/shared/services/api.service.ts b/osse-web/src/app/shared/services/api.service.ts new file mode 100644 index 0000000..72d3d24 --- /dev/null +++ b/osse-web/src/app/shared/services/api.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@angular/core'; +import { Artist } from './artist/artist'; +import { fetcher } from '../util/fetcher'; + +@Injectable({ + providedIn: 'root' +}) +export class ApiService { + constructor() { } + + // TODO: Move this to an artist service. + public async getArtist(id: number): Promise { + let request = await fetcher(`artists/${id}`); + if (request.ok) { + let artist = await request.json(); + return new Artist(artist); + } else { + return null; + } + } +} diff --git a/osse-web/src/app/shared/services/artist/artist-store.service.ts b/osse-web/src/app/shared/services/artist/artist-store.service.ts new file mode 100644 index 0000000..efcc110 --- /dev/null +++ b/osse-web/src/app/shared/services/artist/artist-store.service.ts @@ -0,0 +1,39 @@ +import { EventEmitter, Injectable } from '@angular/core'; +import { Artist } from './artist'; + +@Injectable({ + providedIn: 'root' +}) +export class ArtistStoreService { + public artists: Artist[] = []; + public fetchingArists: number[] = []; + public artistFetched = new EventEmitter(); + + constructor() { } + + public getArtistById(id: number) { + return this.artists.find((a) => a.id == id); + } + + public setArtist(artist: Artist) { + if (this.artistIsLoaded(artist.id)) return; + + this.artists.push(artist); + } + + public artistIsLoaded(id: number) { + return this.artists.some((a) => a.id == id); + } + + public addFetchingArtist(id: number) { + this.fetchingArists.push(id); + } + + public removeFetchingArtist(id: number) { + this.fetchingArists = this.fetchingArists.filter((a) => a != id); + } + + public isFetchingArtist(id: number) { + return this.fetchingArists.includes(id); + } +} diff --git a/osse-web/src/app/shared/services/artist/artist.ts b/osse-web/src/app/shared/services/artist/artist.ts new file mode 100644 index 0000000..9e37162 --- /dev/null +++ b/osse-web/src/app/shared/services/artist/artist.ts @@ -0,0 +1,13 @@ +import { OsseArtist } from "./osse-artist"; + +export class Artist { + constructor(private artist: OsseArtist) { } + + public get name() { + return this.artist.name; + } + + public get id() { + return this.artist.id; + } +} diff --git a/osse-web/src/app/shared/services/artist/osse-artist.ts b/osse-web/src/app/shared/services/artist/osse-artist.ts new file mode 100644 index 0000000..1ab7977 --- /dev/null +++ b/osse-web/src/app/shared/services/artist/osse-artist.ts @@ -0,0 +1,4 @@ +export interface OsseArtist { + id: number; + name: string; +} \ No newline at end of file diff --git a/osse-web/src/app/shared/services/auth/auth.guard.ts b/osse-web/src/app/shared/services/auth/auth.guard.ts new file mode 100644 index 0000000..5149c4b --- /dev/null +++ b/osse-web/src/app/shared/services/auth/auth.guard.ts @@ -0,0 +1,20 @@ +import { inject } from '@angular/core'; +import { ActivatedRouteSnapshot, RouterStateSnapshot, Router, CanActivateFn } from '@angular/router'; +import { AuthService } from './auth.service'; + +export const isLoggedIn: CanActivateFn = async ( + _next: ActivatedRouteSnapshot, + state: RouterStateSnapshot +) => { + const authService = inject(AuthService); + const router = inject(Router); + + if (await authService.isAuthenticated()) { + // Allow access if user is logged in + return true; + } else { + // Redirect to login page if not authenticated + router.navigate(['/login'], { queryParams: { returnUrl: state.url } }); + return false; + } +}; diff --git a/osse-web/src/app/shared/services/auth/auth.interface.ts b/osse-web/src/app/shared/services/auth/auth.interface.ts new file mode 100644 index 0000000..7321607 --- /dev/null +++ b/osse-web/src/app/shared/services/auth/auth.interface.ts @@ -0,0 +1,16 @@ +/** + * A successful auth response. + */ +export interface AuthResponse { + /** + * User ID + */ + id: number, + username: string, + settings: UserSettings; +} + +export interface UserSettings { + id: number; + queue: boolean; +} diff --git a/osse-web/src/app/shared/services/auth/auth.service.ts b/osse-web/src/app/shared/services/auth/auth.service.ts new file mode 100644 index 0000000..7764a4e --- /dev/null +++ b/osse-web/src/app/shared/services/auth/auth.service.ts @@ -0,0 +1,112 @@ +import { EventEmitter, Injectable } from '@angular/core'; +import { fetcher } from '../../util/fetcher'; +import { EchoService } from '../echo/echo.service'; +import { AuthResponse } from './auth.interface'; +import { TrackService } from '../track/track.service'; +import { BackgroundImageService } from '../../ui/background-image.service'; +import { ConfigService } from '../config/config.service'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthService { + private isLoggedIn = false; + private statusChecked = false; + public authStateChanged = new EventEmitter(); + + constructor( + private echoService: EchoService, private trackService: TrackService, private backgroundImageService: BackgroundImageService, + private configService: ConfigService) { + // Check if we are logged in by requesting the current user. If this fails, we know we are not logged in. + this.checkLoginStatus(); + } + + /** + * Tries to login. Sets the status accordingly. + */ + public async checkLoginStatus() { + try { + await this.attemptLogin(); + this.statusChecked = true; + } catch (e) { + this.statusChecked = true; + this.isLoggedIn = false; + } + } + + /** + * Attempt to login. This will get a CSRF token and try to login as the user. + * If this fails (csrf or user request), we set as not logged in. + */ + public async attemptLogin() { + let req = await fetcher('user'); + if (req.ok) { + this.login(await req.json()); + } else { + throw "Login failure."; + } + } + + /** + * Checks if the user is authenticated. + * If the request has not been made, it will wait until it is made. + */ + isAuthenticated(): Promise { + return new Promise((resolve, _reject) => { + if (this.statusChecked) { + resolve(this.isLoggedIn); + } + + setTimeout(async () => { + let result = await this.isAuthenticated(); + resolve(result); + }, 1000); + }); + } + + /** + * Call this after logging in. + * The work is already done, this just lets the client routes work and subscribes to events. + */ + private login(userAuth: AuthResponse): void { + this.isLoggedIn = true; + + // Set the config to use any account level config (not in local storage.) + this.configService.overrideConfig({ + queue: userAuth.settings.queue, + userID: userAuth.id, + }); + + // Listen for events. + this.echoService.connect().then((_e) => { + this.echoService.listenForScanStarted(); + this.echoService.listenForScanProgressed(); + this.echoService.listenForScanCompleted(); + this.echoService.listenForScanError(); + this.echoService.listenForScanFailed(); + this.echoService.listenForScanCancelled(); + }).catch(() => { + console.error("Failed to connect to osse-broadcast. Live events will not be received!"); + }); + + // Request queue from server to resume. + if (this.configService.get('queue')) { + this.trackService.fetchQueueFromServer(); + } + + this.authStateChanged.emit(true); + } + + public async logout() { + this.trackService.clearTracks(); + this.backgroundImageService.clearBG(); + this.echoService.disconnect(); + await fetcher('logout', { method: 'POST' }); + // Delete xsrf token. + document.cookie = "XSRF-TOKEN=;expires=" + new Date(0).toUTCString(); + + this.isLoggedIn = false; + this.authStateChanged.emit(false); + } +} + diff --git a/osse-web/src/app/shared/services/config/config.service.ts b/osse-web/src/app/shared/services/config/config.service.ts new file mode 100644 index 0000000..64ccfac --- /dev/null +++ b/osse-web/src/app/shared/services/config/config.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; +import { environment } from '../../../../environments/environment'; +import { OsseConfig } from './config'; + +@Injectable({ + providedIn: 'root' +}) +export class ConfigService { + private config!: OsseConfig; + + constructor() { + // Get the ENV and populate any variables. Localstorage has priority + this.config = { + apiURL: '/api/', + version: environment.version, + showCoverBackgrounds: Boolean(localStorage.getItem('showCoverBackgrounds') ?? environment.showCoverBackgrounds), + showVisualizer: Boolean(localStorage.getItem('showVisualizer') ?? environment.showVisualizer), + visualizerSamples: Number(localStorage.getItem('visualizerSamples') ?? environment.visualizerSamples), + + // Below keys are sources from the server so we give them default values. + // They shouldn't be read until we get the user settings in the login process. + queue: true, + userID: -1, + }; + } + + public get(key: T, defaultVal?: any): OsseConfig[T] { + return this.config[key] ?? defaultVal ?? null; + } + + public save(key: T, val: OsseConfig[T]) { + localStorage.setItem(key, String(val)); + this.config[key] = val; + } + + /** + * Saves many entries into the config. + */ + public saveMany(conf: Partial) { + this.config = { ...this.config, ...conf }; + for (const key in conf) { + if (conf.hasOwnProperty(key)) { + localStorage.setItem(key, String(conf[key as keyof OsseConfig])); + } + } + } + + /** + * Sets all keys/values from an object to the current config. + * Keys that are in the current conf but not in the new one are unmodified. + * + * Used to populate account config. + */ + public overrideConfig(conf: Partial) { + this.config = { ...this.config, ...conf }; + } +} diff --git a/osse-web/src/app/shared/services/config/config.ts b/osse-web/src/app/shared/services/config/config.ts new file mode 100644 index 0000000..2a913ca --- /dev/null +++ b/osse-web/src/app/shared/services/config/config.ts @@ -0,0 +1,30 @@ +export interface OsseConfig { + /** + * Current version of the app. dev or x.x.x + */ + version: string, + /** + * API URL. + */ + apiURL: string; + /** + * Show album/track art in the background on certain pages. + */ + showCoverBackgrounds: boolean; + /** + * Show a music visualizer on the homepage. + */ + showVisualizer: boolean; + /** + * Amount of samples taken for the visualizer. + */ + visualizerSamples: number; + /** + * Enable/disable account queue + */ + queue: boolean; + /** + * ID of the user. + */ + userID: number; +} diff --git a/osse-web/src/app/shared/services/echo/channels/index.ts b/osse-web/src/app/shared/services/echo/channels/index.ts new file mode 100644 index 0000000..b1f2757 --- /dev/null +++ b/osse-web/src/app/shared/services/echo/channels/index.ts @@ -0,0 +1,15 @@ +import { ScanEventMap } from "./scan"; + +// Add to this as new events are created. +export type EchoEventMap = ScanEventMap; + +/** + * Every possible channel to subscribe to. + */ +export type EchoChannel = keyof EchoEventMap; + +/** + * Every possible result for an echo event. + */ +export type EchoResult = EchoEventMap[T]; + diff --git a/osse-web/src/app/shared/services/echo/channels/scan.ts b/osse-web/src/app/shared/services/echo/channels/scan.ts new file mode 100644 index 0000000..a07ac1b --- /dev/null +++ b/osse-web/src/app/shared/services/echo/channels/scan.ts @@ -0,0 +1,77 @@ +/** + * Listen for Scan Events. + */ +export interface ScanEvents { + listenForScanStarted(): void; + listenForScanProgressed(): void; + listenForScanCompleted(): void; + listenForScanError(): void; + listenForScanFailed(): void; + listenForScanCancelled(): void; +} + +export interface ScanStartedResult { + directories: ScanDirectory[]; +} + +export interface ScanDirectory { + id: number; + scanJobID: number; + path: string; + status: ScanDirectoryStatus; + filesScanned: number; + filesSkipped: number; +} + +export enum ScanDirectoryStatus { + Pending = 'pending', + Scanning = 'scanning', + Scanned = 'scanned', + Errored = 'errored', +} + +export interface ScanProgressedResult { + directoryID: number; + directoryName: string; + filesScanned: number; + filesSkipped: number; + status: ScanDirectoryStatus; +} + +export interface ScanCompletedResult { + directoryCount: number; +} + +export interface ScanErrorResult { + message: string; +} + +export interface ScanFailedResult { + message: string; +} + +export interface ScanCancelledResult { + directoriesScannedBeforeCancellation: number; +} + +export interface ScanEventMap { + ScanStarted: ScanStartedResult; + ScanProgressed: ScanProgressedResult; + ScanCompleted: ScanCompletedResult; + ScanError: ScanErrorResult; + ScanFailed: ScanFailedResult; + ScanCancelled: ScanCancelledResult; +} + + +/** + * Scan channel names to subscrbe to. + */ +export enum ScanChannels { + ScanStarted = "ScanStarted", + ScanProgressed = "ScanProgressed", + ScanCompleted = "ScanCompleted", + ScanError = "ScanError", + ScanFailed = "ScanFailed", + ScanCancelled = "ScanCancelled", +} diff --git a/osse-web/src/app/shared/services/echo/echo.service.ts b/osse-web/src/app/shared/services/echo/echo.service.ts new file mode 100644 index 0000000..cd05fa7 --- /dev/null +++ b/osse-web/src/app/shared/services/echo/echo.service.ts @@ -0,0 +1,89 @@ +import { EventEmitter, Injectable } from '@angular/core'; +import { fetcher } from '../../util/fetcher'; +import { ScanChannels, ScanEvents } from './channels/scan'; +import { EchoChannel, EchoResult } from './channels'; +import { Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class EchoService implements ScanEvents { + private echoEvent = new EventEmitter<{ channel: EchoChannel; data: EchoResult }>(); + private eventSource!: EventSource; + + constructor() { } + + public connect() { + return new Promise((resolve, reject) => { + fetcher('sse', { + method: 'POST' + }).then(async (r) => { + if (r.ok) { + let json = await r.json(); + + // TODO: Use cookies instead of a query string to pass params. + this.eventSource = new EventSource(json.url + '?id=' + json.userID + '&token=' + json.token); + this.eventSource.addEventListener("error", (e) => console.log(e)); + // NOTE: To add a new event, you must suscribe by it to name. You can't use the generic "message" event. + resolve(null); + } + + }).catch(() => reject(null)); + }) + + } + + + public subscribeToEvent( + channel: T, + callback?: (data: EchoResult) => void + ): Observable> { + return new Observable>((observer) => { + const subscription = this.echoEvent.subscribe(({ channel: emittedChannel, data }) => { + if (emittedChannel === channel) { + const eventData = data as EchoResult; + observer.next(eventData); // Emit the data through the observable + if (callback) { + callback(eventData); // Optionally execute the callback + } + } + }); + + // Clean up when the observable is unsubscribed + return () => subscription.unsubscribe(); + }); + } + + private emitEvent(channel: T, data: EchoResult): void { + console.log(channel, data); + this.echoEvent.emit({ channel, data }); + } + + listenForScanStarted(): void { + this.eventSource.addEventListener("ScanStarted", (ev) => this.emitEvent(ScanChannels.ScanStarted, JSON.parse(ev.data))) + } + + listenForScanProgressed(): void { + this.eventSource.addEventListener("ScanProgressed", (ev) => this.emitEvent(ScanChannels.ScanProgressed, JSON.parse(ev.data))) + } + + listenForScanCompleted(): void { + this.eventSource.addEventListener("ScanCompleted", (ev) => this.emitEvent(ScanChannels.ScanCompleted, JSON.parse(ev.data))) + } + + listenForScanError(): void { + this.eventSource.addEventListener("ScanError", (ev) => this.emitEvent(ScanChannels.ScanError, JSON.parse(ev.data))) + } + + listenForScanFailed(): void { + this.eventSource.addEventListener("ScanFailed", (ev) => this.emitEvent(ScanChannels.ScanFailed, JSON.parse(ev.data))) + } + + listenForScanCancelled(): void { + this.eventSource.addEventListener("ScanCancelled", (ev) => this.emitEvent(ScanChannels.ScanCancelled, JSON.parse(ev.data))) + } + + public disconnect() { + this.eventSource.close(); + } +} diff --git a/osse-web/src/app/shared/services/network/network.service.ts b/osse-web/src/app/shared/services/network/network.service.ts new file mode 100644 index 0000000..9597302 --- /dev/null +++ b/osse-web/src/app/shared/services/network/network.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { ToastService } from '../../../toast-container/toast.service'; +import { ModalService } from '../../ui/modal/modal.service'; + +/** + * Class handles network operations. + */ +@Injectable({ + providedIn: 'root' +}) +export class NetworkService { + constructor(private notificationService: ToastService, private modelService: ModalService) { + window.addEventListener('online', () => { + this.notificationService.info('Internet connection restored.'); + }); + window.addEventListener('offline', () => { + this.notificationService.info('Internet connection lost.'); + }); + } +} diff --git a/osse-web/src/app/shared/services/playlist/Playlist.ts b/osse-web/src/app/shared/services/playlist/Playlist.ts new file mode 100644 index 0000000..e81af2d --- /dev/null +++ b/osse-web/src/app/shared/services/playlist/Playlist.ts @@ -0,0 +1,39 @@ +import { fetcher } from "../../util/fetcher"; +import { OsseTrack } from "../track/osse-track"; +import { Track } from "../track/track"; +import { OssePlaylist } from "./osse-playlist"; + +export class Playlist { + public tracks: Track[] = []; + + constructor(private playlist: OssePlaylist) { + if (playlist.tracks == undefined) { + return + } + this.tracks = playlist.tracks.map(t => { + return new Track(t); + }) + } + + public get id() { + return this.playlist.id; + } + + public get name() { + return this.playlist.name; + } + + public get count() { + return this.playlist.tracks_count; + } + + public async requestTracks() { + let req = await fetcher('playlists/' + this.id + '/tracks'); + if (req.ok) { + let res = await req.json(); + this.tracks = res.map((t: OsseTrack) => new Track(t)); + } + + return this.tracks; + } +} diff --git a/osse-web/src/app/shared/services/playlist/osse-playlist.ts b/osse-web/src/app/shared/services/playlist/osse-playlist.ts new file mode 100644 index 0000000..551cb20 --- /dev/null +++ b/osse-web/src/app/shared/services/playlist/osse-playlist.ts @@ -0,0 +1,9 @@ +import { OsseTrack } from "../track/osse-track"; + +export interface OssePlaylist { + id: number; + name: string; + tracks: OsseTrack[]; + // This is used when we don't load the track relation. + tracks_count: number; +} diff --git a/osse-web/src/app/shared/services/playlist/playlist.service.ts b/osse-web/src/app/shared/services/playlist/playlist.service.ts new file mode 100644 index 0000000..f39fb1c --- /dev/null +++ b/osse-web/src/app/shared/services/playlist/playlist.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; +import { Playlist } from './Playlist'; +import { OssePlaylist } from './osse-playlist'; +import { fetcher } from '../../util/fetcher'; + +@Injectable({ + providedIn: 'root' +}) +export class PlaylistService { + constructor() { } + + public async getPlaylist(id: number) { + let req = await fetcher('playlists/' + id); + let res = await req.json(); + + if (req.ok) { + return new Playlist(res); + } + + throw "Playlist Error"; + } + + public async getAll(): Promise { + let req = await fetcher('playlists'); + let res = await req.json(); + + if (req.ok) { + return res.map((p: OssePlaylist) => new Playlist(p)); + } + + throw "Playlist Error"; + } + + public async addTrackToPlaylist(playlistId: number, trackId: number) { + await fetcher('playlists/' + playlistId + '/tracks/' + trackId, { + method: 'POST' + }); + } + + public addTracksToPlaylist(playlistId: number, trackIds: number[]) { + return fetcher('playlists/' + playlistId + '/track-set', { + method: 'POST', + body: JSON.stringify({ + 'track-ids': trackIds + }) + }); + } + + public createPlaylist(name: string) { + return fetcher('playlists', { + method: 'POST', + body: JSON.stringify({ + 'name': name + }) + }); + } +} diff --git a/osse-web/src/app/shared/services/track/osse-track.ts b/osse-web/src/app/shared/services/track/osse-track.ts new file mode 100644 index 0000000..42f91a1 --- /dev/null +++ b/osse-web/src/app/shared/services/track/osse-track.ts @@ -0,0 +1,20 @@ +import { OsseArtist } from "../artist/osse-artist"; + +export interface OsseTrack { + id: number; + title: string; + size: number; + duration: number; + + bitrate: number | null; + artist_ids: number[] | null; + track_number: number | null; + disc_number: number | null; + cover_art_id: number | null; + scanned_at: string; + + /** + * Artist data. A track can have an artist ID without loading the artist, but most queries load it. + */ + artists: OsseArtist[] | null; +} diff --git a/osse-web/src/app/shared/services/track/queue-sync.service.ts b/osse-web/src/app/shared/services/track/queue-sync.service.ts new file mode 100644 index 0000000..6b8ed40 --- /dev/null +++ b/osse-web/src/app/shared/services/track/queue-sync.service.ts @@ -0,0 +1,112 @@ +import { Injectable } from '@angular/core'; +import { fetcher } from '../../util/fetcher'; +import { Track } from './track'; +import { OsseTrack } from './osse-track'; +import { ConfigService } from '../config/config.service'; + +@Injectable({ + providedIn: 'root' +}) +export class QueueSyncService { + private queueDebounce: number = 0; + + private lastSyncedIndex: number | null = null; + private lastSyncedPosition: number = 0; + private currentIndex: number | null = null; + private currentPosition: number = 0; + + constructor(private configService: ConfigService) { + setInterval(() => { + if ( + this.currentIndex !== this.lastSyncedIndex || + this.currentPosition !== this.lastSyncedPosition + ) { + this.lastSyncedIndex = this.currentIndex; + this.lastSyncedPosition = this.currentPosition; + + if (this.configService.get('queue')) { + fetcher('queue/active-track', { + method: 'POST', + body: JSON.stringify({ + active_track_index: this.currentIndex, + track_position: this.currentPosition + }) + }); + } + + } + }, 30000); + } + + /** + * Syncs the queue in 5 seconds, with debounce. + */ + syncQueue(trackIds: number[], trackIndex: number | null) { + if (this.configService.get('queue')) { + clearTimeout(this.queueDebounce); + this.queueDebounce = setTimeout(() => { + fetcher('queue', { + method: 'POST', + body: JSON.stringify({ + 'ids': trackIds, + // The active track is the input of the user if the track length is > 1, otherwise its null. + // Null symbolizes unknown curent track, or no tracks to choose from. + 'active_track': trackIds.length > 0 ? (trackIndex ?? 0) : null, + 'track_position': this.currentPosition, + }) + }) + }, 5000); + } + } + + /** + * Sets the active track and position server side. + * Syncs on an interval of 20 seconds. + */ + syncActiveTrack(trackIndex: number | null, trackPosition: number = 0) { + this.currentIndex = trackIndex; + this.currentPosition = Math.floor(trackPosition); + } + + async getQueueFromServer(): Promise { + if (!this.configService.get('queue')) { + return { + queue: [], + trackIndex: null, + trackPosition: 0 + }; + } + + let req = await fetcher('queue'); + if (req.ok) { + let result: QueueResponse = await req.json(); + if (result.trackIndex != null) { + return { + queue: result.tracks.map((t) => new Track(t)), + trackIndex: result.trackIndex, + trackPosition: result.trackPosition + } + } + } + + return { + queue: [], + trackIndex: null, + trackPosition: 0 + }; + } +} + +type QueueResponse = { + tracks: OsseTrack[]; + // Index of current track. + trackIndex: number | null; + // Seconds into current track. + trackPosition: number; +} + +type TrackQueue = { + queue: Track[]; + trackIndex: number | null; + trackPosition: number; +} diff --git a/osse-web/src/app/shared/services/track/repeat.enum.ts b/osse-web/src/app/shared/services/track/repeat.enum.ts new file mode 100644 index 0000000..81b607c --- /dev/null +++ b/osse-web/src/app/shared/services/track/repeat.enum.ts @@ -0,0 +1,8 @@ +/** + * Queue repeat values. None is default. + */ +export enum Repeat { + None = 0, + Once = 1, + Loop = 2, +} diff --git a/osse-web/src/app/shared/services/track/track.service.ts b/osse-web/src/app/shared/services/track/track.service.ts new file mode 100644 index 0000000..9c6e9fd --- /dev/null +++ b/osse-web/src/app/shared/services/track/track.service.ts @@ -0,0 +1,289 @@ +import { Injectable, signal, WritableSignal } from '@angular/core'; +import { Track } from './track'; +import { PlayerService } from '../../player/player.service'; +import { Repeat } from './repeat.enum'; +import { QueueSyncService } from './queue-sync.service'; + +/** + * This service stores all queued tracks + */ +@Injectable({ + providedIn: 'root' +}) +export class TrackService { + /** + * List of tracks in the queue. We can only call methods on it. DO NOT reset the value or the UI loses reference. + */ + public tracks: Track[] = []; + private index = 0; + private hasRepeatedCurrentTrack: boolean = false; + + /** + * List of cleared tracks. Used for restoration in case of accidental deletion. + */ + private clearedTracks: Track[] = []; + + /** + * If true, track is removed on end playback + */ + public consume: WritableSignal = signal(false); + public repeat: WritableSignal = signal(Repeat.None); + + constructor(private playerService: PlayerService, private queueSyncService: QueueSyncService) { + // When playback ends, wait 250 ms. + // We need the player to clear the UI first. It subscribes to the same event. + // Then, progress to the next track (if any) + this.playerService.playbackEnded.subscribe(_ => { + setTimeout(() => { + if (this.tracks.length <= 0) return; + + // Handle repeats. + let trackPlayedFromRepeat = this.playTrackBasedOnRepeatValue(); + if (trackPlayedFromRepeat) { + return; + } + + if (this.consume()) { + this.tracks.splice(this.index, 1); + if (this.tracks.length == 0) { + this.playerService.clearTrack(); + } else { + this.moveToLastTrack(); + } + } else { + this.moveToNextTrack(); + } + }, 250); + }) + + // Keep the server in synce with playback. There is a throttle of 20 seconds. + this.playerService.trackPositionUpdate.subscribe((pos) => { + this.queueSyncService.syncActiveTrack(this.index, pos.currentTimeSeconds); + }); + } + + get activeTrack() { + return this.tracks[this.index]; + } + + public addTrack(track: Track) { + // If the UUID is already in use, make a new uuid. + if (this.tracks.some((a) => a.uuid == track.uuid)) { + track = track.regenerateTrack(); + } + + this.tracks.push(track); + // If this is the first track added, start playback + if (this.tracks.length - 1 == 0) { + this.moveToTrack(0); + } + + this.queueSyncService.syncQueue(this.tracks.map((t) => t.id), this.index); + } + + // Removes all tracks and stops playback + public clearTracks() { + // Store a list of the cleared tracks. + this.clearedTracks = this.tracks.map((t) => t); + + while (this.tracks.length != 0) { + this.tracks.pop(); + } + + this.index = 0; + this.playerService.pause(); + this.playerService.clearTrack(); + + this.queueSyncService.syncQueue([], null); + } + + /** + * Called when the user leaves the homepage. + */ + public removeClearedTracks() { + this.clearedTracks = []; + } + + + public restoreTracks() { + if (this.clearedTracks.length == 0) { + return; + } + + // If we ever allow this method to be called outside of the homepage, we may need to clear any existing tracks first. + this.clearedTracks.forEach((t) => this.addTrack(t)); + this.clearedTracks = []; + + this.queueSyncService.syncQueue(this.tracks.map((t) => t.id), null); + } + + public moveToNextTrack() { + this.index += 1; + if (this.index == this.tracks.length) { + this.index = 0; + } + + if (this.tracks[this.index]) { + this.playerService.setTrackAndPlay(this.activeTrack); + // If user goes to next track, clear repeat tracker. + this.hasRepeatedCurrentTrack = false; + } + } + + public moveToLastTrack() { + this.index -= 1; + if (this.index < 0) { + this.index = 0; + } + + if (this.tracks[this.index]) { + this.playerService.setTrackAndPlay(this.activeTrack); + // If user goes to last track, clear repeat tracker. + this.hasRepeatedCurrentTrack = false; + } + } + + public moveToTrack(index: number) { + this.index = index; + + this.playerService.setTrackAndPlay(this.activeTrack); + } + + /** + * Same as moveToTrack, but it won't start playback. + */ + public setTrackIndex(index: number) { + this.index = index; + } + + public removeTrack(index: number) { + // If 1 track is present, remove them and end playback + if (this.tracks.length == 1) { + this.tracks.pop(); + this.index = 0; + + this.playerService.pause(); + this.playerService.clearTrack(); + return; + } + + // If the track is after the current track remove it + if (index > this.index) { + this.tracks.splice(index, 1); + } else if (index == this.index) { + // If its the current track, stop playback, remove it, and play next + this.tracks.splice(index, 1); + + this.playerService.pause(); + this.playerService.clearTrack(); + + // Reduce the index by one to play the "next" track + this.moveToLastTrack(); + } else { + // Track is before current, remove it and move index back 1 + this.tracks.splice(index, 1); + this.index -= 1; + } + + this.queueSyncService.syncQueue(this.tracks.map((t) => t.id), this.index); + } + + /** + * Shuffles the tracks and moves the index to the new location of the active track (if any) + */ + public shuffle() { + let currentTrack = this.activeTrack; + + this.tracks = this.tracks + .map(value => ({ value, sort: Math.random() })) + .sort((a, b) => a.sort - b.sort) + .map(({ value }) => value); + + if (currentTrack) { + this.index = this.tracks.findIndex((t) => t.id == currentTrack.id); + } + + this.queueSyncService.syncQueue(this.tracks.map((t) => t.id), this.index); + } + + /** + * Sets the queue to whatever the user has server side. + */ + public fetchQueueFromServer(play: boolean = false) { + // Move duration to 2nd method. + this.queueSyncService.getQueueFromServer() + .then((result) => { + result.queue.forEach(t => this.tracks.push(t)); + + if (this.tracks.length == 0) return; + + // If the session has a track, use it. + if (result.trackIndex != null) { + // There may have been a index desync, so make sure the index is valid. + if (!this.canMoveToTrack(result.trackIndex)) { + if (play) { + this.playerService.setTrackAndPlay(result.queue[0]); + } else { + this.playerService.setTrackAndBackgroundImage(result.queue[0]); + } + + // Set the track index so active track is correct. (it would play the right track, but the active track is wrong which causes UI issues.) + this.setTrackIndex(0); + + return; + } + + if (play) { + this.playerService.setTrackAndPlay(result.queue[result.trackIndex], result.trackPosition ?? 0); + } else { + this.playerService.setTrackAndBackgroundImage(result.queue[result.trackIndex]); + this.playerService.setDuration(result.trackPosition ?? 0); + } + + this.setTrackIndex(result.trackIndex); + } else { + // Else, use first track. + if (play) { + this.playerService.setTrackAndPlay(result.queue[0]); + } else { + this.playerService.setTrackAndBackgroundImage(result.queue[0]); + } + + this.setTrackIndex(0); + } + }); + } + + private canMoveToTrack(index: number) { + return index < this.tracks.length; + } + + private playTrackBasedOnRepeatValue(): boolean { + switch (this.repeat()) { + case Repeat.None: + return false; + case Repeat.Once: + if (this.hasRepeatedCurrentTrack) { + // Move to the next track. + this.moveToNextTrack(); + } else { + this.playerService.setTrackAndPlay(this.activeTrack); + this.hasRepeatedCurrentTrack = true; + } + return true; + case Repeat.Loop: + this.playerService.setTrackAndPlay(this.activeTrack); + return true; + } + } + + public getUpcomingTrack(): Track | undefined { + let track = this.tracks.at(this.index + 1); + if (track != undefined) { + return track; + } + + // If there isn't a next track, try the first track (expecting loop around) + return this.tracks.at(0); + } +} diff --git a/osse-web/src/app/shared/services/track/track.ts b/osse-web/src/app/shared/services/track/track.ts new file mode 100644 index 0000000..28452d2 --- /dev/null +++ b/osse-web/src/app/shared/services/track/track.ts @@ -0,0 +1,171 @@ +import { WritableSignal, computed, signal } from "@angular/core"; +import { LocatorService } from "../../../locator.service"; +import { getNicelyFormattedTime } from "../../util/time"; +import { ApiService } from "../api.service"; +import { ArtistStoreService } from "../artist/artist-store.service"; +import { OsseTrack } from "./osse-track"; +import { Artist } from "../artist/artist"; +import { v4 as uuid } from 'uuid'; +import { ConfigService } from "../config/config.service"; + +export class Track { + public track!: OsseTrack; + private artistStore: ArtistStoreService = LocatorService.injector.get(ArtistStoreService); + private apiService: ApiService = LocatorService.injector.get(ApiService); + private configService: ConfigService = LocatorService.injector.get(ConfigService); + public bufferSize: number = 0; + public artists: WritableSignal = signal([]); + public artistPrimary = computed(() => { + let artists = this.artists(); + return artists.at(0); + }); + public artistNames = computed(() => { + let names = this.artists().map((a) => a.name); + if (names.length == 0) { + return '(None)'; + } + + if (names.length == 1) { + return names[0]; + } + + if (names.length == 2) { + return names.join(' and ') + } + + let allButLastName = names.slice(0, -1).join(', '); + let lastName = names[names.length - 1]; + return `${allButLastName}, and ${lastName}`; + }); + /** + * Generates a random uuid. Use for a unique identifier instead of track ID. This changes each time this class is created. + * Track IDs should be used for server communication only. + */ + public uuid: string; + + constructor(track: OsseTrack) { + this.track = track; + this.uuid = uuid(); + + // Grab the artist info + this.getArtist(); + } + + public get id() { + return this.track.id; + } + + public get title() { + return this.track.title; + } + + public get size() { + return this.track.size; + } + + public get duration() { + return this.track.duration; + } + + public get durationFormatted() { + return getNicelyFormattedTime(this.track.duration); + } + + public hasArtist(): boolean { + return this.track.artists != null && this.track.artists.length > 0; + } + + public async getArtist() { + if (!this.hasArtist()) { + return; + } + + // If we have the data in the request, use it. + if (this.track.artists != null) { + this.artists.set(this.track.artists.map((a) => new Artist(a))); + return; + } + + // Check if it already exists in the store. + for (let artistId of this.track.artist_ids ?? []) { + if (this.artistStore.artistIsLoaded(artistId)) { + this.addArtistById(artistId); + return; + } + } + + // We need to fetch artist. Check the fetch list to make sure we don't make multiple reqeuests for the same artist. + for (let artistId of this.track.artist_ids ?? []) { + if (this.artistStore.isFetchingArtist(artistId)) { + await new Promise((resolve, _reject) => { + let sub = this.artistStore.artistFetched.subscribe((_v) => { + sub.unsubscribe(); + resolve(); + }); + }); + + this.addArtistById(artistId); + return; + } + } + + // Not fetching artist, start fetching artist + for (let artistId of this.track.artist_ids ?? []) { + this.artistStore.addFetchingArtist(artistId); + let artist = await this.apiService.getArtist(artistId); + if (artist) { + this.artistStore.setArtist(artist); + this.addArtistById(artistId); + } + + this.artistStore.removeFetchingArtist(artistId); + this.artistStore.artistFetched.emit(artistId); + } + } + + private addArtistById(artistId: number) { + let artist = this.artistStore.getArtistById(artistId) ?? null; + this.artists.update((a) => { + a.push(artist as Artist); + return a; + }); + } + + /** + * Sometimes tracks are fetched from osse and made into classes, but the user can refer to the same instance. + * Later, these instances are in the same array. + * In cases like this, we need a fresh uuid and track instance. + * An example is the tracklist page. Each track is a Track class, so adding the same track to the queue will result in duplicate uuids! + */ + public regenerateTrack(): Track { + return new Track({ + id: this.track.id, + title: this.track.title, + size: this.track.size, + duration: this.track.duration, + track_number: this.trackNumber, + disc_number: this.discNumber, + bitrate: this.track.bitrate, + cover_art_id: this.track.cover_art_id, + scanned_at: this.track.scanned_at, + artist_ids: this.track.artists?.map((a) => a.id) ?? null, + artists: this.track.artists + }); + } + + public get trackNumber() { + return this.track.track_number; + } + + public get discNumber() { + return this.track.disc_number; + } + + public get scannedAt() { + return this.track.scanned_at; + } + + get coverURL() { + return this.configService.get('apiURL') + "cover-art/" + this.track.cover_art_id; + } +} diff --git a/osse-web/src/app/shared/ui/background-image.service.ts b/osse-web/src/app/shared/ui/background-image.service.ts new file mode 100644 index 0000000..770ebcf --- /dev/null +++ b/osse-web/src/app/shared/ui/background-image.service.ts @@ -0,0 +1,20 @@ +import { EventEmitter, Injectable } from '@angular/core'; +import { ConfigService } from '../services/config/config.service'; + +@Injectable({ + providedIn: 'root' +}) +export class BackgroundImageService { + public bgChanged = new EventEmitter(); + constructor(private configService: ConfigService) { } + + public setBG(bg: string) { + if (this.configService.get('showCoverBackgrounds')) { + this.bgChanged.emit(bg); + } + } + + public clearBG() { + this.bgChanged.emit('#'); + } +} diff --git a/osse-web/src/app/shared/ui/header/header.component.html b/osse-web/src/app/shared/ui/header/header.component.html new file mode 100644 index 0000000..a9f54bd --- /dev/null +++ b/osse-web/src/app/shared/ui/header/header.component.html @@ -0,0 +1,13 @@ +@if (type == 1) { +

{{text}}

+} @else if (type == 2) { +

{{text}}

+} @else if (type == 3) { +

{{text}}

+} @else if (type == 4) { +

{{text}}

+} @else if (type == 5) { +
{{text}}
+} @else if (type == 6) { +
{{text}}
+} diff --git a/osse-web/src/app/shared/ui/header/header.component.ts b/osse-web/src/app/shared/ui/header/header.component.ts new file mode 100644 index 0000000..495704e --- /dev/null +++ b/osse-web/src/app/shared/ui/header/header.component.ts @@ -0,0 +1,14 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-header', + imports: [], + templateUrl: './header.component.html', + styles: `` +}) +export class HeaderComponent { + @Input() + public type: number = 1; + @Input() + public text: string = ''; +} diff --git a/osse-web/src/app/shared/ui/icon/icon.component.html b/osse-web/src/app/shared/ui/icon/icon.component.html new file mode 100644 index 0000000..be844a4 --- /dev/null +++ b/osse-web/src/app/shared/ui/icon/icon.component.html @@ -0,0 +1,10 @@ + + + + + diff --git a/osse-web/src/app/shared/ui/icon/icon.component.ts b/osse-web/src/app/shared/ui/icon/icon.component.ts new file mode 100644 index 0000000..aa8bf66 --- /dev/null +++ b/osse-web/src/app/shared/ui/icon/icon.component.ts @@ -0,0 +1,14 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-icon', + imports: [], + templateUrl: './icon.component.html', + styles: `` +}) +export class IconComponent { + @Input('icon') data: string = ''; + @Input('class') cssClass: string = ''; + @Input('align') align: string = 'text-bottom'; + @Input('active') active: boolean = false; +} diff --git a/osse-web/src/app/shared/ui/loading/loading.component.css b/osse-web/src/app/shared/ui/loading/loading.component.css new file mode 100644 index 0000000..7e16bb4 --- /dev/null +++ b/osse-web/src/app/shared/ui/loading/loading.component.css @@ -0,0 +1,67 @@ +@media (width >=640px) { + + #loading-bar, + #background-loading-bar { + width: calc(100% - 8rem); + } +} + +@media (max-width: 640px) { + + #loading-bar, + #background-loading-bar { + width: 100%; + } +} + + +/* Top loading bar */ +.loading-bar { + animation: loading-animation 2s ease-out forwards; + animation-play-state: running; + transform-origin: left; + will-change: transform; +} + +#loading-bar:not(.loading-bar) { + transform: scaleX(0); +} + +/* Bottom loading bar */ + +.background-loading-bar { + animation: loading-fade-in 1s; + animation-play-state: running; + will-change: opacity; +} + +#background-loading-bar:not(.background-loading-bar) { + opacity: 0; +} + +/* This is for the fade out used by both bars. */ +.opacity-transition { + transition: opacity 500ms linear; +} + +/* Main bar loading animtion */ +@keyframes loading-animation { + 0% { + transform: scaleX(0); + } + + 100% { + transform: scaleX(1); + } +} + +/* Background fade in animation */ +@keyframes loading-fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} diff --git a/osse-web/src/app/shared/ui/loading/loading.component.html b/osse-web/src/app/shared/ui/loading/loading.component.html new file mode 100644 index 0000000..bb1e5c8 --- /dev/null +++ b/osse-web/src/app/shared/ui/loading/loading.component.html @@ -0,0 +1,9 @@ + diff --git a/osse-web/src/app/shared/ui/loading/loading.component.ts b/osse-web/src/app/shared/ui/loading/loading.component.ts new file mode 100644 index 0000000..d7b0503 --- /dev/null +++ b/osse-web/src/app/shared/ui/loading/loading.component.ts @@ -0,0 +1,59 @@ +import { Component, OnInit, signal } from '@angular/core'; +import { LoadingService } from './loading.service'; + +@Component({ + selector: 'app-loading', + imports: [], + templateUrl: './loading.component.html', + styleUrl: `./loading.component.css`, +}) +export class LoadingComponent implements OnInit { + // Front loading bar + isLoading = signal(false); + opacity = signal(1); + componentReady = signal(false); + + // Back loading bar. + isFastEndToLoading = signal(false); + + constructor(private loadingService: LoadingService) { } + + /** + * Resets animations. + * Automatically called by the bottom bar if fastEndToLoading is set. + * Otherwise called at end of load. + */ + resetAnimations() { + this.opacity.set(0); + + setTimeout(() => { + this.isFastEndToLoading.set(false); + this.isLoading.set(false); + this.opacity.set(1); + }, 1000); + } + + ngOnInit(): void { + this.loadingService.loadingStarted.subscribe(() => { + // Show the loading bars. Prevents flicker. + // TODO: Find a better way than checking it each time. NgOnInit and NgAfterViewInit didn't work. + if (!this.componentReady()) { + this.componentReady.set(true); + } + + // Start loading animation. + this.isLoading.set(true); + }); + + this.loadingService.loadingFinished.subscribe((useFastEndAnimation) => { + if (useFastEndAnimation) { + // Show the bottom bar to appear to end the animation faster. + // The bottom bar will call the reset when its done. + this.isFastEndToLoading.set(true); + } else { + // End the enimation. + this.resetAnimations(); + } + }); + } +} diff --git a/osse-web/src/app/shared/ui/loading/loading.service.ts b/osse-web/src/app/shared/ui/loading/loading.service.ts new file mode 100644 index 0000000..0626f54 --- /dev/null +++ b/osse-web/src/app/shared/ui/loading/loading.service.ts @@ -0,0 +1,31 @@ +import { EventEmitter, Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class LoadingService { + + constructor() { } + public loadingStarted = new EventEmitter(); + /** + * Emits when loading is done. + * If the loading animation hasn't finished, we emit true to tell the background bar to appear (appear to end animation faster). + */ + public loadingFinished = new EventEmitter(); + + private loadingStartedAt: number = 0; + + startLoading() { + this.loadingStarted.emit(); + this.loadingStartedAt = Date.now(); + } + + /** + * Ends loading. + * If the time was less than 1 second, use early end to load. + */ + endLoading(): void { + let now = Date.now(); + this.loadingFinished.emit(now - this.loadingStartedAt < 1000); + } +} diff --git a/osse-web/src/app/shared/ui/modal/modal.component.html b/osse-web/src/app/shared/ui/modal/modal.component.html new file mode 100644 index 0000000..c9eba9a --- /dev/null +++ b/osse-web/src/app/shared/ui/modal/modal.component.html @@ -0,0 +1,8 @@ + +
+

{{title}}

+
+
+ +
+
diff --git a/osse-web/src/app/shared/ui/modal/modal.component.ts b/osse-web/src/app/shared/ui/modal/modal.component.ts new file mode 100644 index 0000000..3979fc8 --- /dev/null +++ b/osse-web/src/app/shared/ui/modal/modal.component.ts @@ -0,0 +1,57 @@ +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, ElementRef, ViewChild, ViewContainerRef, inject } from '@angular/core'; +import { ModalService } from './modal.service'; + +@Component({ + selector: 'app-modal', + imports: [], + templateUrl: './modal.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrl: './modal.styles.css' +}) +export class ModalComponent implements AfterViewInit { + // Parent Modal + @ViewChild('modal') modal!: ElementRef; + // VCR (dynamic content) + @ViewChild('vcr', { static: true, read: ViewContainerRef }) vcr!: ViewContainerRef; + title: string = ''; + // Active component + component!: ComponentRef; + cdr = inject(ChangeDetectorRef); + + constructor(private modalService: ModalService) { } + + public open() { + this.modal.nativeElement.showModal(); + } + + public close() { + this.modal.nativeElement.close(); + this.component.destroy(); + this.title = ''; + } + + ngAfterViewInit(): void { + this.modalService.onLoadComponent.subscribe((v) => { + // Clear the old component and save the new one + this.vcr.clear(); + // Load the component + this.component = this.vcr.createComponent(v[0] as any); + this.title = v[1]; + + // Set any input props + (v[2] ?? []).forEach((input: { name: string, val: any }) => { + this.component.instance[`${input.name}`] = input.val; + }); + + // Show changes + this.cdr.detectChanges(); + + // Listen for close events + this.component.instance['onClose'].subscribe((_: any) => this.close()); + }); + + this.modalService.onShow.subscribe(() => this.open()); + this.modalService.onClose.subscribe(() => this.close()); + } +} + diff --git a/osse-web/src/app/shared/ui/modal/modal.service.ts b/osse-web/src/app/shared/ui/modal/modal.service.ts new file mode 100644 index 0000000..2b3839a --- /dev/null +++ b/osse-web/src/app/shared/ui/modal/modal.service.ts @@ -0,0 +1,28 @@ +import { EventEmitter, Injectable, Output } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class ModalService { + @Output() onLoadComponent = new EventEmitter(); + @Output() onShow = new EventEmitter(); + @Output() onClose = new EventEmitter(); + + constructor() { } + + public setDynamicModal(component: any, input: {name: string, val: any}[], title: string) { + this.onLoadComponent.emit([component, title, input]); + } + + public setStaticModal(component: any, title: string) { + this.onLoadComponent.emit([component, title]); + } + + public show() { + this.onShow.emit(); + } + + public close() { + this.onClose.emit(); + } +} diff --git a/osse-web/src/app/shared/ui/modal/modal.styles.css b/osse-web/src/app/shared/ui/modal/modal.styles.css new file mode 100644 index 0000000..3d5aa92 --- /dev/null +++ b/osse-web/src/app/shared/ui/modal/modal.styles.css @@ -0,0 +1,4 @@ +dialog { + min-width: 15rem; + margin: auto; +} diff --git a/osse-web/src/app/shared/ui/modals/add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component.html b/osse-web/src/app/shared/ui/modals/add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component.html new file mode 100644 index 0000000..0b8a095 --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component.html @@ -0,0 +1,16 @@ +
+

Select a playlist to add {{tracks.length}} track(s) to.

+
+ + +
+ +
+ + +
+
diff --git a/osse-web/src/app/shared/ui/modals/add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component.ts b/osse-web/src/app/shared/ui/modals/add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component.ts new file mode 100644 index 0000000..09f39ee --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component.ts @@ -0,0 +1,38 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, WritableSignal, signal } from '@angular/core'; +import { Track } from '../../../services/track/track'; +import { PlaylistService } from '../../../services/playlist/playlist.service'; +import { Playlist } from '../../../services/playlist/Playlist'; +import { FormsModule } from '@angular/forms'; +import { ToastService } from '../../../../toast-container/toast.service'; + +@Component({ + selector: 'app-add-multiple-tracks-to-playlist', + imports: [FormsModule], + templateUrl: './add-multiple-tracks-to-playlist.component.html', + styles: ``, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AddMultipleTracksToPlaylistComponent implements OnInit { + @Input('tracks') tracks: Track[] = []; + @Output('onClose') onClose = new EventEmitter(); + + playlists: WritableSignal = signal([]); + model: WritableSignal = signal(-1); + + constructor(private playlistService: PlaylistService, private notificationService: ToastService) { } + + public close() { + this.onClose.emit(); + } + + public async onSave() { + await this.playlistService.addTracksToPlaylist(Number(this.model()), this.tracks.map((t) => t.id)); + this.notificationService.info(this.tracks.length + ' tracks added to playlist.'); + this.close(); + } + + async ngOnInit(): Promise { + this.playlists.set(await this.playlistService.getAll()); + } +} + diff --git a/osse-web/src/app/shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component.html b/osse-web/src/app/shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component.html new file mode 100644 index 0000000..35ffa50 --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component.html @@ -0,0 +1,11 @@ +
+ @if (loadingPlaylists()) { +

Fetching playlist information...

+ } @else { + @if (playlists().length == 0) { + + } @else { + + } + } +
diff --git a/osse-web/src/app/shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component.ts b/osse-web/src/app/shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component.ts new file mode 100644 index 0000000..06a4d30 --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component.ts @@ -0,0 +1,32 @@ +import { Component, EventEmitter, Input, OnInit, Output, signal } from '@angular/core'; +import { Track } from '../../../services/track/track'; +import { PlaylistService } from '../../../services/playlist/playlist.service'; +import { Playlist } from '../../../services/playlist/Playlist'; +import { AddMultipleTracksToPlaylistComponent } from "../add-multiple-tracks-to-playlist/add-multiple-tracks-to-playlist.component"; +import { CreateNewPlaylistForTracksComponent } from "../create-new-playlist-for-tracks/create-new-playlist-for-tracks.component"; + +@Component({ + selector: 'app-add-to-playlist-factory', + imports: [AddMultipleTracksToPlaylistComponent, CreateNewPlaylistForTracksComponent], + templateUrl: './add-to-playlist-factory.component.html', + styles: `` +}) +export class AddToPlaylistFactoryComponent implements OnInit { + @Input('tracks') tracks: Track[] = []; + @Output('onClose') onClose = new EventEmitter(); + + loadingPlaylists = signal(true); + playlists = signal([]); + + constructor(private playlistService: PlaylistService) { } + + public close() { + this.onClose.emit(); + } + + async ngOnInit(): Promise { + this.loadingPlaylists.set(true); + this.playlists.set(await this.playlistService.getAll()); + this.loadingPlaylists.set(false); + } +} diff --git a/osse-web/src/app/shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component.html b/osse-web/src/app/shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component.html new file mode 100644 index 0000000..edbdfd6 --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component.html @@ -0,0 +1,7 @@ +
+ Album art + +
+ +
+
diff --git a/osse-web/src/app/shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component.ts b/osse-web/src/app/shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component.ts new file mode 100644 index 0000000..f756e55 --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/album-art-fullscreen/album-art-fullscreen.component.ts @@ -0,0 +1,22 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, signal, WritableSignal } from '@angular/core'; + +@Component({ + selector: 'app-album-art-fullscreen', + imports: [], + templateUrl: './album-art-fullscreen.component.html', + styles: ``, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AlbumArtFullscreenComponent implements OnInit { + @Input('url') url: string = ''; + @Output('onClose') onClose = new EventEmitter(); + public albumUrl: WritableSignal = signal(''); + + public close() { + this.onClose.emit(); + } + + public ngOnInit(): void { + this.albumUrl.set(this.url); + } +} diff --git a/osse-web/src/app/shared/ui/modals/create-new-playlist-for-tracks/create-new-playlist-for-tracks.component.html b/osse-web/src/app/shared/ui/modals/create-new-playlist-for-tracks/create-new-playlist-for-tracks.component.html new file mode 100644 index 0000000..cde1a74 --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/create-new-playlist-for-tracks/create-new-playlist-for-tracks.component.html @@ -0,0 +1,14 @@ +
+

Create a playlist to add the track(s) to.

+ +
+ + +
+ +
+ + +
+
diff --git a/osse-web/src/app/shared/ui/modals/create-new-playlist-for-tracks/create-new-playlist-for-tracks.component.ts b/osse-web/src/app/shared/ui/modals/create-new-playlist-for-tracks/create-new-playlist-for-tracks.component.ts new file mode 100644 index 0000000..7d1041d --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/create-new-playlist-for-tracks/create-new-playlist-for-tracks.component.ts @@ -0,0 +1,52 @@ +import { Component, EventEmitter, Input, Output, signal, WritableSignal } from '@angular/core'; +import { Track } from '../../../services/track/track'; +import { Playlist } from '../../../services/playlist/Playlist'; +import { PlaylistService } from '../../../services/playlist/playlist.service'; + +import { FormsModule } from '@angular/forms'; +import { OssePlaylist } from '../../../services/playlist/osse-playlist'; +import { ToastService } from '../../../../toast-container/toast.service'; + +@Component({ + selector: 'app-create-new-playlist-for-tracks', + imports: [FormsModule], + templateUrl: './create-new-playlist-for-tracks.component.html', + styles: `` +}) +export class CreateNewPlaylistForTracksComponent { + @Input('tracks') tracks!: Track[]; + @Output('onClose') onClose = new EventEmitter(); + playlists: WritableSignal = signal([]); + playlistModel: WritableSignal = signal(""); + + constructor(private playlistService: PlaylistService, private notificationService: ToastService) { } + + public close() { + this.onClose.emit(); + } + + public async onSave() { + let playlistId = 0; + try { + let req = await this.playlistService.createPlaylist(this.playlistModel()); + let res: OssePlaylist = await req.json(); + playlistId = res.id; + } catch (error) { + this.notificationService.error('Failed to create playlist. Check that the name is not already in use.') + return; + } + + try { + await this.playlistService.addTracksToPlaylist(playlistId, this.tracks.map((t) => t.id)); + this.notificationService.info(`Playlist created. ${this.tracks.length} tracks added to playlist.`); + } catch (error) { + this.notificationService.error('The playlist was created, but the tracks were not added. Try again?') + } + + this.close(); + } + + async ngOnInit(): Promise { + this.playlists.set(await this.playlistService.getAll()); + } +} diff --git a/osse-web/src/app/shared/ui/modals/player-settings/player-settings.component.html b/osse-web/src/app/shared/ui/modals/player-settings/player-settings.component.html new file mode 100644 index 0000000..2acba9a --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/player-settings/player-settings.component.html @@ -0,0 +1,20 @@ +
+
+ + + +
+ +
+ + +
+
diff --git a/osse-web/src/app/shared/ui/modals/player-settings/player-settings.component.ts b/osse-web/src/app/shared/ui/modals/player-settings/player-settings.component.ts new file mode 100644 index 0000000..ebeb6f9 --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/player-settings/player-settings.component.ts @@ -0,0 +1,39 @@ +import { Component, ElementRef, EventEmitter, Input, OnInit, Output, signal, ViewChild, WritableSignal } from '@angular/core'; +import { ConfigService } from '../../../services/config/config.service'; +import { ToastService } from '../../../../toast-container/toast.service'; + +@Component({ + selector: 'app-player-settings', + imports: [], + templateUrl: './player-settings.component.html', + styles: `` +}) +export class PlayerSettingsComponent implements OnInit { + @Input() visualizerSignal!: WritableSignal; + @Output() onClose = new EventEmitter(); + @ViewChild('samples') sampleElement!: ElementRef; + public showVisualizer: WritableSignal = signal(false); + public visualizerSamples: WritableSignal = signal(1); + + constructor(private configService: ConfigService, private notificationService: ToastService) { } + + public save() { + this.configService.save('showVisualizer', this.showVisualizer()); + this.configService.save('visualizerSamples', Number(this.sampleElement.nativeElement.value)); + this.visualizerSamples.set(Number(this.sampleElement.nativeElement.value)); + // This will cause a visualizer change. The samples are pulled from the config service directly. + this.visualizerSignal.set(this.showVisualizer()); + this.notificationService.info('Saved Preferences!'); + + this.close(); + } + + public close() { + this.onClose.emit(); + } + + ngOnInit(): void { + this.showVisualizer.set(this.configService.get('showVisualizer')); + this.visualizerSamples.set(this.configService.get('visualizerSamples')); + } +} diff --git a/osse-web/src/app/shared/ui/modals/track-info/track-info.component.html b/osse-web/src/app/shared/ui/modals/track-info/track-info.component.html new file mode 100644 index 0000000..8b09ed4 --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/track-info/track-info.component.html @@ -0,0 +1,20 @@ +
+
+ @if (trackInfo) { +

Title:

+

{{trackInfo.title}}

+

Duration:

+

{{trackInfo.durationFormatted}}

+

Artist:

+

{{trackInfo.artistNames()}}

+

Track Number:

+

{{trackInfo.trackNumber ?? 'None'}}

+

Disc Number:

+

{{trackInfo.discNumber ?? 'None'}}

+ } +
+ +
+ +
+
diff --git a/osse-web/src/app/shared/ui/modals/track-info/track-info.component.ts b/osse-web/src/app/shared/ui/modals/track-info/track-info.component.ts new file mode 100644 index 0000000..1e5ab4c --- /dev/null +++ b/osse-web/src/app/shared/ui/modals/track-info/track-info.component.ts @@ -0,0 +1,18 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Track } from '../../../services/track/track'; + +@Component({ + selector: 'app-track-info', + imports: [], + templateUrl: './track-info.component.html', + styles: `` +}) +export class TrackInfoComponent { + @Input() + public trackInfo!: Track; + @Output() onClose = new EventEmitter(); + + public close() { + this.onClose.emit(); + } +} diff --git a/osse-web/src/app/shared/ui/track-matrix/matrix-item/matrix-item.component.html b/osse-web/src/app/shared/ui/track-matrix/matrix-item/matrix-item.component.html new file mode 100644 index 0000000..1a591e6 --- /dev/null +++ b/osse-web/src/app/shared/ui/track-matrix/matrix-item/matrix-item.component.html @@ -0,0 +1,30 @@ +
+ @if (mode() == TrackMatrixMode.View) { + + } @else if (mode() == TrackMatrixMode.Select) { + + } +
diff --git a/osse-web/src/app/shared/ui/track-matrix/matrix-item/matrix-item.component.ts b/osse-web/src/app/shared/ui/track-matrix/matrix-item/matrix-item.component.ts new file mode 100644 index 0000000..03dc973 --- /dev/null +++ b/osse-web/src/app/shared/ui/track-matrix/matrix-item/matrix-item.component.ts @@ -0,0 +1,45 @@ +import { Component, input, InputSignal, output, signal, WritableSignal } from '@angular/core'; +import { Track } from '../../../services/track/track'; +import { TrackMatrixMode } from '../track-matrix-mode.enum'; +import { CommonModule } from '@angular/common'; +import { TrackField, TrackInfo } from '../track-info'; + +@Component({ + selector: 'app-matrix-item', + imports: [CommonModule], + templateUrl: './matrix-item.component.html', + styles: `` +}) +export class MatrixItemComponent { + public track = input.required(); + public TrackMatrixMode = TrackMatrixMode; + public mode: InputSignal = input(TrackMatrixMode.View); + public selected: WritableSignal = signal(false); + public visibleTrackInfo = input(); + public trackInfo = TrackInfo; + public trackField = TrackField; + + public onClick = output(); + public onSelectToggle = output(); + + public toggleSelected() { + this.selected.set(!this.selected()); + this.onSelectToggle.emit(this.selected()); + } + + public determineClickTypeAndEmitEvent(event: MouseEvent) { + if (event.ctrlKey) { + this.toggleSelected(); + } else { + this.onClick.emit(); + } + } + + public emitOnClickEvent() { + this.onClick.emit(); + } + + public setSelected(selected: boolean) { + this.selected.set(selected); + } +} diff --git a/osse-web/src/app/shared/ui/track-matrix/track-info.ts b/osse-web/src/app/shared/ui/track-matrix/track-info.ts new file mode 100644 index 0000000..271e1e6 --- /dev/null +++ b/osse-web/src/app/shared/ui/track-matrix/track-info.ts @@ -0,0 +1,22 @@ +/** + * Available fields to show in a track matrix UI. +*/ +export enum TrackField { + TrackNumber, + Title, + Artist, + Duration +} + +/** + * Track fields wrapper. +*/ +export class TrackInfo { + public static allFields() { + return [TrackField.TrackNumber, TrackField.Title, TrackField.Artist, TrackField.Duration]; + } + + public static default() { + return [TrackField.Title, TrackField.Artist, TrackField.Duration]; + } +} diff --git a/osse-web/src/app/shared/ui/track-matrix/track-matrix-click.enum.ts b/osse-web/src/app/shared/ui/track-matrix/track-matrix-click.enum.ts new file mode 100644 index 0000000..c4b396e --- /dev/null +++ b/osse-web/src/app/shared/ui/track-matrix/track-matrix-click.enum.ts @@ -0,0 +1,4 @@ +export enum TrackMatricClick { + Normal, + Ctrl +} diff --git a/osse-web/src/app/shared/ui/track-matrix/track-matrix-mode.enum.ts b/osse-web/src/app/shared/ui/track-matrix/track-matrix-mode.enum.ts new file mode 100644 index 0000000..9068311 --- /dev/null +++ b/osse-web/src/app/shared/ui/track-matrix/track-matrix-mode.enum.ts @@ -0,0 +1,4 @@ +export enum TrackMatrixMode { + View, + Select, +} diff --git a/osse-web/src/app/shared/ui/track-matrix/track-matrix.component.html b/osse-web/src/app/shared/ui/track-matrix/track-matrix.component.html new file mode 100644 index 0000000..3706a92 --- /dev/null +++ b/osse-web/src/app/shared/ui/track-matrix/track-matrix.component.html @@ -0,0 +1,9 @@ +
+ @for (track of tracks(); track track.uuid) { + + } @empty { +

No tracks found.

+ } +
diff --git a/osse-web/src/app/shared/ui/track-matrix/track-matrix.component.ts b/osse-web/src/app/shared/ui/track-matrix/track-matrix.component.ts new file mode 100644 index 0000000..af8845f --- /dev/null +++ b/osse-web/src/app/shared/ui/track-matrix/track-matrix.component.ts @@ -0,0 +1,65 @@ +import { Component, ViewChildren, WritableSignal, input, output, signal } from '@angular/core'; +import { Track } from '../../services/track/track'; +import { MatrixItemComponent } from './matrix-item/matrix-item.component'; +import { TrackMatrixMode } from './track-matrix-mode.enum'; +import { TrackField, TrackInfo } from './track-info'; + +@Component({ + selector: 'app-track-matrix', + imports: [MatrixItemComponent], + templateUrl: './track-matrix.component.html', + styles: `` +}) +export class TrackMatrixComponent { + @ViewChildren(MatrixItemComponent) items!: MatrixItemComponent[]; + public tracks = input([]); + + public selectedTracks: Track[] = []; + public mode: WritableSignal = signal(TrackMatrixMode.View); + public visibleTrackInfo: WritableSignal = signal(TrackInfo.default()); + + public onClick = output(); + public onModeChanged = output(); + public onEmptySelection = output(); public onTrackSelected = output(); + + public toggleSelect(selected: boolean, track: Track) { + if (selected) { + // If a CTRL click happened and the mode is view, switch to edit and select it. + if (this.mode() == TrackMatrixMode.View) { + this.setMode(TrackMatrixMode.Select); + } + + this.selectedTracks.push(track); + this.onTrackSelected.emit(track); + } else { + this.selectedTracks = this.selectedTracks.filter((t) => t.uuid != track.uuid); + } + + this.checkForEmptySelection(); + } + + public setMode(mode: TrackMatrixMode) { + this.mode.set(mode); + this.onModeChanged.emit(this.mode()); + } + + public clearSelectedTracks() { + this.selectedTracks = []; + this.items.forEach((i) => i.setSelected(false)); + this.onEmptySelection.emit(); + } + + private checkForEmptySelection() { + if (this.selectedTracks.length == 0) { + this.onEmptySelection.emit(); + } + } + + public getSelectedTracks() { + return this.selectedTracks; + } + + public setVisibleFields(fields: TrackField[]) { + this.visibleTrackInfo.set(fields); + } +} diff --git a/osse-web/src/app/shared/util/fetcher.ts b/osse-web/src/app/shared/util/fetcher.ts new file mode 100644 index 0000000..ed29d1e --- /dev/null +++ b/osse-web/src/app/shared/util/fetcher.ts @@ -0,0 +1,63 @@ +import { LocatorService } from "../../locator.service"; +import { ConfigService } from "../services/config/config.service"; + +export async function fetcher(url: string, args: Partial = { method: 'GET', headers: [], body: null, rootURL: null }): Promise { + let token = ''; + try { + token = await getCSRFToken() as string; + } catch (e) { + console.error('Failed to get CSRF Token. Check the the server is running and that the URL is correct'); + throw 'CSRF Error'; + } + + // Add the XSRF token to the header list + let headers = new Headers(args.headers); + headers.append('X-XSRF-TOKEN', decodeURIComponent(token)); + headers.append('Content-Type', 'application/json'); + headers.append('Accept', 'application/json'); + + return fetch((args.rootURL ?? LocatorService.injector.get(ConfigService).get('apiURL')) + url, { + method: args.method, + headers: headers, + body: args.body, + credentials: 'include' + }); +} + +export async function getCSRFToken() { + let token = getCookie('XSRF-TOKEN'); + + if (!token) { + console.log('Fetching CSRF token.'); + let res = await fetch(LocatorService.injector.get(ConfigService).get('apiURL') + 'sanctum/csrf-cookie', { + credentials: 'include', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + }); + + if (res.ok) { + console.log('Fetched token!'); + return getCookie('XSRF-TOKEN'); + } + + throw 'CSRF Error.' + } + + return token; +} + +export function getCookie(name: string) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(';').shift(); + return ''; +} + +export type FetcherArgs = { + method: string, + headers: HeadersInit, + body: BodyInit | null, + rootURL: string | null +} diff --git a/osse-web/src/app/shared/util/time.ts b/osse-web/src/app/shared/util/time.ts new file mode 100644 index 0000000..c596704 --- /dev/null +++ b/osse-web/src/app/shared/util/time.ts @@ -0,0 +1,19 @@ +/** + * Returns time in a duration format. + * 0:04 +*/ +export function getNicelyFormattedTime(seconds: number): string { + if (seconds < 0) { + return '0:00'; + } + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; + } else { + return `${minutes}:${String(secs).padStart(2, '0')}`; + } +} diff --git a/osse-web/src/app/toast-container/toast-container.component.css b/osse-web/src/app/toast-container/toast-container.component.css new file mode 100644 index 0000000..a53ad6d --- /dev/null +++ b/osse-web/src/app/toast-container/toast-container.component.css @@ -0,0 +1,14 @@ +@keyframes toast { + from { + width: 100%; + } + + to { + width: 0%; + } +} + +.toast-animation-bar { + animation-name: toast; + animation-timing-function: linear; +} diff --git a/osse-web/src/app/toast-container/toast-container.component.html b/osse-web/src/app/toast-container/toast-container.component.html new file mode 100644 index 0000000..008c589 --- /dev/null +++ b/osse-web/src/app/toast-container/toast-container.component.html @@ -0,0 +1,18 @@ +
+ @for (toast of toastService.toasts(); track toast.id) { + + + + } +
diff --git a/osse-web/src/app/toast-container/toast-container.component.ts b/osse-web/src/app/toast-container/toast-container.component.ts new file mode 100644 index 0000000..65143a6 --- /dev/null +++ b/osse-web/src/app/toast-container/toast-container.component.ts @@ -0,0 +1,18 @@ +import { Component } from '@angular/core'; +import { NotifyType, ToastService } from './toast.service'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-toast-container', + imports: [CommonModule], + templateUrl: './toast-container.component.html', + styleUrl: `./toast-container.component.css` +}) +export class ToastContainerComponent { + constructor(public toastService: ToastService) { } + public NotifyType = NotifyType; + + public removeToast(id: string) { + this.toastService.removeToast(id); + } +} diff --git a/osse-web/src/app/toast-container/toast.service.ts b/osse-web/src/app/toast-container/toast.service.ts new file mode 100644 index 0000000..996a44e --- /dev/null +++ b/osse-web/src/app/toast-container/toast.service.ts @@ -0,0 +1,51 @@ +import { effect, Injectable, signal } from '@angular/core'; +import { v4 as uuid } from "uuid"; + +@Injectable({ + providedIn: 'root' +}) +export class ToastService { + public toasts = signal([]); + constructor() { + // When a toast is closed, remove it after 400ms (this gives time for the opacity animation) + effect(() => { + this.toasts(); + setTimeout(() => this.toasts.update((arr) => arr.filter((t) => !t.closed)), 400); + }) + } + + public info(message: string) { + let toast = new ToastMessage(message, NotifyType.Info, uuid(), 6) + this.toasts.update((t) => [...t, toast]); + setTimeout(() => this.removeToast(toast.id), 6000); + } + + public error(message: string) { + let toast = new ToastMessage(message, NotifyType.Error, uuid(), 7); + this.toasts.update((t) => [...t, toast]); + setTimeout(() => this.removeToast(toast.id), 7000); + console.error(toast); + } + + public removeToast(id: string) { + this.toasts.update((arr) => arr.map((t) => { + if ((t.id) == id) { + t.closed = true; + } + + return t; + })); + } +} + +export class ToastMessage { + public closed: boolean = false; + + constructor(public message: string, public type: NotifyType, public id: string, public duration: number = 6) { } +} + +export enum NotifyType { + Info, + Error +} + diff --git a/osse-web/src/app/track-list/track-list.component.html b/osse-web/src/app/track-list/track-list.component.html new file mode 100644 index 0000000..ade4851 --- /dev/null +++ b/osse-web/src/app/track-list/track-list.component.html @@ -0,0 +1,38 @@ + +
+
+
+
+ +
+
+
+

Start typing to view results. Press enter to add all visible tracks. Click on a track to play + it, or right click (hold on mobile) to select multiple tracks. +

+ +
+
+
+ + + +
+ +
+ +
+
+
+
diff --git a/osse-web/src/app/track-list/track-list.component.ts b/osse-web/src/app/track-list/track-list.component.ts new file mode 100644 index 0000000..0360fa0 --- /dev/null +++ b/osse-web/src/app/track-list/track-list.component.ts @@ -0,0 +1,174 @@ +import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, OnDestroy, OnInit, ViewChild, WritableSignal, signal } from '@angular/core'; +import { TrackService } from '../shared/services/track/track.service'; +import { HeaderComponent } from '../shared/ui/header/header.component'; +import { Track } from '../shared/services/track/track'; +import { ToastService } from '../toast-container/toast.service'; +import { fetcher } from '../shared/util/fetcher'; +import { debounceTime, fromEvent, Subscription } from 'rxjs'; +import { TrackMatrixComponent } from '../shared/ui/track-matrix/track-matrix.component'; +import { TrackMatrixMode } from '../shared/ui/track-matrix/track-matrix-mode.enum'; import { IconComponent } from "../shared/ui/icon/icon.component"; +import { mdiClose, mdiPencil, mdiPlaylistPlay } from '@mdi/js'; +import { CommonModule } from '@angular/common'; +import { ModalService } from '../shared/ui/modal/modal.service'; +import { AddToPlaylistFactoryComponent } from '../shared/ui/modals/add-to-playlist-factory/add-to-playlist-factory.component'; + +@Component({ + selector: 'app-track-list', + templateUrl: './track-list.component.html', + styles: ``, + imports: [HeaderComponent, TrackMatrixComponent, IconComponent, CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TrackListComponent implements AfterViewInit, OnInit, OnDestroy { + @ViewChild('search') searchBar!: ElementRef; + @ViewChild(TrackMatrixComponent) matrix!: TrackMatrixComponent; + + public loading: WritableSignal = signal(true); + public tracks: WritableSignal = signal([]); + public editing: WritableSignal = signal(false); + private allTracks: Track[] = []; + private timeout: number = 0; + private scrollSubscription!: Subscription; + + pencil = mdiPencil; + close = mdiClose; + play = mdiPlaylistPlay; + + constructor( + private trackService: TrackService, + private notificationService: ToastService, + private modalService: ModalService + ) { } + + ngAfterViewInit(): void { + this.searchBar.nativeElement.focus(); + } + + async ngOnInit(): Promise { + // Listen for scroll events + this.scrollSubscription = fromEvent(window, 'scroll') + .pipe(debounceTime(300)) + .subscribe(() => { + const endOfPage = window.innerHeight + window.pageYOffset >= (document.body.offsetHeight * 0.6); + if (endOfPage) { + this.requestTracks(this.searchBar.nativeElement.value); + } + }) + + // On load, get the first 75 tracks + let req = await fetcher('tracks/search'); + this.loading.set(false); + if (!req.ok) return; + + let tracks = await req.json(); + tracks.forEach((track: any) => { + this.allTracks.push(new Track(track)); + }); + this.tracks.set(this.allTracks); + } + + public onSubmit() { + for (let track of this.tracks()) { + this.trackService.addTrack(track); + } + this.notificationService.info('Added ' + this.tracks().length + ' tracks'); + } + + public addTrack(track: Track) { + this.trackService.addTrack(track); + this.notificationService.info('Added ' + track.title); + } + + public playSelectedTracks() { + let tracks = this.matrix.getSelectedTracks(); + for (const track of tracks) { + this.trackService.addTrack(track); + } + + this.notificationService.info('Added ' + tracks.length + ' tracks.'); + } + + public addSelectedTracksToPlaylist() { + this.modalService.setDynamicModal(AddToPlaylistFactoryComponent, [{ + name: 'tracks', + val: this.matrix.getSelectedTracks() + }], 'Add to Playlist'); + this.modalService.show(); + } + + public async onInput(ev: any) { + // Search for tracks. We made a debounce which waits 500ms before sending. + // Makes it a little easier on the server. + + // If the search input is empty, reset the filter + if (ev.target.value.length == 0) { + this.tracks.set(this.allTracks); + } + + clearTimeout(this.timeout); + this.timeout = setTimeout(async () => { + // Don't search for empty string + if (ev.target.value.trim() == '') return; + this.requestTracks(ev.target.value.trim()); + }, 500); + } + + public async requestTracks(search: string) { + // Find the amount of track that we have that match the regex. + let offset = 0; + + if (search.length == 0) { + offset = this.tracks().length; + } else { + const regex = new RegExp('%' + search + "%"); + this.tracks().forEach(val => { + if (regex.test(val.title)) { + offset += 1; + } + }); + if (offset < 75) { + offset = 0; + } + } + + // Search for tracks + this.loading.set(true); + let req = await fetcher('tracks/search?' + + new URLSearchParams({ + track: search, + track_offset: offset.toString() + }).toString()); + this.loading.set(false); + if (!req.ok && req.status == 200) return; + + let json = await req.json(); + for (let track of json) { + if (this.allTracks.some(v => v.id == track.id)) continue; + this.allTracks.push(new Track(track)); + } + this.tracks.set(this.getMatchingTracks(search)); + } + + public getMatchingTracks(search: string): Track[] { + let regex = new RegExp(search, 'i'); + return this.allTracks.filter((v) => regex.test(v.track.title)); + } + + public handleModeChange(mode: TrackMatrixMode) { + if (mode == TrackMatrixMode.Select) { + this.editing.set(true); + } else { + this.editing.set(false); + } + } + + public handleEmptySelection() { + this.matrix.setMode(TrackMatrixMode.View); + } + + ngOnDestroy(): void { + if (this.scrollSubscription) { + this.scrollSubscription.unsubscribe(); + } + } +} diff --git a/osse-web/src/assets/.gitkeep b/osse-web/src/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/osse-web/src/assets/icons/android-chrome-192x192.png b/osse-web/src/assets/icons/android-chrome-192x192.png new file mode 100644 index 0000000..3cd1149 Binary files /dev/null and b/osse-web/src/assets/icons/android-chrome-192x192.png differ diff --git a/osse-web/src/assets/icons/android-chrome-512x512.png b/osse-web/src/assets/icons/android-chrome-512x512.png new file mode 100644 index 0000000..ae36a12 Binary files /dev/null and b/osse-web/src/assets/icons/android-chrome-512x512.png differ diff --git a/osse-web/src/assets/icons/apple-touch-icon.png b/osse-web/src/assets/icons/apple-touch-icon.png new file mode 100644 index 0000000..7a12f87 Binary files /dev/null and b/osse-web/src/assets/icons/apple-touch-icon.png differ diff --git a/osse-web/src/assets/icons/favicon-16x16.png b/osse-web/src/assets/icons/favicon-16x16.png new file mode 100644 index 0000000..c1a0797 Binary files /dev/null and b/osse-web/src/assets/icons/favicon-16x16.png differ diff --git a/osse-web/src/assets/icons/favicon-32x32.png b/osse-web/src/assets/icons/favicon-32x32.png new file mode 100644 index 0000000..071b9da Binary files /dev/null and b/osse-web/src/assets/icons/favicon-32x32.png differ diff --git a/osse-web/src/assets/icons/favicon.ico b/osse-web/src/assets/icons/favicon.ico new file mode 100644 index 0000000..d1e77e6 Binary files /dev/null and b/osse-web/src/assets/icons/favicon.ico differ diff --git a/osse-web/src/assets/icons/site.webmanifest b/osse-web/src/assets/icons/site.webmanifest new file mode 100644 index 0000000..7ff3d3a --- /dev/null +++ b/osse-web/src/assets/icons/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/assets/icons/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/assets/icons/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#1e293b","display":"standalone"} diff --git a/osse-web/src/assets/img/osse.webp b/osse-web/src/assets/img/osse.webp new file mode 100644 index 0000000..10286cb Binary files /dev/null and b/osse-web/src/assets/img/osse.webp differ diff --git a/osse-web/src/environments/environment.prod.ts b/osse-web/src/environments/environment.prod.ts new file mode 100644 index 0000000..3a0a9fc --- /dev/null +++ b/osse-web/src/environments/environment.prod.ts @@ -0,0 +1,8 @@ +export const environment = { + version: '0.0.1', + showCoverBackgrounds: true, + showVisualizer: true, + visualizerSamples: 1, + queue: true, + userID: -1, +} diff --git a/osse-web/src/environments/environment.ts b/osse-web/src/environments/environment.ts new file mode 100644 index 0000000..d0601a5 --- /dev/null +++ b/osse-web/src/environments/environment.ts @@ -0,0 +1,8 @@ +export const environment = { + version: 'dev', + showCoverBackgrounds: true, + showVisualizer: true, + visualizerSamples: 1, + queue: true, + userID: -1, +} diff --git a/osse-web/src/index.html b/osse-web/src/index.html new file mode 100644 index 0000000..30edce4 --- /dev/null +++ b/osse-web/src/index.html @@ -0,0 +1,19 @@ + + + + + + Osse + + + + + + + + + + + + + diff --git a/osse-web/src/main.ts b/osse-web/src/main.ts new file mode 100644 index 0000000..f77061d --- /dev/null +++ b/osse-web/src/main.ts @@ -0,0 +1,8 @@ +import { provideZonelessChangeDetection } from "@angular/core"; +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +// standalone bootstrap +bootstrapApplication(AppComponent, {...appConfig, providers: [provideZonelessChangeDetection(), ...appConfig.providers]}) + .catch((err) => console.error(err)) diff --git a/osse-web/src/styles.css b/osse-web/src/styles.css new file mode 100644 index 0000000..1ae8963 --- /dev/null +++ b/osse-web/src/styles.css @@ -0,0 +1,70 @@ +/* You can add global styles to this file, and also import other style files */ +@import "tailwindcss"; + +@plugin "daisyui" { + themes: night --default; +} + +@layer utilities { + .drop-shadow-glow { + --tw-drop-shadow: drop-shadow(0 0px 10px rgba(255, 255, 255, 0.35)) drop-shadow(0 0px 35px rgba(255, 255, 255, 0.2)); + --tw-filter: var(--tw-drop-shadow); + filter: var(--tw-filter); + } +} + +@layer base { + ::placeholder { + @apply text-slate-400 opacity-70; + } +} + +html { + @apply bg-inherit; +} + +:root { + --tw-text-base-size: 1.05rem; + --color-primary: HSL(165, 50%, 55%); +} + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + vertical-align: text-bottom !important; +} + +.loading-matrix { + @apply animate-pulse pointer-events-none; +} + +dialog::backdrop { + background-color: black !important; + opacity: 0.7 !important; + padding: 0; + margin: 0; +} + +button { + cursor: pointer; +} + +input.input:focus { + outline: 1px solid oklch(90.5% 0.093 164.15) !important; + border-color: transparent; +} + +.toggle:checked+.label { + color: white; + transition: color 0.3s ease; +} + +.toggle:not(:checked)+.label { + color: color-mix(in oklab, currentColor 85%, transparent); + transition: color 0.3s ease; +} diff --git a/osse-web/tsconfig.app.json b/osse-web/tsconfig.app.json new file mode 100644 index 0000000..374cc9d --- /dev/null +++ b/osse-web/tsconfig.app.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/osse-web/tsconfig.json b/osse-web/tsconfig.json new file mode 100644 index 0000000..84fdc5b --- /dev/null +++ b/osse-web/tsconfig.json @@ -0,0 +1,28 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/osse-web/tsconfig.spec.json b/osse-web/tsconfig.spec.json new file mode 100644 index 0000000..be7e9da --- /dev/null +++ b/osse-web/tsconfig.spec.json @@ -0,0 +1,14 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/osse.sh b/osse.sh new file mode 100755 index 0000000..4444356 --- /dev/null +++ b/osse.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +echo -e "\nšŸŽµ Osse Music Server\n" + +if [ "$EUID" -ne 0 ]; then + echo "šŸ” Osse needs elevated privileges to bind to ports 80/443 and to give the web server write access to osse config." + echo "You may be prompted for your (sudo) password." + sudo -v +fi + +if [ ! -f .env ]; then + echo "āŒ Missing .env file. A file will be copied from the example for you." + cp .env.example .env + echo "File copied!" +fi + +set -a +source .env +set +a + +# Make sure osse config dir exists +eval "LARAVEL_STORAGE_PATH=$LARAVEL_STORAGE_PATH" +DB_DATABASE="$LARAVEL_STORAGE_PATH/database.sqlite" +mkdir $LARAVEL_STORAGE_PATH -p +mkdir "$LARAVEL_STORAGE_PATH"/storage -p +mkdir "$LARAVEL_STORAGE_PATH"/logs -p +touch "$LARAVEL_STORAGE_PATH"/logs/laravel.log +mkdir "$LARAVEL_STORAGE_PATH"/framework/cache -p +mkdir "$LARAVEL_STORAGE_PATH"/framework/sessions -p +mkdir "$LARAVEL_STORAGE_PATH"/framework/views -p +touch "$DB_DATABASE" + +# Make them read/writable +# TODO: Figure out which user needs what access. +sudo chmod -R 755 "$LARAVEL_STORAGE_PATH" + +require() { + command -v "$1" >/dev/null 2>&1 || { + echo "āŒ $1 is required" + echo "šŸ‘‰ You can install it here: $2" + exit 1 + } +} + +require frankenphp "https://frankenphp.dev/docs/#getting-started" +require node "https://nodejs.org" +require go "https://go.dev/dl" + +PNPM=$(command -v pnpm || command -v npm) +[ -z "$PNPM" ] && echo "āŒ pnpm or npm required" && exit 1 + +PIDS=() + +cleanup() { + echo "šŸ›‘ Stopping Osse" + for pid in "${PIDS[@]}"; do + kill "$pid" 2>/dev/null || true + done +} +trap cleanup EXIT INT TERM + +generate_caddy() { + echo "🧩 Generating Caddyfile" + rm -f Caddyfile + envsubst < deployment/Caddyfile.template > Caddyfile + + # Use angular dev server in dev, use build files in prod. + if [ "$OSSE_ENV" = "dev" ]; then + REPLACE="reverse_proxy http://localhost:4200" + else + REPLACE="try_files {path} /index.html\nfile_server" + fi + sed -i "s+WEB_FRONTEND_TEMPLATE+$REPLACE+" Caddyfile +} + +start_broadcast() { + echo "šŸ“” Starting broadcast server" + (cd osse-broadcast && go run .) & + PIDS+=($!) +} + +start_frontend_dev() { + echo "🌐 Starting frontend dev server" + (cd osse-web && $PNPM run start) & + PIDS+=($!) +} + +copy_api_env() { + echo "šŸ—ļø Building API" + + # If the file exists, get the encryption key to add to the new file + OSSE_ENCRYPTION_KEY="" + if [ -e osse-core/.env ]; then + echo "ENV Exists. Copying APP_KEY to new file..." + OSSE_ENCRYPTION_KEY="$(grep '^APP_KEY' osse-core/.env)" + fi + + # Remove the old file if one exists + rm -f osse-core/.env + + # Copy the example .env + cp osse-core/.env.example osse-core/.env + + # Replace the app key + if [ -z "$OSSE_ENCRYPTION_KEY" ]; then + (cd osse-core && frankenphp php-cli artisan key:generate) + else + sed -i "s+APP_KEY.*+$OSSE_ENCRYPTION_KEY+" osse-core/.env + sed -i "s+APP_ENV.*+APP_ENV=$OSSE_ENV+" osse-core/.env + fi + + # Add user vars to end of api env + echo -e "OSSE_DIRECTORIES=\"$OSSE_DIRECTORIES\"\nALLOW_REGISTRATION=\"$OSSE_ALLOW_REGISTRATION\"\nLARAVEL_STORAGE_PATH=\"$LARAVEL_STORAGE_PATH\"\nDB_DATABASE=\"$DB_DATABASE\"" >> osse-core/.env + echo "Osse .env file generated" +} + +optimize_api() { + (cd osse-core && frankenphp php-cli artisan config:cache) + (cd osse-core && frankenphp php-cli artisan config:optimize) +} + +run_api_migrations() { + (cd osse-core && frankenphp php-cli artisan migrate) +} + +build_frontend() { + echo "šŸ—ļø Building frontend" + (cd osse-web && $PNPM i && $PNPM run build) +} + +build_broadcast() { + echo 'šŸ—ļø Building broadcast server' + (cd osse-broadcast && go mod tidy && go build -o bin/osse-broadcast) + echo 'Finished building broadcast server' +} + +start_frankenphp() { + echo "šŸš€ Starting Frankenphp (Web Server)" + sudo frankenphp run --config Caddyfile & + PIDS+=($!) +} + +run_broadcast() { + echo "šŸ“” Starting broadcast server" + (cd osse-broadcast && ./bin/osse-broadcast) & + PIDS+=($!) +} + +run_api_queue() { + # Osse queue needs a special ini file to increase memory limit. + echo "šŸš€ Starting Osse Queue" + (PHP_INI_SCAN_DIR="$PWD/deployment" cd osse-core && frankenphp php-cli artisan queue:work --timeout=0 --memory=2048) + PIDS+=($!) +} + +case "$1" in + dev) + copy_api_env + generate_caddy + run_api_migrations + start_broadcast + start_frontend_dev + start_frankenphp + run_api_queue + wait + ;; + run) + run_api_migrations + optimize_api + generate_caddy + run_broadcast + start_frankenphp + run_api_queue + wait + ;; + build) + build_frontend + build_broadcast + copy_api_env + ;; + # Access php cli + php-cli) + cd osse-core && frankenphp php-cli "${@:2}" + ;; + *) + echo "Usage: ./osse {dev|run|build}" + exit 1 + ;; +esac +