commit 3b2ae530a56a94697871b433573db886d1e4d29f Author: Linrador <68631622+Linrador@users.noreply.github.com> Date: Tue May 12 15:29:56 2026 +0200 update diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..4781d23 --- /dev/null +++ b/backend/.env @@ -0,0 +1,14 @@ +PORT=8080 +DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable +JWT_SECRET=tegvideo7010! +FRONTEND_ORIGIN=http://localhost:5173 + +ADMIN_USERNAME=admin +ADMIN_DISPLAY_NAME=Administrator +ADMIN_EMAIL=admin@example.com +ADMIN_UNIT=Administration +ADMIN_GROUP=admin +ADMIN_RIGHTS=admin,users:read,users:write +ADMIN_TEAM_NAME=Administration +ADMIN_TEAM_INITIAL=A +ADMIN_TEAM_HREF=# \ No newline at end of file diff --git a/backend/cors.go b/backend/cors.go new file mode 100644 index 0000000..6253cd3 --- /dev/null +++ b/backend/cors.go @@ -0,0 +1,25 @@ +// backend\cors.go + +package main + +import "net/http" + +func (s *Server) cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + if origin == s.frontendOrigin { + w.Header().Set("Access-Control-Allow-Origin", s.frontendOrigin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS") + } + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/backend/devices.go b/backend/devices.go new file mode 100644 index 0000000..7c2ddcc --- /dev/null +++ b/backend/devices.go @@ -0,0 +1,561 @@ +// backend/devices.go + +package main + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type Device struct { + ID string `json:"id"` + InventoryNumber string `json:"inventoryNumber"` + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` + SerialNumber string `json:"serialNumber"` + MacAddress string `json:"macAddress"` + Location string `json:"location"` + LoanStatus string `json:"loanStatus"` + Comment string `json:"comment"` + RelatedDevices []DeviceShort `json:"relatedDevices"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type DeviceShort struct { + ID string `json:"id"` + InventoryNumber string `json:"inventoryNumber"` + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` +} + +type CreateDeviceRequest struct { + InventoryNumber string `json:"inventoryNumber"` + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` + SerialNumber string `json:"serialNumber"` + MacAddress string `json:"macAddress"` + Location string `json:"location"` + LoanStatus string `json:"loanStatus"` + Comment string `json:"comment"` + RelatedDeviceIDs []string `json:"relatedDeviceIds"` +} + +type UpdateDeviceRequest = CreateDeviceRequest + +var errDeviceNotFound = errors.New("device does not exist") + +func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.handleListDevices(w, r) + case http.MethodPost: + s.handleCreateDevice(w, r) + default: + writeError(w, http.StatusMethodNotAllowed, "Method not allowed") + } +} + +func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { + rows, err := s.db.Query( + r.Context(), + ` + SELECT + id, + inventory_number, + manufacturer, + model, + serial_number, + mac_address, + location, + loan_status, + comment, + created_at, + updated_at + FROM devices + ORDER BY inventory_number ASC + `, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not load devices") + return + } + defer rows.Close() + + devices := make([]Device, 0) + deviceMap := map[string]*Device{} + + for rows.Next() { + var device Device + + if err := rows.Scan( + &device.ID, + &device.InventoryNumber, + &device.Manufacturer, + &device.Model, + &device.SerialNumber, + &device.MacAddress, + &device.Location, + &device.LoanStatus, + &device.Comment, + &device.CreatedAt, + &device.UpdatedAt, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Could not read devices") + return + } + + device.RelatedDevices = []DeviceShort{} + + devices = append(devices, device) + deviceMap[device.ID] = &devices[len(devices)-1] + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Could not read devices") + return + } + + relationRows, err := s.db.Query( + r.Context(), + ` + SELECT + device_relations.device_id, + related_devices.id, + related_devices.inventory_number, + related_devices.manufacturer, + related_devices.model + FROM device_relations + INNER JOIN devices AS related_devices + ON related_devices.id = device_relations.related_device_id + ORDER BY related_devices.inventory_number ASC + `, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not load device relations") + return + } + defer relationRows.Close() + + for relationRows.Next() { + var deviceID string + var relatedDevice DeviceShort + + if err := relationRows.Scan( + &deviceID, + &relatedDevice.ID, + &relatedDevice.InventoryNumber, + &relatedDevice.Manufacturer, + &relatedDevice.Model, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Could not read device relations") + return + } + + device, exists := deviceMap[deviceID] + if exists { + device.RelatedDevices = append(device.RelatedDevices, relatedDevice) + } + } + + if err := relationRows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Could not read device relations") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "devices": devices, + }) +} + +func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) { + var input CreateDeviceRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + normalizeDeviceInput(&input) + + if input.InventoryNumber == "" { + writeError(w, http.StatusBadRequest, "Inventur-Nr. ist erforderlich") + return + } + + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not create device") + return + } + defer tx.Rollback(r.Context()) + + var device Device + + err = tx.QueryRow( + r.Context(), + ` + INSERT INTO devices ( + inventory_number, + manufacturer, + model, + serial_number, + mac_address, + location, + loan_status, + comment + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING + id, + inventory_number, + manufacturer, + model, + serial_number, + mac_address, + location, + loan_status, + comment, + created_at, + updated_at + `, + input.InventoryNumber, + input.Manufacturer, + input.Model, + input.SerialNumber, + input.MacAddress, + input.Location, + input.LoanStatus, + input.Comment, + ).Scan( + &device.ID, + &device.InventoryNumber, + &device.Manufacturer, + &device.Model, + &device.SerialNumber, + &device.MacAddress, + &device.Location, + &device.LoanStatus, + &device.Comment, + &device.CreatedAt, + &device.UpdatedAt, + ) + + if err != nil { + var pgErr *pgconn.PgError + + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + writeError(w, http.StatusConflict, "Gerät mit dieser Inventur-Nr., Seriennummer oder MAC-Adresse existiert bereits") + return + } + + writeError(w, http.StatusInternalServerError, "Could not create device") + return + } + + if err := syncDeviceRelations(r.Context(), tx, device.ID, input.RelatedDeviceIDs); err != nil { + if errors.Is(err, errDeviceNotFound) { + writeError(w, http.StatusBadRequest, "Zugeordnetes Gerät wurde nicht gefunden") + return + } + + writeError(w, http.StatusInternalServerError, "Could not assign related device") + return + } + + changes, err := buildDeviceCreateHistoryChanges(r.Context(), tx, input) + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not create device history") + return + } + + if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "created", changes); err != nil { + writeError(w, http.StatusInternalServerError, "Could not create device history") + return + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Could not create device") + return + } + + device.RelatedDevices = []DeviceShort{} + + writeJSON(w, http.StatusCreated, map[string]any{ + "device": device, + }) +} + +func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { + deviceID := strings.TrimSpace(r.PathValue("id")) + + if deviceID == "" { + writeError(w, http.StatusBadRequest, "Geräte-ID fehlt") + return + } + + var input UpdateDeviceRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + normalizeDeviceInput(&input) + + if input.InventoryNumber == "" { + writeError(w, http.StatusBadRequest, "Inventur-Nr. ist erforderlich") + return + } + + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not update device") + return + } + defer tx.Rollback(r.Context()) + + oldDevice, oldRelatedDeviceIDs, err := getDeviceForHistory(r.Context(), tx, deviceID) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden") + return + } + + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not load device history state") + return + } + + var device Device + + err = tx.QueryRow( + r.Context(), + ` + UPDATE devices + SET + inventory_number = $2, + manufacturer = $3, + model = $4, + serial_number = $5, + mac_address = $6, + location = $7, + loan_status = $8, + comment = $9, + updated_at = now() + WHERE id = $1 + RETURNING + id, + inventory_number, + manufacturer, + model, + serial_number, + mac_address, + location, + loan_status, + comment, + created_at, + updated_at + `, + deviceID, + input.InventoryNumber, + input.Manufacturer, + input.Model, + input.SerialNumber, + input.MacAddress, + input.Location, + input.LoanStatus, + input.Comment, + ).Scan( + &device.ID, + &device.InventoryNumber, + &device.Manufacturer, + &device.Model, + &device.SerialNumber, + &device.MacAddress, + &device.Location, + &device.LoanStatus, + &device.Comment, + &device.CreatedAt, + &device.UpdatedAt, + ) + + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden") + return + } + + if err != nil { + var pgErr *pgconn.PgError + + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + writeError(w, http.StatusConflict, "Gerät mit dieser Inventur-Nr., Seriennummer oder MAC-Adresse existiert bereits") + return + } + + writeError(w, http.StatusInternalServerError, "Could not update device") + return + } + + if err := syncDeviceRelations(r.Context(), tx, device.ID, input.RelatedDeviceIDs); err != nil { + if errors.Is(err, errDeviceNotFound) { + writeError(w, http.StatusBadRequest, "Zugeordnetes Gerät wurde nicht gefunden") + return + } + + writeError(w, http.StatusInternalServerError, "Could not update device relations") + return + } + + changes, err := buildDeviceUpdateHistoryChanges(r.Context(), tx, oldDevice, oldRelatedDeviceIDs, input) + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not create device history") + return + } + + if len(changes) > 0 { + if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "updated", changes); err != nil { + writeError(w, http.StatusInternalServerError, "Could not create device history") + return + } + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Could not update device") + return + } + + device.RelatedDevices = []DeviceShort{} + + writeJSON(w, http.StatusOK, map[string]any{ + "device": device, + }) +} + +func ensureDeviceExists(ctx context.Context, tx pgx.Tx, deviceID string) error { + var exists bool + + err := tx.QueryRow( + ctx, + ` + SELECT EXISTS( + SELECT 1 + FROM devices + WHERE id = $1 + ) + `, + deviceID, + ).Scan(&exists) + + if err != nil { + return err + } + + if !exists { + return errDeviceNotFound + } + + return nil +} + +func normalizeDeviceInput(input *CreateDeviceRequest) { + input.InventoryNumber = strings.TrimSpace(input.InventoryNumber) + input.Manufacturer = strings.TrimSpace(input.Manufacturer) + input.Model = strings.TrimSpace(input.Model) + input.SerialNumber = strings.TrimSpace(input.SerialNumber) + input.MacAddress = strings.TrimSpace(input.MacAddress) + input.Location = strings.TrimSpace(input.Location) + input.LoanStatus = strings.TrimSpace(input.LoanStatus) + input.Comment = strings.TrimSpace(input.Comment) + + if input.LoanStatus == "" { + input.LoanStatus = "Verfügbar" + } +} + +func syncDeviceRelations(ctx context.Context, tx pgx.Tx, deviceID string, relatedDeviceIDs []string) error { + _, err := tx.Exec( + ctx, + ` + DELETE FROM device_relations + WHERE device_id = $1 OR related_device_id = $1 + `, + deviceID, + ) + + if err != nil { + return err + } + + seen := map[string]bool{} + + for _, relatedDeviceID := range relatedDeviceIDs { + relatedDeviceID = strings.TrimSpace(relatedDeviceID) + + if relatedDeviceID == "" || relatedDeviceID == deviceID { + continue + } + + if seen[relatedDeviceID] { + continue + } + + seen[relatedDeviceID] = true + + if err := ensureDeviceExists(ctx, tx, relatedDeviceID); err != nil { + return err + } + + if err := assignRelatedDevice(ctx, tx, deviceID, relatedDeviceID); err != nil { + return err + } + + if err := assignRelatedDevice(ctx, tx, relatedDeviceID, deviceID); err != nil { + return err + } + } + + return nil +} + +func assignRelatedDevice(ctx context.Context, tx pgx.Tx, deviceID string, relatedDeviceID string) error { + _, err := tx.Exec( + ctx, + ` + INSERT INTO device_relations ( + device_id, + related_device_id + ) + VALUES ($1, $2) + ON CONFLICT (device_id, related_device_id) DO NOTHING + `, + deviceID, + relatedDeviceID, + ) + + return err +} + +type deviceTransaction interface { + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..67bd43b --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,25 @@ +module main + +go 1.26.2 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/jackc/pgx/v5 v5.9.2 + golang.org/x/crypto v0.51.0 +) + +require ( + github.com/jackc/chunkreader/v2 v2.0.1 // indirect + github.com/jackc/pgio v1.0.0 // indirect + github.com/jackc/pgproto3/v2 v2.3.3 // indirect +) + +require ( + github.com/jackc/pgconn v1.14.3 + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/joho/godotenv v1.5.1 + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.37.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..419e055 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,43 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= +github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= +github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/history.go b/backend/history.go new file mode 100644 index 0000000..e6b2654 --- /dev/null +++ b/backend/history.go @@ -0,0 +1,377 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +type DeviceHistoryEntry struct { + ID string `json:"id"` + DeviceID string `json:"deviceId"` + UserID string `json:"userId"` + UserDisplayName string `json:"userDisplayName"` + Action string `json:"action"` + Changes []DeviceHistoryChange `json:"changes"` + CreatedAt time.Time `json:"createdAt"` +} + +type DeviceHistoryChange struct { + Field string `json:"field"` + Label string `json:"label"` + OldValue string `json:"oldValue"` + NewValue string `json:"newValue"` +} + +func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request) { + deviceID := strings.TrimSpace(r.PathValue("id")) + + if deviceID == "" { + writeError(w, http.StatusBadRequest, "Geräte-ID fehlt") + return + } + + rows, err := s.db.Query( + r.Context(), + ` + SELECT + h.id, + h.device_id, + COALESCE(h.user_id::TEXT, ''), + COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'), + h.action, + h.changes, + h.created_at + FROM device_history h + LEFT JOIN users u ON u.id = h.user_id + WHERE h.device_id = $1 + ORDER BY h.created_at DESC + `, + deviceID, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not load device history") + return + } + defer rows.Close() + + history := []DeviceHistoryEntry{} + + for rows.Next() { + var entry DeviceHistoryEntry + var changesRaw []byte + + if err := rows.Scan( + &entry.ID, + &entry.DeviceID, + &entry.UserID, + &entry.UserDisplayName, + &entry.Action, + &changesRaw, + &entry.CreatedAt, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Could not read device history") + return + } + + if len(changesRaw) > 0 { + if err := json.Unmarshal(changesRaw, &entry.Changes); err != nil { + writeError(w, http.StatusInternalServerError, "Could not parse device history") + return + } + } + + history = append(history, entry) + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Could not read device history") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "history": history, + }) +} + +func userFromContext(ctx context.Context) (User, bool) { + user, ok := ctx.Value(userContextKey).(User) + return user, ok +} + +func recordDeviceHistory( + ctx context.Context, + tx pgx.Tx, + deviceID string, + user User, + action string, + changes []DeviceHistoryChange, +) error { + changesJSON, err := json.Marshal(changes) + if err != nil { + return err + } + + _, err = tx.Exec( + ctx, + ` + INSERT INTO device_history ( + device_id, + user_id, + action, + changes + ) + VALUES ($1, $2, $3, $4::JSONB) + `, + deviceID, + user.ID, + action, + string(changesJSON), + ) + + return err +} + +func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Device, []string, error) { + var device Device + + err := tx.QueryRow( + ctx, + ` + SELECT + id, + inventory_number, + manufacturer, + model, + serial_number, + mac_address, + location, + loan_status, + comment, + created_at, + updated_at + FROM devices + WHERE id = $1 + LIMIT 1 + `, + deviceID, + ).Scan( + &device.ID, + &device.InventoryNumber, + &device.Manufacturer, + &device.Model, + &device.SerialNumber, + &device.MacAddress, + &device.Location, + &device.LoanStatus, + &device.Comment, + &device.CreatedAt, + &device.UpdatedAt, + ) + + if err != nil { + return device, nil, err + } + + rows, err := tx.Query( + ctx, + ` + SELECT related_device_id + FROM device_relations + WHERE device_id = $1 + ORDER BY related_device_id ASC + `, + deviceID, + ) + + if err != nil { + return device, nil, err + } + defer rows.Close() + + relatedDeviceIDs := []string{} + + for rows.Next() { + var relatedDeviceID string + + if err := rows.Scan(&relatedDeviceID); err != nil { + return device, nil, err + } + + relatedDeviceIDs = append(relatedDeviceIDs, relatedDeviceID) + } + + if err := rows.Err(); err != nil { + return device, nil, err + } + + return device, relatedDeviceIDs, nil +} + +func buildDeviceCreateHistoryChanges( + ctx context.Context, + tx pgx.Tx, + input CreateDeviceRequest, +) ([]DeviceHistoryChange, error) { + changes := []DeviceHistoryChange{} + + changes = appendHistoryChange(changes, "inventoryNumber", "Inventur-Nr.", "", input.InventoryNumber) + changes = appendHistoryChange(changes, "manufacturer", "Hersteller", "", input.Manufacturer) + changes = appendHistoryChange(changes, "model", "Modell", "", input.Model) + changes = appendHistoryChange(changes, "serialNumber", "Seriennummer", "", input.SerialNumber) + changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", "", input.MacAddress) + changes = appendHistoryChange(changes, "location", "Standort", "", input.Location) + changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", "", input.LoanStatus) + changes = appendHistoryChange(changes, "comment", "Kommentar", "", input.Comment) + + relatedDevices, err := formatRelatedDeviceLabels(ctx, tx, input.RelatedDeviceIDs) + if err != nil { + return nil, err + } + + changes = appendHistoryChange(changes, "relatedDeviceIds", "Zugeordnete Geräte / Zubehör", "", relatedDevices) + + return changes, nil +} + +func buildDeviceUpdateHistoryChanges( + ctx context.Context, + tx pgx.Tx, + oldDevice Device, + oldRelatedDeviceIDs []string, + input UpdateDeviceRequest, +) ([]DeviceHistoryChange, error) { + changes := []DeviceHistoryChange{} + + changes = appendHistoryChange(changes, "inventoryNumber", "Inventur-Nr.", oldDevice.InventoryNumber, input.InventoryNumber) + changes = appendHistoryChange(changes, "manufacturer", "Hersteller", oldDevice.Manufacturer, input.Manufacturer) + changes = appendHistoryChange(changes, "model", "Modell", oldDevice.Model, input.Model) + changes = appendHistoryChange(changes, "serialNumber", "Seriennummer", oldDevice.SerialNumber, input.SerialNumber) + changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", oldDevice.MacAddress, input.MacAddress) + changes = appendHistoryChange(changes, "location", "Standort", oldDevice.Location, input.Location) + changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", oldDevice.LoanStatus, input.LoanStatus) + changes = appendHistoryChange(changes, "comment", "Kommentar", oldDevice.Comment, input.Comment) + + oldRelatedDevices, err := formatRelatedDeviceLabels(ctx, tx, oldRelatedDeviceIDs) + if err != nil { + return nil, err + } + + newRelatedDevices, err := formatRelatedDeviceLabels(ctx, tx, input.RelatedDeviceIDs) + if err != nil { + return nil, err + } + + changes = appendHistoryChange( + changes, + "relatedDeviceIds", + "Zugeordnete Geräte / Zubehör", + oldRelatedDevices, + newRelatedDevices, + ) + + return changes, nil +} + +func appendHistoryChange( + changes []DeviceHistoryChange, + field string, + label string, + oldValue string, + newValue string, +) []DeviceHistoryChange { + oldValue = strings.TrimSpace(oldValue) + newValue = strings.TrimSpace(newValue) + + if oldValue == newValue { + return changes + } + + return append(changes, DeviceHistoryChange{ + Field: field, + Label: label, + OldValue: displayHistoryValue(oldValue), + NewValue: displayHistoryValue(newValue), + }) +} + +func displayHistoryValue(value string) string { + value = strings.TrimSpace(value) + + if value == "" { + return "-" + } + + return value +} + +func formatRelatedDeviceLabels(ctx context.Context, tx pgx.Tx, deviceIDs []string) (string, error) { + normalizedDeviceIDs := normalizeDeviceIDs(deviceIDs) + + if len(normalizedDeviceIDs) == 0 { + return "-", nil + } + + labels := []string{} + + for _, deviceID := range normalizedDeviceIDs { + var label string + + err := tx.QueryRow( + ctx, + ` + SELECT TRIM(CONCAT_WS(' ', inventory_number, NULLIF(manufacturer, ''), NULLIF(model, ''))) + FROM devices + WHERE id = $1 + LIMIT 1 + `, + deviceID, + ).Scan(&label) + + if errors.Is(err, pgx.ErrNoRows) { + return "", errDeviceNotFound + } + + if err != nil { + return "", err + } + + label = strings.TrimSpace(label) + + if label == "" { + label = deviceID + } + + labels = append(labels, label) + } + + return strings.Join(labels, ", "), nil +} + +func normalizeDeviceIDs(deviceIDs []string) []string { + seen := map[string]bool{} + result := []string{} + + for _, deviceID := range deviceIDs { + deviceID = strings.TrimSpace(deviceID) + + if deviceID == "" { + continue + } + + if seen[deviceID] { + continue + } + + seen[deviceID] = true + result = append(result, deviceID) + } + + return result +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..ff8a620 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,76 @@ +// backend\main.go + +package main + +import ( + "context" + "encoding/json" + "log" + "net/http" + "os" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/joho/godotenv" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Println("Keine .env Datei gefunden, nutze System-Environment oder Fallbacks") + } + + port := env("PORT", "8080") + databaseURL := env("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/teg?sslmode=disable") + jwtSecret := env("JWT_SECRET", "dev-secret-change-me") + frontendOrigin := env("FRONTEND_ORIGIN", "http://localhost:5173") + + ctx := context.Background() + + db, err := pgxpool.New(ctx, databaseURL) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + if err := db.Ping(ctx); err != nil { + log.Fatal(err) + } + + server := NewServer(db, jwtSecret, frontendOrigin) + + log.Printf("Backend läuft auf http://localhost:%s", port) + + if err := http.ListenAndServe(":"+port, server.Routes()); err != nil { + log.Fatal(err) + } +} + +func readJSON(r *http.Request, target any) error { + return json.NewDecoder(r.Body).Decode(target) +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{ + "error": message, + }) +} + +func normalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +func env(key string, fallback string) string { + value := os.Getenv(key) + + if value == "" { + return fallback + } + + return value +} diff --git a/backend/routes.go b/backend/routes.go new file mode 100644 index 0000000..c122b8a --- /dev/null +++ b/backend/routes.go @@ -0,0 +1,23 @@ +// backend\routes.go + +package main + +import "net/http" + +func (s *Server) Routes() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("GET /health", s.handleHealth) + + mux.HandleFunc("POST /auth/register", s.handleRegister) + mux.HandleFunc("POST /auth/login", s.handleLogin) + mux.HandleFunc("POST /auth/logout", s.handleLogout) + mux.HandleFunc("GET /auth/me", s.requireAuth(s.handleMe)) + + mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices)) + mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice)) + mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice)) + mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory)) + + return s.cors(mux) +} diff --git a/backend/server.go b/backend/server.go new file mode 100644 index 0000000..a44a688 --- /dev/null +++ b/backend/server.go @@ -0,0 +1,461 @@ +// backend\server.go + +package main + +import ( + "context" + "crypto/subtle" + "errors" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" +) + +type contextKey string + +const userContextKey contextKey = "user" + +type Server struct { + db *pgxpool.Pool + jwtSecret []byte + frontendOrigin string +} + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Unit string `json:"unit"` + Group string `json:"group"` + Rights []string `json:"rights"` + Teams []Team `json:"teams"` +} + +type Team struct { + ID string `json:"id"` + Name string `json:"name"` + Initial string `json:"initial"` + Href string `json:"href"` + Current bool `json:"current"` +} + +type RegisterRequest struct { + Username string `json:"username"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Password string `json:"password"` + Avatar string `json:"avatar"` + Unit string `json:"unit"` + Group string `json:"group"` + Rights []string `json:"rights"` +} + +type LoginRequest struct { + Identifier string `json:"identifier"` + Password string `json:"password"` +} + +type Claims struct { + UserID string `json:"user_id"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +func NewServer(db *pgxpool.Pool, jwtSecret string, frontendOrigin string) *Server { + return &Server{ + db: db, + jwtSecret: []byte(jwtSecret), + frontendOrigin: frontendOrigin, + } +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ok", + }) +} + +func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { + var input RegisterRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + username := normalizeUsername(input.Username) + email := normalizeEmail(input.Email) + + if username == "" || email == "" || len(input.Password) < 8 { + writeError(w, http.StatusBadRequest, "Benutzername, E-Mail und Passwort mit mindestens 8 Zeichen sind erforderlich") + return + } + + displayName := strings.TrimSpace(input.DisplayName) + if displayName == "" { + displayName = username + } + + group := strings.TrimSpace(input.Group) + if group == "" { + group = "user" + } + + rights := input.Rights + if len(rights) == 0 { + rights = []string{"user"} + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not hash password") + return + } + + var user User + + err = s.db.QueryRow( + r.Context(), + ` + INSERT INTO users ( + username, + display_name, + email, + password_hash, + avatar, + unit, + user_group, + rights + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING + id, + COALESCE(username, ''), + COALESCE(display_name, ''), + email, + COALESCE(avatar, ''), + COALESCE(unit, ''), + COALESCE(user_group, ''), + COALESCE(rights, '{}'::TEXT[]) + `, + username, + displayName, + email, + string(passwordHash), + strings.TrimSpace(input.Avatar), + strings.TrimSpace(input.Unit), + group, + rights, + ).Scan( + &user.ID, + &user.Username, + &user.DisplayName, + &user.Email, + &user.Avatar, + &user.Unit, + &user.Group, + &user.Rights, + ) + + if err != nil { + writeError(w, http.StatusConflict, "User already exists") + return + } + + if err := s.setSessionCookie(w, user); err != nil { + writeError(w, http.StatusInternalServerError, "Could not create session") + return + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "user": user, + }) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + var input LoginRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + identifier := strings.ToLower(strings.TrimSpace(input.Identifier)) + + if identifier == "" || input.Password == "" { + writeError(w, http.StatusBadRequest, "Benutzername/E-Mail und Passwort sind erforderlich") + return + } + + user, passwordHash, err := s.getUserByIdentifier(r.Context(), identifier) + + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusUnauthorized, "Invalid username/email or password") + return + } + + if err != nil { + writeError(w, http.StatusInternalServerError, "Login failed") + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(input.Password)); err != nil { + writeError(w, http.StatusUnauthorized, "Invalid username/email or password") + return + } + + if err := s.setSessionCookie(w, user); err != nil { + writeError(w, http.StatusInternalServerError, "Could not create session") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "user": user, + }) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: false, + }) + + writeJSON(w, http.StatusOK, map[string]string{ + "message": "Logged out", + }) +} + +func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(userContextKey).(User) + + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "user": user, + }) +} + +func (s *Server) setSessionCookie(w http.ResponseWriter, user User) error { + now := time.Now() + + claims := Claims{ + UserID: user.ID, + Email: user.Email, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: user.ID, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + tokenString, err := token.SignedString(s.jwtSecret) + if err != nil { + return err + } + + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: tokenString, + Path: "/", + MaxAge: 60 * 60 * 24, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + + // In Produktion auf true setzen, wenn HTTPS aktiv ist. + Secure: false, + }) + + return nil +} + +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + if err != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + claims := &Claims{} + + token, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (any, error) { + if subtle.ConstantTimeCompare([]byte(token.Method.Alg()), []byte(jwt.SigningMethodHS256.Alg())) != 1 { + return nil, errors.New("unexpected signing method") + } + + return s.jwtSecret, nil + }) + + if err != nil || !token.Valid { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + user, err := s.getUserByID(r.Context(), claims.UserID) + if err != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + ctx := context.WithValue(r.Context(), userContextKey, user) + + next(w, r.WithContext(ctx)) + } +} + +func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (User, string, error) { + var user User + var passwordHash string + + err := s.db.QueryRow( + ctx, + ` + SELECT + id, + COALESCE(username, ''), + COALESCE(display_name, ''), + email, + password_hash, + COALESCE(avatar, ''), + COALESCE(unit, ''), + COALESCE(user_group, ''), + COALESCE(rights, '{}'::TEXT[]) + FROM users + WHERE lower(email) = $1 OR lower(username) = $1 + LIMIT 1 + `, + identifier, + ).Scan( + &user.ID, + &user.Username, + &user.DisplayName, + &user.Email, + &passwordHash, + &user.Avatar, + &user.Unit, + &user.Group, + &user.Rights, + ) + + if err != nil { + return user, passwordHash, err + } + + teams, err := s.getTeamsForUser(ctx, user.ID) + if err != nil { + return user, passwordHash, err + } + + user.Teams = teams + + return user, passwordHash, nil +} + +func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) { + var user User + + err := s.db.QueryRow( + ctx, + ` + SELECT + id, + COALESCE(username, ''), + COALESCE(display_name, ''), + email, + COALESCE(avatar, ''), + COALESCE(unit, ''), + COALESCE(user_group, ''), + COALESCE(rights, '{}'::TEXT[]) + FROM users + WHERE id = $1 + LIMIT 1 + `, + userID, + ).Scan( + &user.ID, + &user.Username, + &user.DisplayName, + &user.Email, + &user.Avatar, + &user.Unit, + &user.Group, + &user.Rights, + ) + + if err != nil { + return user, err + } + + teams, err := s.getTeamsForUser(ctx, user.ID) + if err != nil { + return user, err + } + + user.Teams = teams + + return user, nil +} + +func normalizeUsername(username string) string { + return strings.ToLower(strings.TrimSpace(username)) +} + +func (s *Server) getTeamsForUser(ctx context.Context, userID string) ([]Team, error) { + rows, err := s.db.Query( + ctx, + ` + SELECT + t.id, + t.name, + t.initial, + t.href + FROM teams t + INNER JOIN user_teams ut ON ut.team_id = t.id + WHERE ut.user_id = $1 + ORDER BY t.name ASC + `, + userID, + ) + + if err != nil { + return nil, err + } + + defer rows.Close() + + teams := []Team{} + + for rows.Next() { + var team Team + + if err := rows.Scan(&team.ID, &team.Name, &team.Initial, &team.Href); err != nil { + return nil, err + } + + team.Current = false + teams = append(teams, team) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return teams, nil +} diff --git a/backend/setup/main.go b/backend/setup/main.go new file mode 100644 index 0000000..4bf0da6 --- /dev/null +++ b/backend/setup/main.go @@ -0,0 +1,550 @@ +// backend\setup\main.go + +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "flag" + "fmt" + "log" + "net/url" + "os" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/joho/godotenv" + "golang.org/x/crypto/bcrypt" +) + +func main() { + reset := flag.Bool("reset", false, "Datenbank komplett löschen und neu anlegen") + flag.Parse() + + if err := godotenv.Load(".env", "backend/.env"); err != nil { + log.Println("Keine .env Datei gefunden, nutze System-Environment oder Fallbacks") + } + + databaseURL := env("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/teg?sslmode=disable") + + parsedURL, err := url.Parse(databaseURL) + if err != nil { + log.Fatal(err) + } + + databaseName := strings.TrimPrefix(parsedURL.Path, "/") + if databaseName == "" { + log.Fatal("DATABASE_URL enthält keinen Datenbanknamen") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if *reset { + if err := dropDatabaseIfExists(ctx, parsedURL, databaseName); err != nil { + log.Fatal(err) + } + } + + if err := createDatabaseIfNotExists(ctx, parsedURL, databaseName); err != nil { + log.Fatal(err) + } + + db, err := pgxpool.New(ctx, databaseURL) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + if err := db.Ping(ctx); err != nil { + log.Fatal(err) + } + + if err := createSchema(ctx, db); err != nil { + log.Fatal(err) + } + + password, created, err := createAdminIfNotExists(ctx, db) + if err != nil { + log.Fatal(err) + } + + printAdminResult(password, created) +} + +func dropDatabaseIfExists(ctx context.Context, parsedURL *url.URL, databaseName string) error { + if databaseName == "postgres" || databaseName == "template0" || databaseName == "template1" { + return fmt.Errorf("datenbank %q darf nicht per -reset gelöscht werden", databaseName) + } + + maintenanceURL := *parsedURL + maintenanceURL.Path = "/postgres" + + db, err := pgxpool.New(ctx, maintenanceURL.String()) + if err != nil { + return err + } + defer db.Close() + + var exists bool + + err = db.QueryRow( + ctx, + `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`, + databaseName, + ).Scan(&exists) + + if err != nil { + return err + } + + if !exists { + fmt.Printf("Datenbank %q existiert nicht, Reset übersprungen\n", databaseName) + return nil + } + + fmt.Printf("Datenbank %q wird zurückgesetzt...\n", databaseName) + + _, err = db.Exec( + ctx, + ` + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 + AND pid <> pg_backend_pid() + `, + databaseName, + ) + + if err != nil { + return err + } + + _, err = db.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdentifier(databaseName)) + if err != nil { + return err + } + + fmt.Printf("Datenbank %q wurde gelöscht\n", databaseName) + + return nil +} + +func createDatabaseIfNotExists(ctx context.Context, parsedURL *url.URL, databaseName string) error { + maintenanceURL := *parsedURL + maintenanceURL.Path = "/postgres" + + db, err := pgxpool.New(ctx, maintenanceURL.String()) + if err != nil { + return err + } + defer db.Close() + + var exists bool + + err = db.QueryRow( + ctx, + `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`, + databaseName, + ).Scan(&exists) + + if err != nil { + return err + } + + if exists { + fmt.Printf("Datenbank %q existiert bereits\n", databaseName) + return nil + } + + _, err = db.Exec(ctx, `CREATE DATABASE `+quoteIdentifier(databaseName)) + if err != nil { + return err + } + + fmt.Printf("Datenbank %q wurde erstellt\n", databaseName) + + return nil +} + +func createSchema(ctx context.Context, db *pgxpool.Pool) error { + queries := []string{ + `CREATE EXTENSION IF NOT EXISTS pgcrypto`, + + ` + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + username TEXT UNIQUE, + display_name TEXT, + + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + + avatar TEXT, + unit TEXT, + user_group TEXT, + rights TEXT[] NOT NULL DEFAULT '{}'::TEXT[], + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + + ` + CREATE TABLE IF NOT EXISTS teams ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + name TEXT NOT NULL UNIQUE, + initial TEXT NOT NULL, + href TEXT NOT NULL DEFAULT '#', + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + + ` + CREATE TABLE IF NOT EXISTS user_teams ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + PRIMARY KEY (user_id, team_id) + ) + `, + + ` + CREATE TABLE IF NOT EXISTS devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + inventory_number TEXT NOT NULL, + manufacturer TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + serial_number TEXT NOT NULL DEFAULT '', + mac_address TEXT NOT NULL DEFAULT '', + location TEXT NOT NULL DEFAULT '', + loan_status TEXT NOT NULL DEFAULT 'Verfügbar', + comment TEXT NOT NULL DEFAULT '', + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + + ` + CREATE TABLE IF NOT EXISTS device_relations ( + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + related_device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + PRIMARY KEY (device_id, related_device_id), + CONSTRAINT device_relations_no_self_reference CHECK (device_id <> related_device_id) + ) + `, + + ` + CREATE TABLE IF NOT EXISTS device_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + + action TEXT NOT NULL, + changes JSONB NOT NULL DEFAULT '[]'::JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + + `CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`, + `CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`, + + `CREATE INDEX IF NOT EXISTS idx_teams_name ON teams(name)`, + `CREATE INDEX IF NOT EXISTS idx_user_teams_user_id ON user_teams(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_user_teams_team_id ON user_teams(team_id)`, + + `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_address_unique ON devices(lower(mac_address)) WHERE mac_address <> ''`, + + `CREATE INDEX IF NOT EXISTS idx_devices_manufacturer ON devices(manufacturer)`, + `CREATE INDEX IF NOT EXISTS idx_devices_model ON devices(model)`, + `CREATE INDEX IF NOT EXISTS idx_devices_location ON devices(location)`, + `CREATE INDEX IF NOT EXISTS idx_devices_loan_status ON devices(loan_status)`, + + `CREATE INDEX IF NOT EXISTS idx_device_relations_device_id ON device_relations(device_id)`, + `CREATE INDEX IF NOT EXISTS idx_device_relations_related_device_id ON device_relations(related_device_id)`, + + `CREATE INDEX IF NOT EXISTS idx_device_history_device_id ON device_history(device_id)`, + `CREATE INDEX IF NOT EXISTS idx_device_history_user_id ON device_history(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_device_history_created_at ON device_history(created_at)`, + } + + for _, query := range queries { + if _, err := db.Exec(ctx, query); err != nil { + return err + } + } + + fmt.Println("Tabellen und Indizes wurden erstellt oder existieren bereits") + + return nil +} + +func createAdminIfNotExists(ctx context.Context, db *pgxpool.Pool) (string, bool, error) { + adminUsername := env("ADMIN_USERNAME", "admin") + adminDisplayName := env("ADMIN_DISPLAY_NAME", "Administrator") + adminEmail := env("ADMIN_EMAIL", "admin@example.com") + adminAvatar := env("ADMIN_AVATAR", "") + adminUnit := env("ADMIN_UNIT", "Administration") + adminGroup := env("ADMIN_GROUP", "admin") + adminRights := splitCSV(env("ADMIN_RIGHTS", "admin,users:read,users:write,teams:read,teams:write,devices:read,devices:write")) + + adminTeamName := env("ADMIN_TEAM_NAME", "Administration") + adminTeamInitial := env("ADMIN_TEAM_INITIAL", "A") + adminTeamHref := env("ADMIN_TEAM_HREF", "#") + + teamID, err := ensureTeam(ctx, db, adminTeamName, adminTeamInitial, adminTeamHref) + if err != nil { + return "", false, err + } + + var existingUserID string + + err = db.QueryRow( + ctx, + ` + SELECT id + FROM users + WHERE username = $1 OR email = $2 + LIMIT 1 + `, + adminUsername, + adminEmail, + ).Scan(&existingUserID) + + if err == nil { + if err := assignUserToTeam(ctx, db, existingUserID, teamID); err != nil { + return "", false, err + } + + return "", false, nil + } + + if !errors.Is(err, pgx.ErrNoRows) { + return "", false, err + } + + password, err := generatePassword(24) + if err != nil { + return "", false, err + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", false, err + } + + var userID string + + err = db.QueryRow( + ctx, + ` + INSERT INTO users ( + username, + display_name, + email, + password_hash, + avatar, + unit, + user_group, + rights + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::TEXT[]) + RETURNING id + `, + adminUsername, + adminDisplayName, + adminEmail, + string(passwordHash), + adminAvatar, + adminUnit, + adminGroup, + toPostgresTextArray(adminRights), + ).Scan(&userID) + + if err != nil { + return "", false, err + } + + if err := assignUserToTeam(ctx, db, userID, teamID); err != nil { + return "", false, err + } + + return password, true, nil +} + +func ensureTeam(ctx context.Context, db *pgxpool.Pool, name string, initial string, href string) (string, error) { + name = strings.TrimSpace(name) + initial = strings.TrimSpace(initial) + href = strings.TrimSpace(href) + + if name == "" { + name = "Administration" + } + + if initial == "" { + initial = strings.ToUpper(string([]rune(name)[0])) + } + + if href == "" { + href = "#" + } + + var teamID string + + err := db.QueryRow( + ctx, + ` + INSERT INTO teams (name, initial, href) + VALUES ($1, $2, $3) + ON CONFLICT (name) + DO UPDATE SET + initial = EXCLUDED.initial, + href = EXCLUDED.href, + updated_at = now() + RETURNING id + `, + name, + initial, + href, + ).Scan(&teamID) + + return teamID, err +} + +func assignUserToTeam(ctx context.Context, db *pgxpool.Pool, userID string, teamID string) error { + _, err := db.Exec( + ctx, + ` + INSERT INTO user_teams (user_id, team_id) + VALUES ($1, $2) + ON CONFLICT (user_id, team_id) DO NOTHING + `, + userID, + teamID, + ) + + return err +} + +func generatePassword(length int) (string, error) { + randomBytes := make([]byte, length) + + if _, err := rand.Read(randomBytes); err != nil { + return "", err + } + + password := base64.RawURLEncoding.EncodeToString(randomBytes) + + if len(password) > length { + password = password[:length] + } + + return password, nil +} + +func printAdminResult(password string, created bool) { + adminUsername := env("ADMIN_USERNAME", "admin") + adminEmail := env("ADMIN_EMAIL", "admin@example.com") + adminTeamName := env("ADMIN_TEAM_NAME", "Administration") + + fmt.Println("") + + if created { + fmt.Println("=================================================") + fmt.Println("Admin-User wurde erstellt") + fmt.Println("=================================================") + fmt.Printf("Benutzername: %s\n", adminUsername) + fmt.Printf("E-Mail: %s\n", adminEmail) + fmt.Printf("Team: %s\n", adminTeamName) + fmt.Printf("Passwort: %s\n", password) + fmt.Println("=================================================") + fmt.Println("Bitte Passwort sicher speichern. Es wird nicht erneut angezeigt.") + fmt.Println("") + return + } + + fmt.Println("=================================================") + fmt.Println("Admin-User existiert bereits") + fmt.Println("=================================================") + fmt.Printf("Benutzername: %s\n", adminUsername) + fmt.Printf("E-Mail: %s\n", adminEmail) + fmt.Printf("Team: %s\n", adminTeamName) + fmt.Println("Passwort: wird nicht angezeigt, da nur der Hash gespeichert wird") + fmt.Println("=================================================") + fmt.Println("") +} + +func toPostgresTextArray(values []string) string { + if len(values) == 0 { + return "{}" + } + + escaped := make([]string, 0, len(values)) + + for _, value := range values { + value = strings.TrimSpace(value) + + if value == "" { + continue + } + + value = strings.ReplaceAll(value, `\`, `\\`) + value = strings.ReplaceAll(value, `"`, `\"`) + + escaped = append(escaped, `"`+value+`"`) + } + + if len(escaped) == 0 { + return "{}" + } + + return "{" + strings.Join(escaped, ",") + "}" +} + +func splitCSV(value string) []string { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + + for _, part := range parts { + part = strings.TrimSpace(part) + + if part != "" { + result = append(result, part) + } + } + + return result +} + +func quoteIdentifier(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` +} + +func env(key string, fallback string) string { + value := os.Getenv(key) + + if value == "" { + return fallback + } + + return value +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4d75eed --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + teg + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8cf2ff7 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3348 @@ +{ + "name": "teg", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "teg", + "version": "0.0.0", + "dependencies": { + "@headlessui/react": "^2.2.10", + "@heroicons/react": "^2.2.0", + "@tailwindcss/vite": "^4.3.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router": "^7.15.0", + "tailwindcss": "^4.3.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.10.tgz", + "integrity": "sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@internationalized/date": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz", + "integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.6", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.6.tgz", + "integrity": "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/string": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.8.tgz", + "integrity": "sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", + "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@react-aria/focus": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.22.0.tgz", + "integrity": "sha512-ZfDOVuVhqDsM9mkNji3QUZ/d40JhlVgXrDkrfXylM1035QCrcTHN7m2DpbE95sU2A8EQb4wikvt5jM6K/73BPg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0", + "react-aria": "3.48.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.28.0.tgz", + "integrity": "sha512-OXwdU1EWFdMxmr/K1CXNGJzmNlCClByb+PuCaqUyzBymHPCGVhawirLIon/CrIN5psh3AiWpHSh4H0WeJdVpng==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "react-aria": "3.48.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz", + "integrity": "sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", + "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", + "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", + "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", + "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", + "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", + "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", + "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", + "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", + "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", + "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", + "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", + "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", + "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", + "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", + "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.3", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.353", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", + "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-aria": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.48.0.tgz", + "integrity": "sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w==", + "license": "Apache-2.0", + "dependencies": { + "@internationalized/date": "^3.12.1", + "@internationalized/number": "^3.6.6", + "@internationalized/string": "^3.2.8", + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "aria-hidden": "^1.2.3", + "clsx": "^2.0.0", + "react-stately": "3.46.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-router": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", + "integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-stately": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.46.0.tgz", + "integrity": "sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==", + "license": "Apache-2.0", + "dependencies": { + "@internationalized/date": "^3.12.1", + "@internationalized/number": "^3.6.6", + "@internationalized/string": "^3.2.8", + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/rolldown": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", + "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.129.0", + "@rolldown/pluginutils": "1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0", + "@rolldown/binding-darwin-arm64": "1.0.0", + "@rolldown/binding-darwin-x64": "1.0.0", + "@rolldown/binding-freebsd-x64": "1.0.0", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", + "@rolldown/binding-linux-arm64-gnu": "1.0.0", + "@rolldown/binding-linux-arm64-musl": "1.0.0", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0", + "@rolldown/binding-linux-s390x-gnu": "1.0.0", + "@rolldown/binding-linux-x64-gnu": "1.0.0", + "@rolldown/binding-linux-x64-musl": "1.0.0", + "@rolldown/binding-openharmony-arm64": "1.0.0", + "@rolldown/binding-wasm32-wasi": "1.0.0", + "@rolldown/binding-win32-arm64-msvc": "1.0.0", + "@rolldown/binding-win32-x64-msvc": "1.0.0" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", + "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", + "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.0", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.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 + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1773b11 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "teg", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@headlessui/react": "^2.2.10", + "@heroicons/react": "^2.2.0", + "@tailwindcss/vite": "^4.3.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router": "^7.15.0", + "tailwindcss": "^4.3.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..635e1c4 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1 @@ +/* frontend\src\App.css */ \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..153385a --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,130 @@ +// frontend\src\App.tsx + +import { useEffect, useState, type FormEvent } from 'react' +import { Navigate, Route, Routes } from 'react-router' +import LoginForm from './components/LoginForm' +import AppLayout from './components/AppLayout' +import DashboardPage from './pages/DashboardPage' +import DevicesPage from './pages/DevicesPage' +import IncidentsPage from './pages/IncidentsPage' +import CalendarPage from './pages/CalendarPage' +import DocumentsPage from './pages/DocumentsPage' +import ReportsPage from './pages/ReportsPage' +import SettingsPage from './pages/SettingsPage' +import type { Team, User } from './components/types' +import './App.css' + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' + +function App() { + const [user, setUser] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isLoggingIn, setIsLoggingIn] = useState(false) + + useEffect(() => { + loadUser() + }, []) + + async function loadUser() { + try { + const response = await fetch(`${API_URL}/auth/me`, { + credentials: 'include', + }) + + if (!response.ok) { + setUser(null) + return + } + + const data = await response.json() + setUser(data.user) + } catch { + setUser(null) + } finally { + setIsLoading(false) + } + } + + async function handleLogin(event: FormEvent) { + event.preventDefault() + + setIsLoggingIn(true) + + const formData = new FormData(event.currentTarget) + + const identifier = String(formData.get('identifier')) + const password = String(formData.get('password')) + + try { + const response = await fetch(`${API_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ identifier, password }), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => null) + alert(errorData?.error ?? 'Login fehlgeschlagen') + return + } + + const data = await response.json() + setUser(data.user) + } catch { + alert('Backend ist nicht erreichbar') + } finally { + setIsLoggingIn(false) + } + } + + async function handleLogout() { + try { + await fetch(`${API_URL}/auth/logout`, { + method: 'POST', + credentials: 'include', + }) + } finally { + setUser(null) + } + } + + if (isLoading) { + return ( +
+

