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 (
+
+ )
+ }
+
+ 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 (
+ <>
+
+
+
+ >
+ )
+}
\ 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 (
+
+ )
+ }
+
+ 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 (
+ <>
+
+
+ {showCommentForm && (
+
+

+
+
+
+ )}
+ >
+ )
+}
+
+function FeedWithMultipleTypes({ activity }: { activity: FeedMultipleActivityItem[] }) {
+ return (
+
+
+ {activity.map((activityItem, activityIndex) => (
+ -
+
+ {activityIndex !== activity.length - 1 && (
+
+ )}
+
+
+ {activityItem.type === 'comment' && (
+ <>
+
+

+
+
+
+
+
+
+
+
+
+
+
+ Commented {activityItem.date}
+
+
+
+
+
{activityItem.comment}
+
+
+ >
+ )}
+
+ {activityItem.type === 'assignment' && (
+ <>
+
+
+
+ >
+ )}
+
+ {activityItem.type === 'tags' && (
+ <>
+
+
+
+ >
+ )}
+
+
+
+ ))}
+
+
+ )
+}
+
+export default function Feed({
+ variant = 'icons',
+ timeline = defaultTimeline,
+ activity = defaultCommentActivity,
+ multipleActivity = defaultMultipleActivity,
+ currentUserAvatar = defaultAvatar,
+ showCommentForm = true,
+ onCommentSubmit,
+ className,
+}: FeedProps) {
+ return (
+
+ {variant === 'icons' && }
+
+ {variant === 'comments' && (
+
+ )}
+
+ {variant === 'multiple' && }
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/LoginForm.tsx b/frontend/src/components/LoginForm.tsx
new file mode 100644
index 0000000..7fb27bb
--- /dev/null
+++ b/frontend/src/components/LoginForm.tsx
@@ -0,0 +1,144 @@
+// frontend\src\components\LoginForm.tsx
+
+import type { FormEvent } from 'react'
+import Button from './Button'
+import Card from './Card'
+
+type LoginFormProps = {
+ title?: string
+ submitText?: string
+ isSubmitting?: boolean
+ forgotPasswordHref?: string
+ registerHref?: string
+ googleHref?: string
+ githubHref?: string
+ onSubmit?: (event: FormEvent) => void
+}
+
+export default function LoginForm({
+ title = 'Sign in to your account',
+ submitText = 'Sign in',
+ isSubmitting = false,
+ forgotPasswordHref = '#',
+ onSubmit,
+}: LoginFormProps) {
+ return (
+
+
+

+

+
+
+ {title}
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx
new file mode 100644
index 0000000..f696360
--- /dev/null
+++ b/frontend/src/components/Modal.tsx
@@ -0,0 +1,259 @@
+import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
+import {
+ CheckIcon,
+ ExclamationTriangleIcon,
+ XMarkIcon,
+} from '@heroicons/react/24/outline'
+
+type ModalVariant =
+ | 'success-single'
+ | 'success-wide'
+ | 'alert'
+ | 'alert-dismiss'
+ | 'alert-gray-footer'
+ | 'alert-left-buttons'
+
+type ModalProps = {
+ open: boolean
+ setOpen: (open: boolean) => void
+ variant?: ModalVariant
+ title?: string
+ description?: string
+ confirmText?: string
+ cancelText?: string
+ onConfirm?: () => void
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+export default function Modal({
+ open,
+ setOpen,
+ variant = 'success-single',
+ title,
+ description,
+ confirmText,
+ cancelText = 'Cancel',
+ onConfirm,
+}: ModalProps) {
+ const isSuccess = variant === 'success-single' || variant === 'success-wide'
+ const isGrayFooter = variant === 'alert-gray-footer'
+ const isDismiss = variant === 'alert-dismiss'
+ const isLeftButtons = variant === 'alert-left-buttons'
+ const isWideSuccess = variant === 'success-wide'
+
+ const Icon = isSuccess ? CheckIcon : ExclamationTriangleIcon
+
+ const resolvedTitle =
+ title ?? (isSuccess ? 'Payment successful' : 'Deactivate account')
+
+ const resolvedDescription =
+ description ??
+ (isSuccess
+ ? 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Consequatur amet labore.'
+ : 'Are you sure you want to deactivate your account? All of your data will be permanently removed from our servers forever. This action cannot be undone.')
+
+ const resolvedConfirmText =
+ confirmText ?? (isSuccess ? 'Go back to dashboard' : 'Deactivate')
+
+ function handleConfirm() {
+ onConfirm?.()
+ setOpen(false)
+ }
+
+ if (isGrayFooter) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/Search.tsx b/frontend/src/components/Search.tsx
new file mode 100644
index 0000000..f7aca1d
--- /dev/null
+++ b/frontend/src/components/Search.tsx
@@ -0,0 +1,20 @@
+// frontend\src\components\Search.tsx
+
+import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
+
+export default function Search() {
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
new file mode 100644
index 0000000..42ab680
--- /dev/null
+++ b/frontend/src/components/Sidebar.tsx
@@ -0,0 +1,206 @@
+// frontend\src\components\Sidebar.tsx
+
+import {
+ Dialog,
+ DialogBackdrop,
+ DialogPanel,
+ TransitionChild,
+} from '@headlessui/react'
+import {
+ CalendarIcon,
+ ChartPieIcon,
+ Cog6ToothIcon,
+ ComputerDesktopIcon,
+ DocumentDuplicateIcon,
+ FireIcon,
+ HomeIcon,
+ XMarkIcon,
+} from '@heroicons/react/24/outline'
+import { NavLink } from 'react-router'
+
+const navigation = [
+ { name: 'Dashboard', href: '/', icon: HomeIcon },
+ { name: 'Geräte', href: '/geraete', icon: ComputerDesktopIcon },
+ { name: 'Einsätze', href: '/einsaetze', icon: FireIcon },
+ { name: 'Kalender', href: '/kalender', icon: CalendarIcon },
+ { name: 'Dokumente', href: '/dokumente', icon: DocumentDuplicateIcon },
+ { name: 'Berichte', href: '/berichte', icon: ChartPieIcon },
+]
+
+type Team = {
+ id: string
+ name: string
+ initial: string
+ href: string
+ current: boolean
+}
+
+type User = {
+ id: string
+ username: string
+ displayName: string
+ email: string
+ avatar: string
+ unit: string
+ group: string
+ rights: string[]
+ teams: Team[]
+}
+
+type SidebarProps = {
+ user: User
+ sidebarOpen: boolean
+ setSidebarOpen: (open: boolean) => void
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+function getTeamInitial(team: Team) {
+ if (team.initial) {
+ return team.initial
+ }
+
+ return team.name.charAt(0).toUpperCase()
+}
+
+function SidebarContent({
+ user,
+ setSidebarOpen,
+}: {
+ user: User
+ setSidebarOpen: (open: boolean) => void
+}) {
+ const teams = user.teams ?? []
+
+ return (
+ <>
+
+

+
+
+
+ >
+ )
+}
+
+export default function Sidebar({ user, sidebarOpen, setSidebarOpen }: SidebarProps) {
+ return (
+ <>
+
+
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/Tables.tsx b/frontend/src/components/Tables.tsx
new file mode 100644
index 0000000..ba6ccfd
--- /dev/null
+++ b/frontend/src/components/Tables.tsx
@@ -0,0 +1,673 @@
+// frontend/src/components/Tables.tsx
+
+import {
+ Fragment,
+ type ReactNode,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from 'react'
+import { ChevronDownIcon } from '@heroicons/react/20/solid'
+import Button from './Button'
+
+export type TableVariant =
+ | 'simple'
+ | 'card'
+ | 'full-width'
+ | 'constrained'
+ | 'striped'
+ | 'uppercase'
+ | 'stacked-mobile'
+ | 'hidden-mobile'
+ | 'avatars'
+ | 'sticky-header'
+ | 'vertical-lines'
+ | 'condensed'
+ | 'sortable'
+ | 'grouped'
+ | 'summary'
+ | 'border'
+
+export type TableColumn = {
+ key: string
+ header: string
+ render: (row: T) => ReactNode
+ mobileRender?: (row: T) => ReactNode
+ isPrimary?: boolean
+ sortable?: boolean
+ hideOnMobile?: 'sm' | 'md' | 'lg'
+ align?: 'left' | 'center' | 'right'
+ className?: string
+ headerClassName?: string
+}
+
+export type TableGroup = {
+ id: string | number
+ name: string
+ rows: T[]
+}
+
+export type TableSummaryRow = {
+ label: ReactNode
+ value: ReactNode
+ emphasized?: boolean
+ colSpan?: number
+}
+
+type SortDirection = 'asc' | 'desc'
+
+type TablesProps = {
+ title?: string
+ description?: string
+
+ rows?: T[]
+ groups?: TableGroup[]
+
+ columns: TableColumn[]
+ getRowId: (row: T) => string | number
+
+ variant?: TableVariant
+ emptyText?: string
+
+ actionLabel?: string
+ onAction?: () => void
+ headerAction?: ReactNode
+
+ rowActions?: (row: T) => ReactNode
+
+ selectable?: boolean
+ selectedRowIds?: Array
+ onSelectedRowIdsChange?: (ids: Array) => void
+
+ sortKey?: string
+ sortDirection?: SortDirection
+ onSort?: (column: TableColumn) => void
+
+ summaryRows?: TableSummaryRow[]
+
+ hideHeadings?: boolean
+ stickyTopClassName?: string
+
+ className?: string
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+function getAlignmentClass(align?: TableColumn['align']) {
+ if (align === 'right') {
+ return 'text-right'
+ }
+
+ if (align === 'center') {
+ return 'text-center'
+ }
+
+ return 'text-left'
+}
+
+function getHiddenClass(hideOnMobile?: TableColumn['hideOnMobile']) {
+ if (hideOnMobile === 'sm') {
+ return 'hidden sm:table-cell'
+ }
+
+ if (hideOnMobile === 'md') {
+ return 'hidden md:table-cell'
+ }
+
+ if (hideOnMobile === 'lg') {
+ return 'hidden lg:table-cell'
+ }
+
+ return ''
+}
+
+function Checkbox({
+ checked,
+ onChange,
+ inputRef,
+ value,
+}: {
+ checked: boolean
+ onChange: (checked: boolean) => void
+ inputRef?: React.Ref
+ value?: string
+}) {
+ return (
+
+
onChange(event.target.checked)}
+ className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 dark:border-white/20 dark:bg-gray-800/50 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500 dark:disabled:border-white/10 dark:disabled:bg-gray-800 dark:disabled:checked:bg-gray-800 forced-colors:appearance-auto"
+ />
+
+
+
+ )
+}
+
+export default function Tables({
+ title,
+ description,
+
+ rows = [],
+ groups,
+
+ columns,
+ getRowId,
+
+ variant = 'simple',
+ emptyText = 'Keine Einträge vorhanden.',
+
+ actionLabel,
+ onAction,
+ headerAction,
+
+ rowActions,
+
+ selectable = false,
+ selectedRowIds,
+ onSelectedRowIdsChange,
+
+ sortKey,
+ sortDirection = 'asc',
+ onSort,
+
+ summaryRows,
+
+ hideHeadings = false,
+ stickyTopClassName = 'top-0',
+
+ className,
+}: TablesProps) {
+ const headerCheckboxRef = useRef(null)
+ const [internalSelectedRowIds, setInternalSelectedRowIds] = useState>([])
+
+ const rowGroups =
+ groups && groups.length > 0
+ ? groups
+ : [
+ {
+ id: 'default',
+ name: '',
+ rows,
+ },
+ ]
+
+ const allRows = rowGroups.flatMap((group) => group.rows)
+ const allRowIds = allRows.map(getRowId)
+
+ const selectedIds = selectedRowIds ?? internalSelectedRowIds
+ const selectedSet = new Set(selectedIds)
+
+ const checked = allRowIds.length > 0 && selectedIds.length === allRowIds.length
+ const indeterminate = selectedIds.length > 0 && selectedIds.length < allRowIds.length
+
+ const isCard = variant === 'card'
+ const isBorder = variant === 'border'
+ const isFullWidth = variant === 'full-width'
+ const isConstrained = variant === 'constrained'
+ const isStriped = variant === 'striped'
+ const isUppercase = variant === 'uppercase'
+ const isStackedMobile = variant === 'stacked-mobile'
+ const isStickyHeader = variant === 'sticky-header'
+ const isVerticalLines = variant === 'vertical-lines'
+ const isCondensed = variant === 'condensed'
+ const isSortable = variant === 'sortable'
+ const isGrouped = variant === 'grouped' || Boolean(groups?.length)
+ const hasSummary = variant === 'summary' || Boolean(summaryRows?.length)
+
+ const totalColumns =
+ columns.length +
+ (selectable ? 1 : 0) +
+ (rowActions ? 1 : 0)
+
+ useLayoutEffect(() => {
+ if (!headerCheckboxRef.current) {
+ return
+ }
+
+ headerCheckboxRef.current.indeterminate = indeterminate
+ }, [indeterminate])
+
+ function updateSelected(nextSelectedIds: Array) {
+ if (!selectedRowIds) {
+ setInternalSelectedRowIds(nextSelectedIds)
+ }
+
+ onSelectedRowIdsChange?.(nextSelectedIds)
+ }
+
+ function toggleAll(nextChecked: boolean) {
+ updateSelected(nextChecked ? allRowIds : [])
+ }
+
+ function toggleRow(rowId: string | number, nextChecked: boolean) {
+ if (nextChecked) {
+ updateSelected(Array.from(new Set([...selectedIds, rowId])))
+ return
+ }
+
+ updateSelected(selectedIds.filter((id) => id !== rowId))
+ }
+
+ function renderHeaderAction() {
+ if (headerAction) {
+ return headerAction
+ }
+
+ if (!actionLabel) {
+ return null
+ }
+
+ return (
+
+ )
+ }
+
+ function renderHeader() {
+ if (!title && !description && !actionLabel && !headerAction) {
+ return null
+ }
+
+ return (
+
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+ {(actionLabel || headerAction) && (
+
+ {renderHeaderAction()}
+
+ )}
+
+ )
+ }
+
+ function renderColumnHeader(column: TableColumn) {
+ const activeSortColumn = sortKey === column.key
+ const showSortIcon = isSortable || column.sortable
+
+ if (!showSortIcon) {
+ return column.header
+ }
+
+ return (
+
+ )
+ }
+
+ function renderTableHead() {
+ return (
+
+
+ {selectable && (
+ |
+
+ |
+ )}
+
+ {columns.map((column, columnIndex) => {
+ const isFirst = columnIndex === 0
+ const isLast = columnIndex === columns.length - 1 && !rowActions
+ const hiddenClass = getHiddenClass(column.hideOnMobile)
+
+ return (
+
+ {renderColumnHeader(column)}
+ |
+ )
+ })}
+
+ {rowActions && (
+
+ Aktionen
+ |
+ )}
+
+
+ )
+ }
+
+ function renderRow(row: T, rowIndex: number) {
+ const rowId = getRowId(row)
+ const selected = selectedSet.has(rowId)
+
+ return (
+
+ {selectable && (
+ |
+ {selected && (
+
+ )}
+
+ toggleRow(rowId, nextChecked)}
+ />
+ |
+ )}
+
+ {columns.map((column, columnIndex) => {
+ const isFirst = columnIndex === 0
+ const isLast = columnIndex === columns.length - 1 && !rowActions
+ const hiddenClass = getHiddenClass(column.hideOnMobile)
+
+ return (
+
+ {column.render(row)}
+
+ {isStackedMobile && column.isPrimary && column.mobileRender && (
+
+ {column.mobileRender(row)}
+
+ )}
+ |
+ )
+ })}
+
+ {rowActions && (
+
+ {rowActions(row)}
+ |
+ )}
+
+ )
+ }
+
+ function renderGroupedRows() {
+ return rowGroups.map((group) => (
+
+ {isGrouped && group.name && (
+
+ |
+ {group.name}
+ |
+
+ )}
+
+ {group.rows.map(renderRow)}
+
+ ))
+ }
+
+ function renderEmptyRow() {
+ if (allRows.length > 0) {
+ return null
+ }
+
+ return (
+
+ |
+ {emptyText}
+ |
+
+ )
+ }
+
+ function renderSummaryRows() {
+ if (!hasSummary || !summaryRows?.length) {
+ return null
+ }
+
+ return (
+
+ {summaryRows.map((row, index) => {
+ const colSpan = row.colSpan ?? Math.max(totalColumns - 1, 1)
+
+ return (
+
+ |
+ {row.label}
+ |
+
+
+ {row.value}
+ |
+
+ )
+ })}
+
+ )
+ }
+
+ function renderTable() {
+ return (
+
+ {renderTableHead()}
+
+
+ {renderEmptyRow()}
+ {renderGroupedRows()}
+
+
+ {renderSummaryRows()}
+
+ )
+ }
+
+ return (
+
+ {renderHeader()}
+
+
+
+
+ {isCard || isBorder ? (
+
+ {renderTable()}
+
+ ) : (
+ renderTable()
+ )}
+
+
+
+
+ )
+}
+
+function isAvatarVariant(variant: TableVariant) {
+ return variant === 'avatars'
+}
\ No newline at end of file
diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx
new file mode 100644
index 0000000..6449206
--- /dev/null
+++ b/frontend/src/components/Tabs.tsx
@@ -0,0 +1,398 @@
+// frontend\src\components\Tabs.tsx
+
+import type { ComponentType, SVGProps } from 'react'
+import { Link, useLocation, useNavigate } from 'react-router'
+import { ChevronDownIcon } from '@heroicons/react/16/solid'
+
+type TabIcon = ComponentType>
+
+export type TabItem = {
+ name: string
+ href: string
+ current?: boolean
+ icon?: TabIcon
+ count?: string | number
+}
+
+type TabsVariant =
+ | 'underline'
+ | 'underline-icons'
+ | 'pills'
+ | 'pills-gray'
+ | 'pills-brand'
+ | 'full-width-underline'
+ | 'bar-underline'
+ | 'underline-badges'
+ | 'simple'
+
+type TabsAlign = 'left' | 'center' | 'right'
+
+type TabsProps = {
+ tabs: TabItem[]
+ variant?: TabsVariant
+ align?: TabsAlign
+ sticky?: boolean
+ stickyTopClassName?: string
+ ariaLabel?: string
+ className?: string
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+const alignClassMap: Record = {
+ left: 'justify-start',
+ center: 'justify-center',
+ right: 'justify-end',
+}
+
+function isTabActive(tab: TabItem, pathname: string) {
+ if (typeof tab.current === 'boolean') {
+ return tab.current
+ }
+
+ if (tab.href === '/') {
+ return pathname === '/'
+ }
+
+ return pathname.startsWith(tab.href)
+}
+
+export default function Tabs({
+ tabs,
+ variant = 'underline',
+ align = 'left',
+ sticky = false,
+ stickyTopClassName = 'top-16',
+ ariaLabel = 'Tabs',
+ className,
+}: TabsProps) {
+ const location = useLocation()
+ const navigate = useNavigate()
+
+ const activeTab = tabs.find((tab) => isTabActive(tab, location.pathname)) ?? tabs[0]
+ const alignClassName = alignClassMap[align]
+
+ function handleSelectChange(event: React.ChangeEvent) {
+ const selectedTab = tabs.find((tab) => tab.name === event.target.value)
+
+ if (!selectedTab) {
+ return
+ }
+
+ if (selectedTab.href === '#') {
+ return
+ }
+
+ navigate(selectedTab.href)
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {variant === 'underline' && (
+
+
+
+ )}
+
+ {variant === 'underline-icons' && (
+
+
+
+ )}
+
+ {variant === 'pills' && (
+
+
+
+ )}
+
+ {variant === 'pills-gray' && (
+
+
+
+ )}
+
+ {variant === 'pills-brand' && (
+
+
+
+ )}
+
+ {variant === 'full-width-underline' && (
+
+
+
+ )}
+
+ {variant === 'bar-underline' && (
+
+ )}
+
+ {variant === 'underline-badges' && (
+
+
+
+ )}
+
+ {variant === 'simple' && (
+
+ )}
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx
new file mode 100644
index 0000000..e986f3a
--- /dev/null
+++ b/frontend/src/components/Topbar.tsx
@@ -0,0 +1,132 @@
+// frontend/src/components/Topbar.tsx
+
+import {
+ Menu,
+ MenuButton,
+ MenuItem,
+ MenuItems,
+} from '@headlessui/react'
+import {
+ Bars3Icon,
+ BellIcon,
+} from '@heroicons/react/24/outline'
+import { ChevronDownIcon } from '@heroicons/react/20/solid'
+import { Link } from 'react-router'
+import Search from './Search'
+import Camera from './Camera'
+import type { User } from './types'
+
+type TopbarProps = {
+ user: User
+ onLogout: () => void | Promise
+ onOpenSidebar: () => void
+}
+
+const userNavigation = [
+ { name: 'Mein Account', href: '/einstellungen' },
+]
+
+export default function Topbar({ user, onLogout, onOpenSidebar }: TopbarProps) {
+ function handleQrScan(value: string) {
+ console.log('QR-Code erkannt:', value)
+ }
+
+ const avatarUrl =
+ user.avatar ||
+ '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'
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/types.ts b/frontend/src/components/types.ts
new file mode 100644
index 0000000..fcaef1d
--- /dev/null
+++ b/frontend/src/components/types.ts
@@ -0,0 +1,38 @@
+// frontend\src\components\types.ts
+
+export type Team = {
+ id: string
+ name: string
+ initial: string
+ href: string
+ current: boolean
+}
+
+export type User = {
+ id: string
+ username: string
+ displayName: string
+ email: string
+ avatar: string
+ unit: string
+ group: string
+ rights: string[]
+ teams: Team[]
+}
+
+export type DeviceHistoryChange = {
+ field: string
+ label: string
+ oldValue: string
+ newValue: string
+}
+
+export type DeviceHistoryEntry = {
+ id: string
+ deviceId: string
+ userId: string
+ userDisplayName: string
+ action: 'created' | 'updated'
+ changes: DeviceHistoryChange[]
+ createdAt: string
+}
\ No newline at end of file
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..9207df0
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,13 @@
+/* frontend\src\index.css */
+
+@import "tailwindcss";
+
+html,
+body,
+#root {
+ min-height: 100%;
+}
+
+body {
+ margin: 0;
+}
\ No newline at end of file
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..4b56315
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,15 @@
+// main.tsx
+
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { BrowserRouter } from 'react-router'
+import './index.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+)
\ No newline at end of file
diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx
new file mode 100644
index 0000000..0337110
--- /dev/null
+++ b/frontend/src/pages/CalendarPage.tsx
@@ -0,0 +1,11 @@
+// frontend/src/pages/CalendarPage.tsx
+
+export default function CalendarPage() {
+ return (
+
+
+ Kalender
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
new file mode 100644
index 0000000..f1ee377
--- /dev/null
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -0,0 +1,19 @@
+// frontend\src\pages\DashboardPage.tsx
+
+type DashboardPageProps = {
+ username: string
+}
+
+export default function DashboardPage({ username }: DashboardPageProps) {
+ return (
+
+
+ Dashboard
+
+
+
+ Willkommen zurück, {username}.
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx
new file mode 100644
index 0000000..fab9434
--- /dev/null
+++ b/frontend/src/pages/DevicesPage.tsx
@@ -0,0 +1,698 @@
+// frontend/src/pages/DevicesPage.tsx
+
+import { useEffect, useState, type FormEvent } from 'react'
+import {
+ Dialog,
+ DialogBackdrop,
+ DialogPanel,
+ DialogTitle,
+} from '@headlessui/react'
+import { XMarkIcon } from '@heroicons/react/24/outline'
+import Feed, { type FeedTimelineItem } from '../components/Feed'
+import { PencilSquareIcon, PlusCircleIcon } from '@heroicons/react/20/solid'
+import Tables, { type TableColumn } from '../components/Tables'
+import Button from '../components/Button'
+import type { DeviceHistoryChange, DeviceHistoryEntry } from '../components/types'
+
+const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
+
+type RelatedDevice = {
+ id: string
+ inventoryNumber: string
+ manufacturer: string
+ model: string
+}
+
+type Device = {
+ id: string
+ inventoryNumber: string
+ manufacturer: string
+ model: string
+ serialNumber: string
+ macAddress: string
+ location: string
+ loanStatus: string
+ comment: string
+ relatedDevices: RelatedDevice[]
+}
+
+const inputClassName =
+ 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
+
+const selectClassName =
+ 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500'
+
+function getLoanStatusClassName(status: string) {
+ const normalizedStatus = status.toLowerCase()
+
+ if (
+ normalizedStatus.includes('ausgeliehen') ||
+ normalizedStatus.includes('verliehen') ||
+ normalizedStatus.includes('reserviert')
+ ) {
+ return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-300 dark:ring-amber-500/30'
+ }
+
+ if (
+ normalizedStatus.includes('defekt') ||
+ normalizedStatus.includes('verloren') ||
+ normalizedStatus.includes('gesperrt')
+ ) {
+ return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
+ }
+
+ if (
+ normalizedStatus.includes('wartung') ||
+ normalizedStatus.includes('prüfung')
+ ) {
+ return 'bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-900/30 dark:text-blue-300 dark:ring-blue-500/30'
+ }
+
+ return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-400 dark:ring-green-500/30'
+}
+
+function formatHistoryDate(value: string) {
+ try {
+ return new Intl.DateTimeFormat('de-DE', {
+ dateStyle: 'short',
+ timeStyle: 'short',
+ }).format(new Date(value))
+ } catch {
+ return value
+ }
+}
+
+function formatHistoryChange(change: DeviceHistoryChange) {
+ return `${change.label}: ${change.oldValue} → ${change.newValue}`
+}
+
+function getHistoryContent(entry: DeviceHistoryEntry) {
+ if (entry.action === 'created') {
+ return 'Gerät erstellt durch'
+ }
+
+ if (!entry.changes.length) {
+ return 'Gerät bearbeitet durch'
+ }
+
+ return `${entry.changes.map(formatHistoryChange).join(', ')} durch`
+}
+
+export default function DevicesPage() {
+ const [devices, setDevices] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
+ const [isCreating, setIsCreating] = useState(false)
+ const [error, setError] = useState(null)
+ const [createError, setCreateError] = useState(null)
+ const [relatedDeviceIds, setRelatedDeviceIds] = useState([])
+ const [currentDeviceId, setCurrentDeviceId] = useState(null)
+ const [editingDevice, setEditingDevice] = useState(null)
+
+ const [deviceHistory, setDeviceHistory] = useState([])
+ const [isHistoryLoading, setIsHistoryLoading] = useState(false)
+ const [historyError, setHistoryError] = useState(null)
+
+ const isEditing = editingDevice !== null
+
+ useEffect(() => {
+ loadDevices()
+ }, [])
+
+ async function loadDevices() {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const response = await fetch(`${API_URL}/devices`, {
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Geräte konnten nicht geladen werden')
+ }
+
+ const data = await response.json()
+ setDevices(data.devices ?? [])
+ } catch (error) {
+ setError(error instanceof Error ? error.message : 'Geräte konnten nicht geladen werden')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ async function loadDeviceHistory(deviceId: string) {
+ setIsHistoryLoading(true)
+ setHistoryError(null)
+
+ try {
+ const response = await fetch(`${API_URL}/devices/${deviceId}/history`, {
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Bearbeitungsverlauf konnte nicht geladen werden')
+ }
+
+ const data = await response.json()
+ setDeviceHistory(data.history ?? [])
+ } catch (error) {
+ setHistoryError(
+ error instanceof Error
+ ? error.message
+ : 'Bearbeitungsverlauf konnte nicht geladen werden',
+ )
+ } finally {
+ setIsHistoryLoading(false)
+ }
+ }
+
+ function openCreateModal() {
+ setCreateError(null)
+ setEditingDevice(null)
+ setRelatedDeviceIds([])
+ setCurrentDeviceId(null)
+ setIsCreateModalOpen(true)
+ }
+
+ function closeCreateModal() {
+ if (isCreating) {
+ return
+ }
+
+ setIsCreateModalOpen(false)
+ setEditingDevice(null)
+ setCurrentDeviceId(null)
+ setRelatedDeviceIds([])
+ setDeviceHistory([])
+ setHistoryError(null)
+ setIsHistoryLoading(false)
+ }
+
+ function openEditModal(device: Device) {
+ setCreateError(null)
+ setHistoryError(null)
+ setDeviceHistory([])
+ setEditingDevice(device)
+ setCurrentDeviceId(device.id)
+ setRelatedDeviceIds(device.relatedDevices?.map((relatedDevice) => relatedDevice.id) ?? [])
+ setIsCreateModalOpen(true)
+
+ void loadDeviceHistory(device.id)
+ }
+
+ function toggleRelatedDevice(deviceId: string) {
+ setRelatedDeviceIds((currentDeviceIds) => {
+ if (currentDeviceIds.includes(deviceId)) {
+ return currentDeviceIds.filter((currentDeviceId) => currentDeviceId !== deviceId)
+ }
+
+ return [...currentDeviceIds, deviceId]
+ })
+ }
+
+ async function handleSaveDevice(event: FormEvent) {
+ event.preventDefault()
+
+ const form = event.currentTarget
+ const formData = new FormData(form)
+
+ setIsCreating(true)
+ setCreateError(null)
+
+ const payload = {
+ inventoryNumber: String(formData.get('inventoryNumber') ?? ''),
+ manufacturer: String(formData.get('manufacturer') ?? ''),
+ model: String(formData.get('model') ?? ''),
+ serialNumber: String(formData.get('serialNumber') ?? ''),
+ macAddress: String(formData.get('macAddress') ?? ''),
+ location: String(formData.get('location') ?? ''),
+ loanStatus: String(formData.get('loanStatus') ?? 'Verfügbar'),
+ comment: String(formData.get('comment') ?? ''),
+ relatedDeviceIds,
+ }
+
+ try {
+ const response = await fetch(
+ isEditing
+ ? `${API_URL}/devices/${editingDevice.id}`
+ : `${API_URL}/devices`,
+ {
+ method: isEditing ? 'PUT' : 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify(payload),
+ },
+ )
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(
+ errorData?.error ??
+ (isEditing ? 'Gerät konnte nicht aktualisiert werden' : 'Gerät konnte nicht erstellt werden'),
+ )
+ }
+
+ form.reset()
+ setRelatedDeviceIds([])
+ setEditingDevice(null)
+ setCurrentDeviceId(null)
+ setIsCreateModalOpen(false)
+
+ await loadDevices()
+ } catch (error) {
+ setCreateError(
+ error instanceof Error
+ ? error.message
+ : isEditing
+ ? 'Gerät konnte nicht aktualisiert werden'
+ : 'Gerät konnte nicht erstellt werden',
+ )
+ } finally {
+ setIsCreating(false)
+ }
+ }
+
+ const columns: TableColumn[] = [
+ {
+ key: 'inventoryNumber',
+ header: 'Inventur-Nr.',
+ isPrimary: true,
+ render: (device) => device.inventoryNumber,
+ },
+ {
+ key: 'manufacturer',
+ header: 'Hersteller',
+ render: (device) => device.manufacturer || '-',
+ },
+ {
+ key: 'model',
+ header: 'Modell',
+ render: (device) => device.model || '-',
+ },
+ {
+ key: 'serialNumber',
+ header: 'Seriennummer',
+ hideOnMobile: 'lg',
+ render: (device) => device.serialNumber || '-',
+ },
+ {
+ key: 'macAddress',
+ header: 'MAC-Adresse',
+ hideOnMobile: 'lg',
+ render: (device) => device.macAddress || '-',
+ },
+ {
+ key: 'location',
+ header: 'Standort',
+ render: (device) => device.location || '-',
+ },
+ {
+ key: 'loanStatus',
+ header: 'Verleih-Status',
+ render: (device) => (
+
+ {device.loanStatus || 'Verfügbar'}
+
+ ),
+ },
+ {
+ key: 'relatedDevices',
+ header: 'Zubehör',
+ hideOnMobile: 'lg',
+ render: (device) => {
+ if (!device.relatedDevices?.length) {
+ return '-'
+ }
+
+ return (
+
+ {device.relatedDevices.map((relatedDevice) => (
+
+ {relatedDevice.inventoryNumber}
+
+ ))}
+
+ )
+ },
+ },
+ {
+ key: 'comment',
+ header: 'Kommentar',
+ hideOnMobile: 'lg',
+ render: (device) => (
+
+ {device.comment || '-'}
+
+ ),
+ },
+ ]
+
+ const assignableDevices = devices.filter((device) => device.id !== currentDeviceId)
+
+ const historyTimeline: FeedTimelineItem[] = deviceHistory.map((entry) => ({
+ id: entry.id,
+ content: getHistoryContent(entry),
+ target: entry.userDisplayName || 'Unbekannt',
+ href: '#',
+ date: formatHistoryDate(entry.createdAt),
+ datetime: entry.createdAt,
+ icon: entry.action === 'created' ? PlusCircleIcon : PencilSquareIcon,
+ iconBackground: entry.action === 'created' ? 'bg-green-500' : 'bg-blue-500',
+ }))
+
+ return (
+
+ {error && (
+
+ {error}
+
+ )}
+
+
device.id}
+ variant="card"
+ emptyText={isLoading ? 'Geräte werden geladen...' : 'Keine Geräte vorhanden.'}
+ rowActions={(device) => (
+
+ )}
+ />
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx
new file mode 100644
index 0000000..4560c24
--- /dev/null
+++ b/frontend/src/pages/DocumentsPage.tsx
@@ -0,0 +1,11 @@
+// frontend/src/pages/DocumentsPage.tsx
+
+export default function DocumentsPage() {
+ return (
+
+
+ Dokumente
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/IncidentsPage.tsx b/frontend/src/pages/IncidentsPage.tsx
new file mode 100644
index 0000000..4473aa6
--- /dev/null
+++ b/frontend/src/pages/IncidentsPage.tsx
@@ -0,0 +1,11 @@
+// frontend/src/pages/IncidentsPage.tsx
+
+export default function IncidentsPage() {
+ return (
+
+
+ Einsätze
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx
new file mode 100644
index 0000000..f8448dd
--- /dev/null
+++ b/frontend/src/pages/ReportsPage.tsx
@@ -0,0 +1,11 @@
+// frontend/src/pages/ReportsPage.tsx
+
+export default function ReportsPage() {
+ return (
+
+
+ Berichte
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx
new file mode 100644
index 0000000..12f720b
--- /dev/null
+++ b/frontend/src/pages/SettingsPage.tsx
@@ -0,0 +1,357 @@
+// frontend\src\pages\SettingsPage.tsx
+
+import type { FormEvent, ReactNode } from 'react'
+import { useLocation } from 'react-router'
+import {
+ BellIcon,
+ CreditCardIcon,
+ PuzzlePieceIcon,
+ UserIcon,
+ UsersIcon,
+} from '@heroicons/react/20/solid'
+import Tabs from '../components/Tabs'
+import Button from '../components/Button'
+import Container from '../components/Container'
+import type { User } from '../components/types'
+
+type SettingsPageProps = {
+ user?: User
+}
+
+const inputClassName =
+ 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
+
+function Section({
+ title,
+ description,
+ children,
+}: {
+ title: string
+ description: string
+ children: ReactNode
+}) {
+ return (
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+function PlaceholderSection({ title }: { title: string }) {
+ return (
+
+
+ Noch keine Einstellungen vorhanden.
+
+
+ )
+}
+
+export default function SettingsPage({ user }: SettingsPageProps) {
+ const location = useLocation()
+
+ const pathname = location.pathname.replace(/\/$/, '')
+ const section = pathname.split('/')[2] ?? 'konto'
+
+ const settingsTabs = [
+ {
+ name: 'Konto',
+ href: '/einstellungen',
+ icon: UserIcon,
+ current: pathname === '/einstellungen',
+ },
+ {
+ name: 'Benachrichtigungen',
+ href: '/einstellungen/benachrichtigungen',
+ icon: BellIcon,
+ current: pathname === '/einstellungen/benachrichtigungen',
+ },
+ {
+ name: 'Abrechnung',
+ href: '/einstellungen/abrechnung',
+ icon: CreditCardIcon,
+ current: pathname === '/einstellungen/abrechnung',
+ },
+ {
+ name: 'Teams',
+ href: '/einstellungen/teams',
+ icon: UsersIcon,
+ current: pathname === '/einstellungen/teams',
+ },
+ {
+ name: 'Integrationen',
+ href: '/einstellungen/integrationen',
+ icon: PuzzlePieceIcon,
+ current: pathname === '/einstellungen/integrationen',
+ },
+ ]
+
+ const avatarUrl =
+ user?.avatar ||
+ '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 handleSubmit(event: FormEvent) {
+ event.preventDefault()
+ }
+
+ return (
+
+ Einstellungen
+
+
+
+ {section === 'konto' && (
+
+ )}
+
+ {section === 'benachrichtigungen' && (
+
+ )}
+
+ {section === 'abrechnung' && (
+
+ )}
+
+ {section === 'teams' && (
+
+ )}
+
+ {section === 'integrationen' && (
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json
new file mode 100644
index 0000000..7f42e5f
--- /dev/null
+++ b/frontend/tsconfig.app.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "esnext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..d3c52ea
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023"],
+ "module": "esnext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..3d15f68
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [
+ react(),
+ tailwindcss(),
+ ],
+})