+ Loading... +

+
+ ) + } + + if (!user) { + return ( + + ) + } + + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) +} + +export default App \ No newline at end of file diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx new file mode 100644 index 0000000..bb70d37 --- /dev/null +++ b/frontend/src/components/AppLayout.tsx @@ -0,0 +1,38 @@ +// frontend\src\components\AppLayout.tsx + +import { useState, type ReactNode } from 'react' +import Sidebar from './Sidebar' +import Topbar from './Topbar' +import type { User } from './types' + +type AppLayoutProps = { + user: User + onLogout: () => void | Promise + children: ReactNode +} + +export default function AppLayout({ user, onLogout, children }: AppLayoutProps) { + const [sidebarOpen, setSidebarOpen] = useState(false) + + return ( +
+ + +
+ setSidebarOpen(true)} + /> + +
+ {children} +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx new file mode 100644 index 0000000..2be2cc4 --- /dev/null +++ b/frontend/src/components/Button.tsx @@ -0,0 +1,200 @@ +// frontend\src\components\ui\Button.tsx + +import { type ButtonHTMLAttributes, type ReactNode } from 'react' + +type Variant = 'primary' | 'secondary' | 'soft' +type Size = 'xs' | 'sm' | 'md' | 'lg' +type Color = 'indigo' | 'blue' | 'emerald' | 'red' | 'amber' + +export type ButtonProps = Omit< + ButtonHTMLAttributes, + 'children' +> & { + children: ReactNode + variant?: Variant + size?: Size + color?: Color + rounded?: 'sm' | 'md' | 'full' + leadingIcon?: ReactNode + trailingIcon?: ReactNode + isLoading?: boolean + className?: string +} + +function cn(...parts: Array) { + return parts.filter(Boolean).join(' ') +} + +const base = + 'inline-flex items-center justify-center font-semibold transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 ' + + 'disabled:cursor-not-allowed disabled:saturate-0 disabled:opacity-60 disabled:hover:brightness-100' + +const disabledPrimary = + 'disabled:!bg-gray-300 disabled:!text-gray-600 disabled:shadow-none disabled:hover:!bg-gray-300 ' + + 'dark:disabled:!bg-white/10 dark:disabled:!text-white/40 dark:disabled:hover:!bg-white/10' + +const disabledSecondary = + 'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' + + 'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5' + +const disabledSoft = + 'disabled:bg-gray-100 disabled:text-gray-500 disabled:inset-ring-gray-200 disabled:shadow-none disabled:hover:bg-gray-100 ' + + 'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:hover:bg-white/5' + +const roundedMap = { + sm: 'rounded-sm', + md: 'rounded-md', + full: 'rounded-full', +} as const + +const sizeMap: Record = { + xs: 'px-2 py-1 text-xs', + sm: 'px-2.5 py-1.5 text-sm', + md: 'px-3 py-2 text-sm', + lg: 'px-3.5 py-2.5 text-sm', +} + +const colorMap: Record> = { + indigo: { + primary: + '!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-500 focus-visible:outline-indigo-600 ' + + 'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500 ' + + disabledPrimary, + secondary: + 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-indigo-50 hover:text-indigo-700 hover:ring-indigo-200 ' + + 'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-indigo-500/15 dark:hover:text-indigo-300 dark:hover:ring-indigo-400/20 ' + + disabledSecondary, + soft: + 'bg-indigo-500/14 text-indigo-700 shadow-xs inset-ring inset-ring-indigo-500/20 hover:bg-indigo-500/20 hover:inset-ring-indigo-500/25 ' + + 'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-indigo-500/25 ' + + disabledSoft, + }, + + blue: { + primary: + '!bg-blue-600 !text-white shadow-sm hover:!bg-blue-500 focus-visible:outline-blue-600 ' + + 'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500 ' + + disabledPrimary, + secondary: + 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-blue-50 hover:text-blue-700 hover:ring-blue-200 ' + + 'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-blue-500/15 dark:hover:text-blue-300 dark:hover:ring-blue-400/20 ' + + disabledSecondary, + soft: + 'bg-blue-500/14 text-blue-700 shadow-xs inset-ring inset-ring-blue-500/20 hover:bg-blue-500/20 hover:inset-ring-blue-500/25 ' + + 'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-blue-500/25 ' + + disabledSoft, + }, + + emerald: { + primary: + '!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-500 focus-visible:outline-emerald-600 ' + + 'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500 ' + + disabledPrimary, + secondary: + 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-emerald-50 hover:text-emerald-700 hover:ring-emerald-200 ' + + 'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-emerald-500/15 dark:hover:text-emerald-300 dark:hover:ring-emerald-400/20 ' + + disabledSecondary, + soft: + 'bg-emerald-500/14 text-emerald-700 shadow-xs inset-ring inset-ring-emerald-500/20 hover:bg-emerald-500/20 hover:inset-ring-emerald-500/25 ' + + 'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-emerald-500/25 ' + + disabledSoft, + }, + + red: { + primary: + '!bg-red-600 !text-white shadow-sm hover:!bg-red-500 focus-visible:outline-red-600 ' + + 'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500 ' + + disabledPrimary, + secondary: + 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-red-50 hover:text-red-700 hover:ring-red-200 ' + + 'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-red-500/15 dark:hover:text-red-300 dark:hover:ring-red-400/20 ' + + disabledSecondary, + soft: + 'bg-red-500/14 text-red-700 shadow-xs inset-ring inset-ring-red-500/20 hover:bg-red-500/20 hover:inset-ring-red-500/25 ' + + 'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-red-500/25 ' + + disabledSoft, + }, + + amber: { + primary: + '!bg-amber-500 !text-white shadow-sm hover:!bg-amber-400 focus-visible:outline-amber-500 ' + + 'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500 ' + + disabledPrimary, + secondary: + 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-amber-50 hover:text-amber-800 hover:ring-amber-200 ' + + 'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-amber-500/15 dark:hover:text-amber-300 dark:hover:ring-amber-400/20 ' + + disabledSecondary, + soft: + 'bg-amber-500/12 text-amber-800 shadow-xs inset-ring inset-ring-amber-500/20 hover:bg-amber-500/18 hover:inset-ring-amber-500/25 ' + + 'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:inset-ring-0 dark:hover:bg-amber-500/25 ' + + disabledSoft, + }, +} + +function Spinner() { + return ( + + ) +} + +export default function Button({ + children, + variant = 'primary', + color = 'indigo', + size = 'md', + rounded = 'md', + leadingIcon, + trailingIcon, + isLoading = false, + disabled, + className, + type = 'button', + ...props +}: ButtonProps) { + const iconGap = leadingIcon || trailingIcon || isLoading ? 'gap-x-1.5' : '' + + return ( + + ) +} \ No newline at end of file diff --git a/frontend/src/components/ButtonGroup.tsx b/frontend/src/components/ButtonGroup.tsx new file mode 100644 index 0000000..cd1167a --- /dev/null +++ b/frontend/src/components/ButtonGroup.tsx @@ -0,0 +1,116 @@ +// components/ui/ButtonGroup.tsx +'use client' + +import { type ReactNode } from 'react' + +type Size = 'sm' | 'md' | 'lg' + +export type ButtonGroupItem = { + id: string + label?: ReactNode + icon?: ReactNode + srLabel?: string + disabled?: boolean +} + +export type ButtonGroupProps = { + items: ButtonGroupItem[] + value: string + onChange: (id: string) => void + size?: Size + className?: string + ariaLabel?: string +} + +function cn(...parts: Array) { + return parts.filter(Boolean).join(' ') +} + +const sizeMap: Record = { + sm: { btn: 'px-2.5 py-1.5 text-sm', icon: 'size-5', iconOnly: 'h-9 w-9' }, + md: { btn: 'px-3 py-2 text-sm', icon: 'size-5', iconOnly: 'h-10 w-10' }, + lg: { btn: 'px-3.5 py-2.5 text-sm', icon: 'size-5', iconOnly: 'h-11 w-11' }, +} + +export default function ButtonGroup({ + items, + value, + onChange, + size = 'md', + className, + ariaLabel = 'Optionen', +}: ButtonGroupProps) { + const s = sizeMap[size] + + return ( + + {items.map((it, idx) => { + const active = it.id === value + const isFirst = idx === 0 + const isLast = idx === items.length - 1 + const iconOnly = !it.label && !!it.icon + + return ( + + ) + })} + + ) +} \ No newline at end of file diff --git a/frontend/src/components/Camera.tsx b/frontend/src/components/Camera.tsx new file mode 100644 index 0000000..a58aa95 --- /dev/null +++ b/frontend/src/components/Camera.tsx @@ -0,0 +1,235 @@ +// frontend\src\components\Camera.tsx + +import { useEffect, useRef, useState } from 'react' +import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react' +import { CameraIcon, XMarkIcon } from '@heroicons/react/24/outline' + +type BarcodeDetectorResult = { + rawValue: string +} + +type BarcodeDetectorInstance = { + detect: (source: HTMLVideoElement) => Promise +} + +type BarcodeDetectorConstructor = new (options?: { + formats?: string[] +}) => BarcodeDetectorInstance + +type CameraProps = { + onScan?: (value: string) => void +} + +export default function Camera({ onScan }: CameraProps) { + const [open, setOpen] = useState(false) + const [error, setError] = useState(null) + const [scannedValue, setScannedValue] = useState(null) + const [isStarting, setIsStarting] = useState(false) + + const videoRef = useRef(null) + const streamRef = useRef(null) + const frameRef = useRef(null) + + useEffect(() => { + if (!open) { + stopCamera() + return + } + + startCamera() + + return () => { + stopCamera() + } + }, [open]) + + async function startCamera() { + setError(null) + setScannedValue(null) + setIsStarting(true) + + try { + if (!navigator.mediaDevices?.getUserMedia) { + setError('Dein Browser unterstützt keinen Kamera-Zugriff.') + return + } + + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: { + ideal: 'environment', + }, + }, + audio: false, + }) + + streamRef.current = stream + + if (videoRef.current) { + videoRef.current.srcObject = stream + await videoRef.current.play() + } + + startQrScanner() + } catch { + setError('Kamera konnte nicht geöffnet werden. Bitte Berechtigung prüfen.') + } finally { + setIsStarting(false) + } + } + + function stopCamera() { + if (frameRef.current) { + cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()) + streamRef.current = null + } + + if (videoRef.current) { + videoRef.current.srcObject = null + } + } + + function startQrScanner() { + const BarcodeDetector = ( + window as Window & { + BarcodeDetector?: BarcodeDetectorConstructor + } + ).BarcodeDetector + + if (!BarcodeDetector) { + setError('QR-Code-Erkennung wird in diesem Browser nicht unterstützt. Die Kamera-Vorschau funktioniert trotzdem.') + return + } + + const detector = new BarcodeDetector({ + formats: ['qr_code'], + }) + + async function scan() { + if (!videoRef.current) return + + try { + if (videoRef.current.readyState >= 2) { + const codes = await detector.detect(videoRef.current) + + if (codes.length > 0) { + const value = codes[0].rawValue + + setScannedValue(value) + onScan?.(value) + stopCamera() + return + } + } + } catch { + // Weiter versuchen, falls ein einzelner Frame nicht gelesen werden kann. + } + + frameRef.current = requestAnimationFrame(scan) + } + + frameRef.current = requestAnimationFrame(scan) + } + + function closeModal() { + setOpen(false) + } + + return ( + <> + + + + + +
+
+ +
+ +
+ +
+ + QR-Code scannen + + +

+ Richte die Kamera auf einen QR-Code. +

+ +
+ {scannedValue ? ( +
+

+ QR-Code erkannt: +

+

+ {scannedValue} +

+
+ ) : ( +
+ + {isStarting && ( +

+ Kamera wird gestartet... +

+ )} + + {error && ( +

+ {error} +

+ )} +
+ +
+ +
+
+
+
+
+ + ) +} \ No newline at end of file diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx new file mode 100644 index 0000000..62a3b1e --- /dev/null +++ b/frontend/src/components/Card.tsx @@ -0,0 +1,102 @@ +// frontend\src\components\Card.tsx + +import type { ReactNode } from 'react' + +type CardVariant = + | 'basic' + | 'edge-to-edge-mobile' + | 'with-header' + | 'with-footer' + | 'with-header-footer' + | 'gray-footer' + | 'gray-body' + | 'well' + | 'well-on-gray' + | 'well-edge-to-edge-mobile' + +type CardProps = { + children?: ReactNode + header?: ReactNode + footer?: ReactNode + variant?: CardVariant + className?: string +} + +function classNames(...classes: Array) { + return classes.filter(Boolean).join(' ') +} + +export default function Card({ + children, + header, + footer, + variant = 'basic', + className, +}: CardProps) { + const isEdgeToEdgeMobile = + variant === 'edge-to-edge-mobile' || variant === 'well-edge-to-edge-mobile' + + const isWell = + variant === 'well' || + variant === 'well-on-gray' || + variant === 'well-edge-to-edge-mobile' + + const hasDivider = + variant === 'with-header' || + variant === 'with-footer' || + variant === 'with-header-footer' + + const hasHeader = + header && + (variant === 'with-header' || + variant === 'with-header-footer' || + variant === 'gray-body') + + const hasFooter = + footer && + (variant === 'with-footer' || + variant === 'with-header-footer' || + variant === 'gray-footer') + + return ( +
+ {hasHeader && ( +
+ {header} +
+ )} + +
+ {children} +
+ + {hasFooter && ( +
+ {footer} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/Container.tsx b/frontend/src/components/Container.tsx new file mode 100644 index 0000000..0a98345 --- /dev/null +++ b/frontend/src/components/Container.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from 'react' + +type ContainerVariant = + | 'full-width-mobile' + | 'constrained' + | 'breakpoint-full-width-mobile' + | 'breakpoint-constrained' + | 'narrow' + +type ContainerProps = { + children: ReactNode + variant?: ContainerVariant + className?: string +} + +function classNames(...classes: Array) { + return classes.filter(Boolean).join(' ') +} + +const variantClasses: Record = { + 'full-width-mobile': 'mx-auto max-w-7xl sm:px-6 lg:px-8', + constrained: 'mx-auto max-w-7xl px-4 sm:px-6 lg:px-8', + 'breakpoint-full-width-mobile': 'container mx-auto sm:px-6 lg:px-8', + 'breakpoint-constrained': 'container mx-auto px-4 sm:px-6 lg:px-8', + narrow: 'mx-auto max-w-7xl px-4 sm:px-6 lg:px-8', +} + +export default function Container({ + children, + variant = 'constrained', + className, +}: ContainerProps) { + if (variant === 'narrow') { + return ( +
+
+ {children} +
+
+ ) + } + + return ( +
+ {children} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx new file mode 100644 index 0000000..06d94cb --- /dev/null +++ b/frontend/src/components/Feed.tsx @@ -0,0 +1,689 @@ +// frontend/src/components/Feed.tsx + +import { Fragment, useState, type ComponentType, type FormEvent, type SVGProps } from 'react' +import { + ChatBubbleLeftEllipsisIcon, + CheckIcon, + FaceFrownIcon, + FaceSmileIcon, + FireIcon, + HandThumbUpIcon, + HeartIcon, + PaperClipIcon, + TagIcon, + UserCircleIcon, + UserIcon, + XMarkIcon, +} from '@heroicons/react/20/solid' +import { CheckCircleIcon } from '@heroicons/react/24/solid' +import { Label, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react' +import Button from './Button' + +type FeedIcon = ComponentType> + +type FeedVariant = 'icons' | 'comments' | 'multiple' + +export type FeedTimelineItem = { + id: string | number + content: string + target: string + href: string + date: string + datetime: string + icon: FeedIcon + iconBackground: string +} + +export type FeedCommentActivityItem = + | { + id: string | number + type: 'created' | 'edited' | 'sent' | 'viewed' | 'paid' + person: { + name: string + } + date: string + dateTime: string + } + | { + id: string | number + type: 'commented' + person: { + name: string + imageUrl: string + } + comment: string + date: string + dateTime: string + } + +export type FeedMultipleActivityItem = + | { + id: string | number + type: 'comment' + person: { + name: string + href: string + } + imageUrl: string + comment: string + date: string + } + | { + id: string | number + type: 'assignment' + person: { + name: string + href: string + } + assigned: { + name: string + href: string + } + date: string + } + | { + id: string | number + type: 'tags' + person: { + name: string + href: string + } + tags: Array<{ + name: string + href: string + color: string + }> + date: string + } + +type Mood = { + name: string + value: string | null + icon: FeedIcon + iconColor: string + bgColor: string +} + +type FeedProps = { + variant?: FeedVariant + timeline?: FeedTimelineItem[] + activity?: FeedCommentActivityItem[] + multipleActivity?: FeedMultipleActivityItem[] + currentUserAvatar?: string + showCommentForm?: boolean + onCommentSubmit?: (comment: string, mood: Mood) => void + className?: string +} + +function classNames(...classes: Array) { + return classes.filter(Boolean).join(' ') +} + +const defaultTimeline: FeedTimelineItem[] = [ + { + id: 1, + content: 'Applied to', + target: 'Front End Developer', + href: '#', + date: 'Sep 20', + datetime: '2020-09-20', + icon: UserIcon, + iconBackground: 'bg-gray-400 dark:bg-gray-600', + }, + { + id: 2, + content: 'Advanced to phone screening by', + target: 'Bethany Blake', + href: '#', + date: 'Sep 22', + datetime: '2020-09-22', + icon: HandThumbUpIcon, + iconBackground: 'bg-blue-500', + }, + { + id: 3, + content: 'Completed phone screening with', + target: 'Martha Gardner', + href: '#', + date: 'Sep 28', + datetime: '2020-09-28', + icon: CheckIcon, + iconBackground: 'bg-green-500', + }, + { + id: 4, + content: 'Advanced to interview by', + target: 'Bethany Blake', + href: '#', + date: 'Sep 30', + datetime: '2020-09-30', + icon: HandThumbUpIcon, + iconBackground: 'bg-blue-500', + }, + { + id: 5, + content: 'Completed interview with', + target: 'Katherine Snyder', + href: '#', + date: 'Oct 4', + datetime: '2020-10-04', + icon: CheckIcon, + iconBackground: 'bg-green-500', + }, +] + +const defaultCommentActivity: FeedCommentActivityItem[] = [ + { + id: 1, + type: 'created', + person: { name: 'Chelsea Hagon' }, + date: '7d ago', + dateTime: '2023-01-23T10:32', + }, + { + id: 2, + type: 'edited', + person: { name: 'Chelsea Hagon' }, + date: '6d ago', + dateTime: '2023-01-23T11:03', + }, + { + id: 3, + type: 'sent', + person: { name: 'Chelsea Hagon' }, + date: '6d ago', + dateTime: '2023-01-23T11:24', + }, + { + id: 4, + type: 'commented', + person: { + name: 'Chelsea Hagon', + imageUrl: + 'https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80', + }, + comment: 'Called client, they reassured me the invoice would be paid by the 25th.', + date: '3d ago', + dateTime: '2023-01-23T15:56', + }, + { + id: 5, + type: 'viewed', + person: { name: 'Alex Curren' }, + date: '2d ago', + dateTime: '2023-01-24T09:12', + }, + { + id: 6, + type: 'paid', + person: { name: 'Alex Curren' }, + date: '1d ago', + dateTime: '2023-01-24T09:20', + }, +] + +const defaultMultipleActivity: FeedMultipleActivityItem[] = [ + { + id: 1, + type: 'comment', + person: { name: 'Eduardo Benz', href: '#' }, + imageUrl: + 'https://images.unsplash.com/photo-1520785643438-5bf77931f493?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=8&w=256&h=256&q=80', + comment: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tincidunt nunc ipsum tempor purus vitae id.', + date: '6d ago', + }, + { + id: 2, + type: 'assignment', + person: { name: 'Hilary Mahy', href: '#' }, + assigned: { name: 'Kristin Watson', href: '#' }, + date: '2d ago', + }, + { + id: 3, + type: 'tags', + person: { name: 'Hilary Mahy', href: '#' }, + tags: [ + { name: 'Bug', href: '#', color: 'fill-red-500' }, + { name: 'Accessibility', href: '#', color: 'fill-indigo-500' }, + ], + date: '6h ago', + }, + { + id: 4, + type: 'comment', + person: { name: 'Jason Meyers', href: '#' }, + imageUrl: + 'https://images.unsplash.com/photo-1531427186611-ecfd6d936c79?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=8&w=256&h=256&q=80', + comment: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tincidunt nunc ipsum tempor purus vitae id.', + date: '2h ago', + }, +] + +const moods: Mood[] = [ + { name: 'Excited', value: 'excited', icon: FireIcon, iconColor: 'text-white', bgColor: 'bg-red-500' }, + { name: 'Loved', value: 'loved', icon: HeartIcon, iconColor: 'text-white', bgColor: 'bg-pink-400' }, + { name: 'Happy', value: 'happy', icon: FaceSmileIcon, iconColor: 'text-white', bgColor: 'bg-green-400' }, + { name: 'Sad', value: 'sad', icon: FaceFrownIcon, iconColor: 'text-white', bgColor: 'bg-yellow-400' }, + { name: 'Thumbsy', value: 'thumbsy', icon: HandThumbUpIcon, iconColor: 'text-white', bgColor: 'bg-blue-500' }, + { + name: 'I feel nothing', + value: null, + icon: XMarkIcon, + iconColor: 'text-gray-400 dark:text-gray-500', + bgColor: 'bg-transparent', + }, +] + +const defaultAvatar = + 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80' + +function FeedWithIcons({ timeline }: { timeline: FeedTimelineItem[] }) { + return ( +
+
    + {timeline.map((event, eventIndex) => ( +
  • +
    + {eventIndex !== timeline.length - 1 && ( +
    +
  • + ))} +
+
+ ) +} + +function FeedWithComments({ + activity, + currentUserAvatar, + showCommentForm, + onCommentSubmit, +}: { + activity: FeedCommentActivityItem[] + currentUserAvatar: string + showCommentForm: boolean + onCommentSubmit?: (comment: string, mood: Mood) => void +}) { + const [selectedMood, setSelectedMood] = useState(moods[5]) + + function handleSubmit(event: FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + const comment = String(formData.get('comment') ?? '').trim() + + if (!comment) { + return + } + + onCommentSubmit?.(comment, selectedMood) + event.currentTarget.reset() + } + + return ( + <> +
    + {activity.map((activityItem, activityIndex) => ( +
  • +
    +
    +
    + + {activityItem.type === 'commented' ? ( + <> + + +
    +
    +
    + + {activityItem.person.name} + {' '} + commented +
    + + +
    + +

    + {activityItem.comment} +

    +
    + + ) : ( + <> +
    + {activityItem.type === 'paid' ? ( +
  • + ))} +
+ + {showCommentForm && ( +
+ + +
+
+ + +