commit 99837f0ed3c6ecf136518c6eee9224998a92166d Author: Linrador <68631622+Linrador@users.noreply.github.com> Date: Fri Dec 19 17:52:14 2025 +0100 updated diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..1beac26 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,16 @@ +module nsfwapp + +go 1.25.3 + +require ( + github.com/PuerkitoBio/goquery v1.11.0 + github.com/google/uuid v1.6.0 + github.com/grafov/m3u8 v0.12.1 +) + +require ( + github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 // indirect + golang.org/x/net v0.47.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..97ce432 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,79 @@ +github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= +github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= +github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2jiKyS17BH6IrTmUBYqZa/CXT4uvb+I= +github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s= +github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= +github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ= +github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..f3cee0f --- /dev/null +++ b/backend/main.go @@ -0,0 +1,1787 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/PuerkitoBio/goquery" + "github.com/google/uuid" + "github.com/grafov/m3u8" + "github.com/sqweek/dialog" +) + +var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`) + +type JobStatus string + +const ( + JobRunning JobStatus = "running" + JobFinished JobStatus = "finished" + JobFailed JobStatus = "failed" + JobStopped JobStatus = "stopped" +) + +type RecordJob struct { + ID string `json:"id"` + model string `json:"model"` + SourceURL string `json:"sourceUrl"` + Output string `json:"output"` + Status JobStatus `json:"status"` + StartedAt time.Time `json:"startedAt"` + EndedAt *time.Time `json:"endedAt,omitempty"` + Error string `json:"error,omitempty"` + + PreviewDir string `json:"-"` + previewCmd *exec.Cmd `json:"-"` + + cancel context.CancelFunc `json:"-"` +} + +var ( + jobs = map[string]*RecordJob{} + jobsMu = sync.Mutex{} +) + +type RecorderSettings struct { + RecordDir string `json:"recordDir"` + DoneDir string `json:"doneDir"` +} + +var ( + settingsMu sync.Mutex + settings = RecorderSettings{ + RecordDir: "/records", + DoneDir: "/records/done", + } + settingsFile = "recorder_settings.json" +) + +func getSettings() RecorderSettings { + settingsMu.Lock() + defer settingsMu.Unlock() + return settings +} + +func loadSettings() { + b, err := os.ReadFile(settingsFile) + if err == nil { + var s RecorderSettings + if json.Unmarshal(b, &s) == nil { + if strings.TrimSpace(s.RecordDir) != "" { + s.RecordDir = filepath.Clean(strings.TrimSpace(s.RecordDir)) + } + if strings.TrimSpace(s.DoneDir) != "" { + s.DoneDir = filepath.Clean(strings.TrimSpace(s.DoneDir)) + } + settingsMu.Lock() + settings = s + settingsMu.Unlock() + } + } + + // Ordner sicherstellen + s := getSettings() + _ = os.MkdirAll(s.RecordDir, 0o755) + _ = os.MkdirAll(s.DoneDir, 0o755) +} + +func saveSettingsToDisk() { + s := getSettings() + b, _ := json.MarshalIndent(s, "", " ") + _ = os.WriteFile(settingsFile, b, 0o644) +} + +func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(getSettings()) + return + + case http.MethodPost: + var in RecorderSettings + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest) + return + } + + in.RecordDir = filepath.Clean(strings.TrimSpace(in.RecordDir)) + in.DoneDir = filepath.Clean(strings.TrimSpace(in.DoneDir)) + + if in.RecordDir == "" || in.DoneDir == "" { + http.Error(w, "recordDir und doneDir dürfen nicht leer sein", http.StatusBadRequest) + return + } + + // Ordner erstellen (Fehler zurückgeben, falls z.B. keine Rechte) + if err := os.MkdirAll(in.RecordDir, 0o755); err != nil { + http.Error(w, "konnte recordDir nicht erstellen: "+err.Error(), http.StatusBadRequest) + return + } + if err := os.MkdirAll(in.DoneDir, 0o755); err != nil { + http.Error(w, "konnte doneDir nicht erstellen: "+err.Error(), http.StatusBadRequest) + return + } + + settingsMu.Lock() + settings = in + settingsMu.Unlock() + saveSettingsToDisk() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(getSettings()) + return + + default: + http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed) + return + } +} + +func settingsBrowse(w http.ResponseWriter, r *http.Request) { + target := r.URL.Query().Get("target") + if target != "record" && target != "done" { + http.Error(w, "target muss record oder done sein", http.StatusBadRequest) + return + } + + p, err := dialog.Directory().Title("Ordner auswählen").Browse() + if err != nil { + // User cancelled → 204 No Content ist praktisch fürs Frontend + if strings.Contains(strings.ToLower(err.Error()), "cancel") { + w.WriteHeader(http.StatusNoContent) + return + } + http.Error(w, "ordnerauswahl fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + return + } + + // optional: wenn innerhalb exe-dir, als RELATIV zurückgeben + p = maybeMakeRelativeToExe(p) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"path": p}) +} + +func maybeMakeRelativeToExe(abs string) string { + exe, err := os.Executable() + if err != nil { + return abs + } + base := filepath.Dir(exe) + + rel, err := filepath.Rel(base, abs) + if err != nil { + return abs + } + // wenn rel mit ".." beginnt -> nicht innerhalb base -> absoluten Pfad behalten + if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return abs + } + return filepath.ToSlash(rel) // frontend-freundlich +} + +// --- Gemeinsame Status-Werte für MFC --- +type Status int + +const ( + StatusUnknown Status = iota + StatusPublic + StatusPrivate + StatusOffline + StatusNotExist +) + +func (s Status) String() string { + switch s { + case StatusPublic: + return "PUBLIC" + case StatusPrivate: + return "PRIVATE" + case StatusOffline: + return "OFFLINE" + case StatusNotExist: + return "NOTEXIST" + default: + return "UNKNOWN" + } +} + +// HTTPClient kapselt http.Client + Header/Cookies (wie internal.Req im DVR) +type HTTPClient struct { + client *http.Client + userAgent string +} + +// gemeinsamen HTTP-Client erzeugen +func NewHTTPClient(userAgent string) *HTTPClient { + if userAgent == "" { + // Default, falls kein UA übergeben wird + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" + } + + return &HTTPClient{ + client: &http.Client{ + Timeout: 10 * time.Second, + }, + userAgent: userAgent, + } +} + +// Request-Erstellung mit User-Agent + Cookies +func (h *HTTPClient) NewRequest(ctx context.Context, method, url, cookieStr string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, url, nil) + if err != nil { + return nil, err + } + + // Basis-Header, die immer gesetzt werden + if h.userAgent != "" { + req.Header.Set("User-Agent", h.userAgent) + } else { + req.Header.Set("User-Agent", "Mozilla/5.0") + } + req.Header.Set("Accept", "*/*") + + // Cookie-String wie "name=value; foo=bar" + addCookiesFromString(req, cookieStr) + + return req, nil +} + +// Seite laden + einfache Erkennung von Schutzseiten (Cloudflare / Age-Gate) +func (h *HTTPClient) FetchPage(ctx context.Context, url, cookieStr string) (string, error) { + req, err := h.NewRequest(ctx, http.MethodGet, url, cookieStr) + if err != nil { + return "", err + } + + resp, err := h.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + body := string(data) + + // Etwas aussagekräftigere Fehler als nur "room dossier nicht gefunden" + if strings.Contains(body, "Just a moment...") { + return "", errors.New("Schutzseite von Cloudflare erhalten (\"Just a moment...\") – kein Room-HTML") + } + if strings.Contains(body, "Verify your age") { + return "", errors.New("Altersverifikationsseite erhalten – kein Room-HTML") + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d beim Laden von %s", resp.StatusCode, url) + } + + return body, nil +} + +func remuxTSToMP4(tsPath, mp4Path string) error { + // ffmpeg -y -i in.ts -c copy -movflags +faststart out.mp4 + cmd := exec.Command("ffmpeg", + "-y", + "-i", tsPath, + "-c", "copy", + "-movflags", "+faststart", + mp4Path, + ) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("ffmpeg remux failed: %v (%s)", err, stderr.String()) + } + return nil +} + +func extractLastFrameJPEG(path string) ([]byte, error) { + // “Letzter Frame” über -sseof nahe Dateiende. + // Bei laufenden Aufnahmen kann das manchmal fehlschlagen -> Fallback oben. + cmd := exec.Command( + "ffmpeg", + "-hide_banner", + "-loglevel", "error", + "-sseof", "-0.1", + "-i", path, + "-frames:v", "1", + "-q:v", "4", + "-f", "image2pipe", + "-vcodec", "mjpeg", + "pipe:1", + ) + + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("ffmpeg last-frame: %w (%s)", err, strings.TrimSpace(stderr.String())) + } + return out.Bytes(), nil +} + +func recordPreview(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "id fehlt", http.StatusBadRequest) + return + } + + // HLS mode: ?file=index.m3u8 / seg_00001.ts / index_hq.m3u8 ... + if file := r.URL.Query().Get("file"); file != "" { + servePreviewHLSFile(w, r, id, file) + return + } + + // sonst: JPEG Fallback + jobsMu.Lock() + job, ok := jobs[id] + jobsMu.Unlock() + + if !ok { + http.Error(w, "job nicht gefunden", http.StatusNotFound) + return + } + + outPath := strings.TrimSpace(job.Output) + if outPath == "" { + http.Error(w, "preview nicht verfügbar", http.StatusNotFound) + return + } + + outPath = filepath.Clean(outPath) + + // ✅ Basic hardening: relative Pfade dürfen nicht mit ".." anfangen. + // (Absolute Pfade wie "/records/x.mp4" oder "C:\records\x.mp4" sind ok.) + if !filepath.IsAbs(outPath) { + if outPath == "." || outPath == ".." || + strings.HasPrefix(outPath, ".."+string(os.PathSeparator)) { + http.Error(w, "ungültiger output-pfad", http.StatusBadRequest) + return + } + } + + fi, err := os.Stat(outPath) + if err != nil { + http.Error(w, "preview nicht verfügbar", http.StatusNotFound) + return + } + if fi.IsDir() || fi.Size() == 0 { + http.Error(w, "preview nicht verfügbar", http.StatusNotFound) + return + } + + img, err := extractLastFrameJPEG(outPath) + if err != nil { + // Fallback: erster Frame klappt bei “wachsenden” Dateien oft zuverlässiger + img2, err2 := extractFirstFrameJPEG(outPath) + if err2 != nil { + http.Error(w, "konnte preview nicht erzeugen: "+err.Error(), http.StatusInternalServerError) + return + } + img = img2 + } + + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(img) +} + +func recordList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) + return + } + + jobsMu.Lock() + list := make([]*RecordJob, 0, len(jobs)) + for _, j := range jobs { + list = append(list, j) + } + jobsMu.Unlock() + + // optional: neueste zuerst + sort.Slice(list, func(i, j int) bool { + return list[i].StartedAt.After(list[j].StartedAt) + }) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(list) +} + +var previewFileRe = regexp.MustCompile(`^(index(_hq)?\.m3u8|seg_(low|hq)_\d+\.ts|seg_\d+\.ts)$`) + +func servePreviewHLSFile(w http.ResponseWriter, r *http.Request, id, file string) { + file = strings.TrimSpace(file) + if file == "" || filepath.Base(file) != file || !previewFileRe.MatchString(file) { + http.Error(w, "ungültige file", http.StatusBadRequest) + return + } + + isIndex := file == "index.m3u8" || file == "index_hq.m3u8" + + jobsMu.Lock() + job, ok := jobs[id] + jobsMu.Unlock() + if !ok { + // Job wirklich unbekannt => 404 ist ok + http.Error(w, "job nicht gefunden", http.StatusNotFound) + return + } + + // Preview noch nicht initialisiert? Für index => 204 (kein roter Fehler im Browser) + if strings.TrimSpace(job.PreviewDir) == "" { + if isIndex { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNoContent) + return + } + http.Error(w, "preview nicht verfügbar", http.StatusNotFound) + return + } + + p := filepath.Join(job.PreviewDir, file) + if _, err := os.Stat(p); err != nil { + if isIndex { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNoContent) + return + } + http.Error(w, "datei nicht gefunden", http.StatusNotFound) + return + } + + switch strings.ToLower(filepath.Ext(p)) { + case ".m3u8": + w.Header().Set("Content-Type", "application/vnd.apple.mpegurl; charset=utf-8") + case ".ts": + w.Header().Set("Content-Type", "video/mp2t") + default: + w.Header().Set("Content-Type", "application/octet-stream") + } + w.Header().Set("Cache-Control", "no-store") + http.ServeFile(w, r, p) +} + +func rewriteM3U8ToPreviewEndpoint(m3u8 string, id string) string { + lines := strings.Split(m3u8, "\n") + escapedID := url.QueryEscape(id) + + for i, line := range lines { + l := strings.TrimSpace(line) + if l == "" || strings.HasPrefix(l, "#") { + continue + } + // Segment/URI-Zeilen umschreiben + lines[i] = "/api/record/preview?id=" + escapedID + "&file=" + url.QueryEscape(l) + } + + return strings.Join(lines, "\n") +} + +func startPreviewHLS(ctx context.Context, jobID, m3u8URL, previewDir, httpCookie, userAgent string) error { + if err := os.MkdirAll(previewDir, 0755); err != nil { + return err + } + + commonIn := []string{"-y"} + if strings.TrimSpace(userAgent) != "" { + commonIn = append(commonIn, "-user_agent", userAgent) + } + if strings.TrimSpace(httpCookie) != "" { + commonIn = append(commonIn, "-headers", fmt.Sprintf("Cookie: %s\r\n", httpCookie)) + } + commonIn = append(commonIn, "-i", m3u8URL) + + baseURL := fmt.Sprintf("/api/record/preview?id=%s&file=", url.QueryEscape(jobID)) + + // LOW (ohne Audio – spart Bandbreite) + lowArgs := append(commonIn, + "-vf", "scale=160:-2", + "-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency", + "-g", "48", "-keyint_min", "48", "-sc_threshold", "0", + "-an", + "-f", "hls", + "-hls_time", "2", + "-hls_list_size", "4", + "-hls_flags", "delete_segments+append_list+independent_segments", + "-hls_segment_filename", filepath.Join(previewDir, "seg_low_%05d.ts"), + "-hls_base_url", baseURL, + filepath.Join(previewDir, "index.m3u8"), + ) + + // HQ (mit Audio) + hqArgs := append(commonIn, + "-vf", "scale=640:-2", + "-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency", + "-g", "48", "-keyint_min", "48", "-sc_threshold", "0", + "-c:a", "aac", "-b:a", "128k", "-ac", "2", + "-f", "hls", + "-hls_time", "2", + "-hls_list_size", "4", + "-hls_flags", "delete_segments+append_list+independent_segments", + "-hls_segment_filename", filepath.Join(previewDir, "seg_hq_%05d.ts"), + "-hls_base_url", baseURL, + filepath.Join(previewDir, "index_hq.m3u8"), + ) + + // beide Prozesse starten (einfach & robust) + go func(kind string, args []string) { + cmd := exec.CommandContext(ctx, "ffmpeg", args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil && ctx.Err() == nil { + fmt.Printf("⚠️ preview %s ffmpeg failed: %v (%s)\n", kind, err, strings.TrimSpace(stderr.String())) + } + }("low", lowArgs) + + go func(kind string, args []string) { + cmd := exec.CommandContext(ctx, "ffmpeg", args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil && ctx.Err() == nil { + fmt.Printf("⚠️ preview %s ffmpeg failed: %v (%s)\n", kind, err, strings.TrimSpace(stderr.String())) + } + }("hq", hqArgs) + + return nil +} + +func extractFirstFrameJPEG(path string) ([]byte, error) { + cmd := exec.Command( + "ffmpeg", + "-hide_banner", + "-loglevel", "error", + "-i", path, + "-frames:v", "1", + "-q:v", "4", + "-f", "image2pipe", + "-vcodec", "mjpeg", + "pipe:1", + ) + + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("ffmpeg first-frame: %w (%s)", err, strings.TrimSpace(stderr.String())) + } + return out.Bytes(), nil +} + +func resolvePathRelativeToExe(p string) (string, error) { + p = strings.TrimSpace(p) + if p == "" { + return "", nil + } + + // akzeptiere sowohl "records/done" als auch "records\done" + p = filepath.Clean(filepath.FromSlash(p)) + + if filepath.IsAbs(p) { + return p, nil + } + + exe, err := os.Executable() + if err != nil { + return "", err + } + base := filepath.Dir(exe) + return filepath.Join(base, p), nil +} + +// routes.go (package main) +func registerRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/settings", recordSettingsHandler) + mux.HandleFunc("/api/settings/browse", settingsBrowse) + + mux.HandleFunc("/api/record", startRecordingFromRequest) + mux.HandleFunc("/api/record/status", recordStatus) + mux.HandleFunc("/api/record/stop", recordStop) + mux.HandleFunc("/api/record/preview", recordPreview) + mux.HandleFunc("/api/record/list", recordList) + mux.HandleFunc("/api/record/video", recordVideo) + mux.HandleFunc("/api/record/done", recordDoneList) + + modelsPath, _ := resolvePathRelativeToExe("data/models_store.json") + store := NewModelStore(modelsPath) + if err := store.Load(); err != nil { + fmt.Println("⚠️ models load:", err) + } + + // ✅ registriert /api/models/list, /parse, /upsert, /flags, /delete + RegisterModelAPI(mux, store) +} + +// --- main --- +func main() { + loadSettings() + + mux := http.NewServeMux() + registerRoutes(mux) + + fmt.Println("🌐 HTTP-API aktiv: http://localhost:8080") + if err := http.ListenAndServe(":8080", mux); err != nil { + fmt.Println("❌ HTTP-Server Fehler:", err) + os.Exit(1) + } +} + +type RecordRequest struct { + URL string `json:"url"` + Cookie string `json:"cookie,omitempty"` + UserAgent string `json:"userAgent,omitempty"` +} + +func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) + return + } + + var req RecordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.URL == "" { + http.Error(w, "url fehlt", http.StatusBadRequest) + return + } + + jobID := uuid.NewString() + ctx, cancel := context.WithCancel(context.Background()) + + job := &RecordJob{ + ID: jobID, + SourceURL: req.URL, + Status: JobRunning, + StartedAt: time.Now(), + cancel: cancel, + } + + jobsMu.Lock() + jobs[jobID] = job + jobsMu.Unlock() + + go runJob(ctx, job, req) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(job) +} + +func parseCookieString(cookieStr string) map[string]string { + out := map[string]string{} + for _, pair := range strings.Split(cookieStr, ";") { + parts := strings.SplitN(strings.TrimSpace(pair), "=", 2) + if len(parts) != 2 { + continue + } + name := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + if name == "" { + continue + } + out[strings.ToLower(name)] = value + } + return out +} + +func hasChaturbateCookies(cookieStr string) bool { + m := parseCookieString(cookieStr) + _, hasCF := m["cf_clearance"] + // akzeptiere session_id ODER sessionid ODER sessionid/sessionId Varianten (case-insensitive durch ToLower) + _, hasSessID := m["session_id"] + _, hasSessIdAlt := m["sessionid"] // falls es ohne underscore kommt + return hasCF && (hasSessID || hasSessIdAlt) +} + +func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { + defer func() { + now := time.Now() + job.EndedAt = &now + }() + + hc := NewHTTPClient(req.UserAgent) + provider := detectProvider(req.URL) + + var err error + now := time.Now() + + switch provider { + case "chaturbate": + if !hasChaturbateCookies(req.Cookie) { + err = errors.New("cf_clearance und session_id (oder sessionid) Cookies sind für Chaturbate erforderlich") + break + } + + s := getSettings() + + username := extractUsername(req.URL) + filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05")) + outPath := filepath.Join(s.RecordDir, filename) + + job.Output = outPath + err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job) + + case "mfc": + s := getSettings() + + username := extractMFCUsername(req.URL) + filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05")) + outPath := filepath.Join(s.RecordDir, filename) + + job.Output = outPath + err = RecordStreamMFC(ctx, hc, username, outPath, job) + + default: + err = errors.New("unsupported provider") + } + + jobsMu.Lock() + defer jobsMu.Unlock() + + if err != nil { + if errors.Is(err, context.Canceled) { + job.Status = JobStopped + + // ✅ Auch bei STOP: .ts -> .mp4 remuxen (falls möglich) + if newOut, err2 := maybeRemuxTS(job.Output); err2 == nil && newOut != "" { + job.Output = newOut + } + + // ✅ und danach nach "done" verschieben + if moved, err2 := moveToDoneDir(job.Output); err2 == nil && moved != "" { + job.Output = moved + } + } else { + job.Status = JobFailed + job.Error = err.Error() + + // ✅ best effort: trotzdem remuxen und nach done verschieben (falls Datei existiert) + if newOut, err2 := maybeRemuxTS(job.Output); err2 == nil && newOut != "" { + job.Output = newOut + } + if moved, err2 := moveToDoneDir(job.Output); err2 == nil && moved != "" { + job.Output = moved + } + } + } else { + job.Status = JobFinished + + // ✅ Erst remuxen (damit in /done direkt die .mp4 landet) + if newOut, err2 := maybeRemuxTS(job.Output); err2 == nil && newOut != "" { + job.Output = newOut + } + + // ✅ nach "done" verschieben (robust) + if moved, err2 := moveToDoneDir(job.Output); err2 == nil && moved != "" { + job.Output = moved + } + } + +} + +func recordVideo(w http.ResponseWriter, r *http.Request) { + // ✅ NEU: Wiedergabe über Dateiname (für doneDir / recordDir) + if raw := r.URL.Query().Get("file"); strings.TrimSpace(raw) != "" { + // ✅ explizit decoden (zur Sicherheit) + file, err := url.QueryUnescape(raw) + if err != nil { + http.Error(w, "ungültiger file", http.StatusBadRequest) + return + } + file = strings.TrimSpace(file) + + // ✅ kein Pfad, keine Backslashes, kein Traversal + if file == "" || + strings.Contains(file, "/") || + strings.Contains(file, "\\") || + filepath.Base(file) != file { + http.Error(w, "ungültiger file", http.StatusBadRequest) + return + } + + ext := strings.ToLower(filepath.Ext(file)) + if ext != ".mp4" && ext != ".ts" { + http.Error(w, "nicht erlaubt", http.StatusForbidden) + return + } + + s := getSettings() + recordAbs, _ := resolvePathRelativeToExe(s.RecordDir) + doneAbs, _ := resolvePathRelativeToExe(s.DoneDir) + + candidates := []string{ + filepath.Join(doneAbs, file), // bevorzugt doneDir + filepath.Join(recordAbs, file), // fallback recordDir + } + + var outPath string + for _, p := range candidates { + fi, err := os.Stat(p) + if err == nil && !fi.IsDir() && fi.Size() > 0 { + outPath = p + break + } + } + + if outPath == "" { + http.Error(w, "datei nicht gefunden", http.StatusNotFound) + return + } + + if ext == ".mp4" { + w.Header().Set("Content-Type", "video/mp4") + } else { + w.Header().Set("Content-Type", "video/mp2t") + } + w.Header().Set("Cache-Control", "no-store") + http.ServeFile(w, r, outPath) + return + } + + // --- ALT: Wiedergabe über Job-ID (funktioniert nur solange Job im RAM existiert) --- + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "id fehlt", http.StatusBadRequest) + return + } + + jobsMu.Lock() + job, ok := jobs[id] + jobsMu.Unlock() + if !ok { + http.Error(w, "job nicht gefunden", http.StatusNotFound) + return + } + + outPath := filepath.Clean(strings.TrimSpace(job.Output)) + if outPath == "" { + http.Error(w, "output fehlt", http.StatusNotFound) + return + } + + if !filepath.IsAbs(outPath) { + abs, err := resolvePathRelativeToExe(outPath) + if err != nil { + http.Error(w, "pfad auflösung fehlgeschlagen", http.StatusInternalServerError) + return + } + outPath = abs + } + + fi, err := os.Stat(outPath) + if err != nil || fi.IsDir() || fi.Size() == 0 { + http.Error(w, "datei nicht gefunden", http.StatusNotFound) + return + } + + ext := strings.ToLower(filepath.Ext(outPath)) + if ext == ".mp4" { + w.Header().Set("Content-Type", "video/mp4") + } else if ext == ".ts" { + w.Header().Set("Content-Type", "video/mp2t") + } + + w.Header().Set("Cache-Control", "no-store") + http.ServeFile(w, r, outPath) +} + +func recordDoneList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) + return + } + + s := getSettings() + doneAbs, err := resolvePathRelativeToExe(s.DoneDir) + if err != nil { + http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + return + } + + entries, err := os.ReadDir(doneAbs) + if err != nil { + http.Error(w, "doneDir lesen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + return + } + + list := make([]*RecordJob, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + ext := strings.ToLower(filepath.Ext(name)) + if ext != ".mp4" && ext != ".ts" { + continue + } + + full := filepath.Join(doneAbs, name) + fi, err := os.Stat(full) + if err != nil || fi.IsDir() { + continue + } + + // ID stabil aus Dateiname (ohne Extension) – reicht für Player/Key + base := strings.TrimSuffix(name, filepath.Ext(name)) + + // best effort: Zeiten aus FileInfo + t := fi.ModTime() + + list = append(list, &RecordJob{ + ID: base, + SourceURL: "", + Output: full, + Status: JobFinished, + StartedAt: t, + EndedAt: &t, + }) + } + + // neueste zuerst + sort.Slice(list, func(i, j int) bool { + return list[i].EndedAt.After(*list[j].EndedAt) + }) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(list) +} + +func maybeRemuxTS(path string) (string, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", nil + } + + if !strings.EqualFold(filepath.Ext(path), ".ts") { + return "", nil + } + + mp4 := strings.TrimSuffix(path, filepath.Ext(path)) + ".mp4" + + // remux (ohne neu encoden) + if err := remuxTSToMP4(path, mp4); err != nil { + return "", err + } + + _ = os.Remove(path) // TS entfernen, wenn MP4 ok + return mp4, nil +} + +func moveFile(src, dst string) error { + // zuerst Rename (schnell) + if err := os.Rename(src, dst); err == nil { + return nil + } else { + // Fallback: Copy+Remove (z.B. bei EXDEV) + in, err2 := os.Open(src) + if err2 != nil { + return err + } + defer in.Close() + + out, err2 := os.Create(dst) + if err2 != nil { + return err + } + if _, err2 := io.Copy(out, in); err2 != nil { + out.Close() + return err2 + } + if err2 := out.Close(); err2 != nil { + return err2 + } + return os.Remove(src) + } +} + +func moveToDoneDir(outputPath string) (string, error) { + outputPath = strings.TrimSpace(outputPath) + if outputPath == "" { + return "", nil + } + + s := getSettings() + + // ✅ doneDir relativ zur exe auflösen (funktion hast du schon) + doneDirAbs, err := resolvePathRelativeToExe(s.DoneDir) + if err != nil { + return "", err + } + if err := os.MkdirAll(doneDirAbs, 0o755); err != nil { + return "", err + } + + dst := filepath.Join(doneDirAbs, filepath.Base(outputPath)) + if err := moveFile(outputPath, dst); err != nil { + return "", err + } + return dst, nil +} + +func recordStatus(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "id fehlt", http.StatusBadRequest) + return + } + + jobsMu.Lock() + job, ok := jobs[id] + jobsMu.Unlock() + + if !ok { + http.Error(w, "job nicht gefunden", http.StatusNotFound) + return + } + + json.NewEncoder(w).Encode(job) +} + +func recordStop(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Nur POST", http.StatusMethodNotAllowed) + return + } + + id := r.URL.Query().Get("id") + jobsMu.Lock() + job, ok := jobs[id] + jobsMu.Unlock() + + if !ok { + http.Error(w, "job nicht gefunden", http.StatusNotFound) + return + } + + if job.previewCmd != nil && job.previewCmd.Process != nil { + _ = job.previewCmd.Process.Kill() + job.previewCmd = nil + } + + if job.cancel != nil { + job.cancel() + } + + out := job.Output + if strings.EqualFold(filepath.Ext(out), ".ts") { + mp4 := strings.TrimSuffix(out, filepath.Ext(out)) + ".mp4" + + if err := remuxTSToMP4(out, mp4); err == nil { + _ = os.Remove(out) // optional: TS löschen + job.Output = mp4 // wichtig: Output umstellen + } else { + // optional: loggen, TS behalten + } + } + + fmt.Println("📡 Aufnahme gestoppt:", job.ID) + + w.Write([]byte(`{"ok":"stopped"}`)) +} + +// --- DVR-ähnlicher Recorder-Ablauf --- +// Entspricht grob dem RecordStream aus dem Channel-Snippet: +func RecordStream( + ctx context.Context, + hc *HTTPClient, + domain string, + username string, + outputPath string, + httpCookie string, + job *RecordJob, +) error { + // 1) Seite laden + // Domain sauber zusammenbauen (mit/ohne Slash) + base := strings.TrimRight(domain, "/") + pageURL := base + "/" + username + + body, err := hc.FetchPage(ctx, pageURL, httpCookie) + + // 2) HLS-URL aus roomDossier extrahieren (wie DVR.ParseStream) + hlsURL, err := ParseStream(body) + if err != nil { + return fmt.Errorf("stream-parsing: %w", err) + } + + // 3) Playlist holen (wie stream.GetPlaylist im DVR) + playlist, err := FetchPlaylist(ctx, hc, hlsURL, httpCookie) + if err != nil { + return fmt.Errorf("playlist abrufen: %w", err) + } + + if job != nil && strings.TrimSpace(job.PreviewDir) == "" { + previewDir := filepath.Join(os.TempDir(), "rec_preview", job.ID) + + jobsMu.Lock() + job.PreviewDir = previewDir + jobsMu.Unlock() + + if err := startPreviewHLS(ctx, job.ID, playlist.PlaylistURL, previewDir, httpCookie, hc.userAgent); err != nil { + fmt.Println("⚠️ preview start fehlgeschlagen:", err) + } + } + + fmt.Printf("Stream-Qualität: %dp @ %dfps\n", playlist.Resolution, playlist.Framerate) + + // 4) Datei öffnen + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("datei erstellen: %w", err) + } + defer func() { + _ = file.Close() + }() + + fmt.Println("📡 Aufnahme gestartet:", outputPath) + + // 5) Segmente „watchen“ – analog zu WatchSegments + HandleSegment im DVR + err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error { + // Hier wäre im DVR ch.HandleSegment – bei dir einfach in eine Datei schreiben + if _, err := file.Write(b); err != nil { + return fmt.Errorf("schreibe segment: %w", err) + } + // Könntest hier z.B. auch Dauer/Größe loggen, wenn du möchtest + _ = duration // aktuell unbenutzt + return nil + }) + if err != nil { + return fmt.Errorf("watch segments: %w", err) + } + + return nil +} + +// RecordStreamMFC nimmt vorerst die URL 1:1 und ruft ffmpeg direkt darauf auf. +// In der Praxis musst du hier meist erst eine HLS-URL aus dem HTML extrahieren. +// RecordStreamMFC ist jetzt nur noch ein Wrapper um den bewährten MFC-Flow (runMFC). +func RecordStreamMFC( + ctx context.Context, + hc *HTTPClient, + username string, + outputPath string, + job *RecordJob, +) error { + mfc := NewMyFreeCams(username) + + // optional, aber sinnvoll: nur aufnehmen wenn Public + st, err := mfc.GetStatus() + if err != nil { + return fmt.Errorf("mfc status: %w", err) + } + if st != StatusPublic { + return fmt.Errorf("Stream ist nicht öffentlich (Status: %s)", st) + } + + m3u8URL, err := mfc.GetVideoURL(false) + if err != nil { + return fmt.Errorf("mfc get video url: %w", err) + } + if strings.TrimSpace(m3u8URL) == "" { + return fmt.Errorf("mfc: keine m3u8 URL gefunden") + } + + // ✅ Preview starten + if job != nil && job.PreviewDir == "" { + previewDir := filepath.Join(os.TempDir(), "preview_"+job.ID) + job.PreviewDir = previewDir + + if err := startPreviewHLS(ctx, job.ID, m3u8URL, previewDir, "", hc.userAgent); err != nil { + fmt.Println("⚠️ preview start fehlgeschlagen:", err) + job.PreviewDir = "" // rollback + } + } + + // Aufnahme starten + return handleM3U8Mode(ctx, m3u8URL, outputPath) +} + +func detectProvider(raw string) string { + s := strings.ToLower(raw) + + if strings.Contains(s, "chaturbate.com") { + return "chaturbate" + } + if strings.Contains(s, "myfreecams.com") { + return "mfc" + } + return "unknown" +} + +// --- helper --- +func extractUsername(input string) string { + input = strings.TrimSpace(input) + input = strings.TrimPrefix(input, "https://") + input = strings.TrimPrefix(input, "http://") + input = strings.TrimPrefix(input, "www.") + if strings.HasPrefix(input, "chaturbate.com/") { + input = strings.TrimPrefix(input, "chaturbate.com/") + } + + // alles nach dem ersten Slash abschneiden (Pfadteile, /, etc.) + if idx := strings.IndexAny(input, "/?#"); idx != -1 { + input = input[:idx] + } + + // zur Sicherheit evtl. übrig gebliebene Slash/Backslash trimmen + return strings.Trim(input, "/\\") +} + +// Cookie-Hilfsfunktion (wie ParseCookies + AddCookie im DVR) +func addCookiesFromString(req *http.Request, cookieStr string) { + if cookieStr == "" { + return + } + pairs := strings.Split(cookieStr, ";") + for _, pair := range pairs { + parts := strings.SplitN(strings.TrimSpace(pair), "=", 2) + if len(parts) != 2 { + continue + } + name := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + if name == "" { + continue + } + req.AddCookie(&http.Cookie{ + Name: name, + Value: value, + }) + } +} + +// ParseStream entspricht der DVR-Variante (roomDossier → hls_source) +func ParseStream(html string) (string, error) { + matches := roomDossierRegexp.FindStringSubmatch(html) + if len(matches) == 0 { + return "", errors.New("room dossier nicht gefunden") + } + + // DVR-Style Unicode-Decode + decoded, err := strconv.Unquote( + strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1), + ) + if err != nil { + return "", fmt.Errorf("Unicode-decode failed: %w", err) + } + + var rd struct { + HLSSource string `json:"hls_source"` + } + if err := json.Unmarshal([]byte(decoded), &rd); err != nil { + return "", fmt.Errorf("JSON-parse failed: %w", err) + } + if rd.HLSSource == "" { + return "", errors.New("kein HLS-Quell-URL im JSON") + } + return rd.HLSSource, nil +} + +// --- Playlist/WatchSegments wie gehabt --- +type Playlist struct { + PlaylistURL string + RootURL string + Resolution int + Framerate int +} + +type Resolution struct { + Framerate map[int]string + Width int +} + +// nimmt jetzt *HTTPClient entgegen +func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie string) (*Playlist, error) { + if hlsSource == "" { + return nil, errors.New("HLS-URL leer") + } + + req, err := hc.NewRequest(ctx, http.MethodGet, hlsSource, httpCookie) + if err != nil { + return nil, fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err) + } + + resp, err := hc.client.Do(req) + if err != nil { + return nil, fmt.Errorf("Fehler beim Laden der Playlist: %w", err) + } + defer resp.Body.Close() + + playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) + if err != nil || listType != m3u8.MASTER { + return nil, errors.New("keine gültige Master-Playlist") + } + + master := playlist.(*m3u8.MasterPlaylist) + var bestURI string + var bestWidth int + var bestFramerate int + + for _, variant := range master.Variants { + if variant == nil || variant.Resolution == "" { + continue + } + + parts := strings.Split(variant.Resolution, "x") + if len(parts) != 2 { + continue + } + + width, err := strconv.Atoi(parts[1]) + if err != nil { + continue + } + + fr := 30 + if strings.Contains(variant.Name, "FPS:60.0") { + fr = 60 + } + + if width > bestWidth || (width == bestWidth && fr > bestFramerate) { + bestWidth = width + bestFramerate = fr + bestURI = variant.URI + } + } + + if bestURI == "" { + return nil, errors.New("keine gültige Auflösung gefunden") + } + + root := hlsSource[:strings.LastIndex(hlsSource, "/")+1] + return &Playlist{ + PlaylistURL: root + bestURI, + RootURL: root, + Resolution: bestWidth, + Framerate: bestFramerate, + }, nil +} + +// nutzt ebenfalls *HTTPClient +func (p *Playlist) WatchSegments( + ctx context.Context, + hc *HTTPClient, + httpCookie string, + handler func([]byte, float64) error, +) error { + var lastSeq int64 = -1 + emptyRounds := 0 + const maxEmptyRounds = 5 + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // Playlist holen + req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie) + if err != nil { + return fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err) + } + + resp, err := hc.client.Do(req) + if err != nil { + emptyRounds++ + if emptyRounds >= maxEmptyRounds { + return errors.New("❌ Playlist nicht mehr erreichbar – Stream vermutlich offline") + } + time.Sleep(2 * time.Second) + continue + } + + playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) + resp.Body.Close() + + if err != nil || listType != m3u8.MEDIA { + emptyRounds++ + if emptyRounds >= maxEmptyRounds { + return errors.New("❌ Fehlerhafte Playlist – möglicherweise offline") + } + time.Sleep(2 * time.Second) + continue + } + + media := playlist.(*m3u8.MediaPlaylist) + newSegment := false + + for _, segment := range media.Segments { + if segment == nil { + continue + } + if int64(segment.SeqId) <= lastSeq { + continue + } + + lastSeq = int64(segment.SeqId) + newSegment = true + + segmentURL := p.RootURL + segment.URI + + segReq, err := hc.NewRequest(ctx, http.MethodGet, segmentURL, httpCookie) + if err != nil { + continue + } + + segResp, err := hc.client.Do(segReq) + if err != nil { + continue + } + + data, err := io.ReadAll(segResp.Body) + segResp.Body.Close() + if err != nil || len(data) == 0 { + continue + } + + if err := handler(data, segment.Duration); err != nil { + return err + } + } + + if newSegment { + emptyRounds = 0 + } else { + emptyRounds++ + if emptyRounds >= maxEmptyRounds { + return errors.New("🛑 Keine neuen HLS-Segmente empfangen – Stream vermutlich beendet oder offline.") + } + } + + time.Sleep(1 * time.Second) + } +} + +/* ─────────────────────────────── + MyFreeCams (übernommener Flow) + ─────────────────────────────── */ + +type MyFreeCams struct { + Username string + Attrs map[string]string + VideoURL string +} + +func NewMyFreeCams(username string) *MyFreeCams { + return &MyFreeCams{ + Username: username, + Attrs: map[string]string{}, + } +} + +func (m *MyFreeCams) GetWebsiteURL() string { + return "https://www.myfreecams.com/#" + m.Username +} + +func (m *MyFreeCams) GetVideoURL(refresh bool) (string, error) { + if !refresh && m.VideoURL != "" { + return m.VideoURL, nil + } + + // Prüfen, ob alle benötigten Attribute vorhanden sind + if _, ok := m.Attrs["data-cam-preview-model-id-value"]; !ok { + return "", nil + } + sid := m.Attrs["data-cam-preview-server-id-value"] + midBase := m.Attrs["data-cam-preview-model-id-value"] + isWzobs := strings.ToLower(m.Attrs["data-cam-preview-is-wzobs-value"]) == "true" + + midInt, err := strconv.Atoi(midBase) + if err != nil { + return "", fmt.Errorf("model-id parse error: %w", err) + } + mid := 100000000 + midInt + a := "" + if isWzobs { + a = "a_" + } + + playlistURL := fmt.Sprintf( + "https://previews.myfreecams.com/hls/NxServer/%s/ngrp:mfc_%s%d.f4v_mobile_mhp1080_previewurl/playlist.m3u8", + sid, a, mid, + ) + + // Validieren (HTTP 200) & ggf. auf gewünschte Auflösung verlinken + u, err := getWantedResolutionPlaylist(playlistURL) + if err != nil { + return "", err + } + m.VideoURL = u + return m.VideoURL, nil +} + +func (m *MyFreeCams) GetStatus() (Status, error) { + // 1) share-Seite prüfen (existiert/nicht existiert) + shareURL := "https://share.myfreecams.com/" + m.Username + resp, err := http.Get(shareURL) + if err != nil { + return StatusUnknown, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return StatusNotExist, nil + } + if resp.StatusCode != 200 { + return StatusUnknown, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + // wir brauchen sowohl Bytes (für Suche) als auch Reader (für HTML) + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return StatusUnknown, err + } + + // 2) „tracking.php?“ suchen und prüfen, ob model_id vorhanden ist + start := bytes.Index(bodyBytes, []byte("https://www.myfreecams.com/php/tracking.php?")) + if start == -1 { + // ohne tracking Parameter -> behandeln wie nicht existent + return StatusNotExist, nil + } + end := bytes.IndexByte(bodyBytes[start:], '"') + if end == -1 { + return StatusUnknown, errors.New("tracking url parse failed") + } + raw := string(bodyBytes[start : start+end]) + u, err := url.Parse(raw) + if err != nil { + return StatusUnknown, fmt.Errorf("tracking url invalid: %w", err) + } + qs := u.Query() + if qs.Get("model_id") == "" { + return StatusNotExist, nil + } + + // 3) HTML parsen und
Attribute auslesen + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(bodyBytes)) + if err != nil { + return StatusUnknown, err + } + + params := doc.Find(".campreview").First() + if params.Length() == 0 { + // keine campreview -> offline + return StatusOffline, nil + } + + attrs := map[string]string{} + params.Each(func(_ int, s *goquery.Selection) { + for _, a := range []string{ + "data-cam-preview-server-id-value", + "data-cam-preview-model-id-value", + "data-cam-preview-is-wzobs-value", + } { + if v, ok := s.Attr(a); ok { + attrs[a] = v + } + } + }) + m.Attrs = attrs + + // 4) Versuchen, VideoURL (Preview-HLS) zu ermitteln + uStr, err := m.GetVideoURL(true) + if err != nil { + return StatusUnknown, err + } + if uStr != "" { + return StatusPublic, nil + } + // campreview vorhanden, aber keine playable url -> „PRIVATE“ + return StatusPrivate, nil +} + +func runMFC(ctx context.Context, username string, outArg string) error { + mfc := NewMyFreeCams(username) + + st, err := mfc.GetStatus() + if err != nil { + return err + } + if st != StatusPublic { + return fmt.Errorf("Stream ist nicht öffentlich (Status: %s)", st) + } + + m3u8URL, err := mfc.GetVideoURL(false) + if err != nil { + return err + } + if m3u8URL == "" { + return errors.New("keine m3u8 URL gefunden") + } + + return handleM3U8Mode(ctx, m3u8URL, outArg) +} + +/* ─────────────────────────────── + Gemeinsame HLS/M3U8-Helper (MFC) + ─────────────────────────────── */ + +func getWantedResolutionPlaylist(playlistURL string) (string, error) { + // Holt eine URL; wenn MASTER, wähle beste Variante; wenn MEDIA, gib die URL zurück. + resp, err := http.Get(playlistURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) + } + + playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) + if err != nil { + return "", fmt.Errorf("m3u8 parse: %w", err) + } + if listType == m3u8.MEDIA { + return playlistURL, nil + } + + master := playlist.(*m3u8.MasterPlaylist) + var bestURI string + var bestWidth int + var bestFramerate float64 + + for _, v := range master.Variants { + if v == nil { + continue + } + // Resolution kommt als "WxH" – wir nutzen die Höhe als Vergleichswert. + w := 0 + if v.Resolution != "" { + parts := strings.Split(v.Resolution, "x") + if len(parts) == 2 { + if ww, err := strconv.Atoi(parts[1]); err == nil { + w = ww + } + } + } + fr := 30.0 + if v.FrameRate > 0 { + fr = v.FrameRate + } else if strings.Contains(v.Name, "FPS:60") { + fr = 60 + } + if w > bestWidth || (w == bestWidth && fr > bestFramerate) { + bestWidth = w + bestFramerate = fr + bestURI = v.URI + } + } + if bestURI == "" { + return "", errors.New("Master-Playlist ohne gültige Varianten") + } + + // Absolutieren + root := playlistURL[:strings.LastIndex(playlistURL, "/")+1] + if strings.HasPrefix(bestURI, "http://") || strings.HasPrefix(bestURI, "https://") { + return bestURI, nil + } + return root + bestURI, nil +} + +func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string) error { + // Validierung + u, err := url.Parse(m3u8URL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("ungültige URL: %q", m3u8URL) + } + + // HTTP-Check MIT Context + req, err := http.NewRequestWithContext(ctx, "GET", m3u8URL, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) + } + + if strings.TrimSpace(outFile) == "" { + return errors.New("output file path leer") + } + + // ffmpeg mit Context (STOP FUNKTIONIERT HIER!) + cmd := exec.CommandContext( + ctx, + "ffmpeg", + "-y", + "-i", m3u8URL, + "-c", "copy", + outFile, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + if errors.Is(ctx.Err(), context.Canceled) { + return ctx.Err() + } + return fmt.Errorf("ffmpeg fehlgeschlagen: %w", err) + } + + return nil +} + +/* ─────────────────────────────── + Kleine Helper für MFC + ─────────────────────────────── */ + +func extractMFCUsername(input string) string { + if strings.Contains(input, "myfreecams.com/#") { + i := strings.Index(input, "#") + if i >= 0 && i < len(input)-1 { + return strings.TrimSpace(input[i+1:]) + } + return "" + } + return strings.TrimSpace(input) +} + +func readLine() string { + r := bufio.NewReader(os.Stdin) + s, _ := r.ReadString('\n') + return strings.TrimRight(s, "\r\n") +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/backend/models.go b/backend/models.go new file mode 100644 index 0000000..42d7f3a --- /dev/null +++ b/backend/models.go @@ -0,0 +1,228 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" +) + +type Model struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + // optional Flags (kannst du später im UI nutzen) + Watching bool `json:"watching"` + Favorite bool `json:"favorite"` + Hot bool `json:"hot"` + Liked *bool `json:"liked"` // nil = keine Angabe +} + +type modelStore struct { + mu sync.Mutex + path string + loaded bool + items []Model +} + +var models = &modelStore{ + path: filepath.Join("data", "models.json"), +} + +func modelsHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + list, err := modelsList() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, list) + + case http.MethodPost: + var in Model + if err := readJSON(r.Body, &in); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + in.Name = strings.TrimSpace(in.Name) + if in.Name == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + + out, err := modelsUpsert(in) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, out) + + case http.MethodDelete: + // /api/models?id=... + id := strings.TrimSpace(r.URL.Query().Get("id")) + if id == "" { + http.Error(w, "id required", http.StatusBadRequest) + return + } + if err := modelsDelete(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + + default: + w.Header().Set("Allow", "GET, POST, DELETE") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// optional: /api/models/parse?file=ella_desire_12_18_2025__14-25-30.mp4 +func modelsParseHandler(w http.ResponseWriter, r *http.Request) { + file := strings.TrimSpace(r.URL.Query().Get("file")) + if file == "" { + http.Error(w, "file required", http.StatusBadRequest) + return + } + name := modelNameFromFilename(file) + writeJSON(w, http.StatusOK, map[string]string{"model": name}) +} + +var reModel = regexp.MustCompile(`^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}`) + +func modelNameFromFilename(file string) string { + file = strings.ReplaceAll(file, "\\", "/") + base := file[strings.LastIndex(file, "/")+1:] + base = strings.TrimSuffix(base, filepath.Ext(base)) + + if m := reModel.FindStringSubmatch(base); len(m) == 2 && strings.TrimSpace(m[1]) != "" { + return m[1] + } + // fallback: bis zum letzten "_" (wie bisher) + if i := strings.LastIndex(base, "_"); i > 0 { + return base[:i] + } + if base == "" { + return "—" + } + return base +} + +func modelsEnsureLoaded() error { + models.mu.Lock() + defer models.mu.Unlock() + + if models.loaded { + return nil + } + models.loaded = true + + b, err := os.ReadFile(models.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + models.items = []Model{} + return nil + } + return err + } + if len(b) == 0 { + models.items = []Model{} + return nil + } + return json.Unmarshal(b, &models.items) +} + +func modelsSaveLocked() error { + if err := os.MkdirAll(filepath.Dir(models.path), 0o755); err != nil { + return err + } + b, err := json.MarshalIndent(models.items, "", " ") + if err != nil { + return err + } + return os.WriteFile(models.path, b, 0o644) +} + +func modelsList() ([]Model, error) { + if err := modelsEnsureLoaded(); err != nil { + return nil, err + } + models.mu.Lock() + defer models.mu.Unlock() + out := make([]Model, len(models.items)) + copy(out, models.items) + return out, nil +} + +func modelsUpsert(in Model) (Model, error) { + if err := modelsEnsureLoaded(); err != nil { + return Model{}, err + } + + now := time.Now() + models.mu.Lock() + defer models.mu.Unlock() + + // update by ID if provided + if strings.TrimSpace(in.ID) != "" { + for i := range models.items { + if models.items[i].ID == in.ID { + in.CreatedAt = models.items[i].CreatedAt + in.UpdatedAt = now + models.items[i] = in + return in, modelsSaveLocked() + } + } + } + + // otherwise: create new + in.ID = newID() + in.CreatedAt = now + in.UpdatedAt = now + models.items = append(models.items, in) + return in, modelsSaveLocked() +} + +func modelsDelete(id string) error { + if err := modelsEnsureLoaded(); err != nil { + return err + } + models.mu.Lock() + defer models.mu.Unlock() + + out := models.items[:0] + for _, m := range models.items { + if m.ID != id { + out = append(out, m) + } + } + models.items = out + return modelsSaveLocked() +} + +func newID() string { + var b [16]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +func readJSON(r io.Reader, v any) error { + dec := json.NewDecoder(r) + dec.DisallowUnknownFields() + return dec.Decode(v) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/backend/models_api.go b/backend/models_api.go new file mode 100644 index 0000000..7ee81ba --- /dev/null +++ b/backend/models_api.go @@ -0,0 +1,191 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" +) + +// ✅ umbenannt, damit es nicht mit models.go kollidiert +func modelsWriteJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func modelsReadJSON(r *http.Request, v any) error { + if r.Body == nil { + return errors.New("missing body") + } + defer r.Body.Close() + return json.NewDecoder(r.Body).Decode(v) +} + +type parseReq struct { + Input string `json:"input"` +} + +func parseModelFromURL(raw string) (ParsedModelDTO, error) { + in := strings.TrimSpace(raw) + if in == "" { + return ParsedModelDTO{}, errors.New("Bitte eine URL eingeben.") + } + + // scheme ergänzen, falls User "chaturbate.com/xyz" eingibt + if !strings.Contains(in, "://") { + in = "https://" + in + } + + u, err := url.Parse(in) + if err != nil || u.Scheme == "" || u.Hostname() == "" { + return ParsedModelDTO{}, errors.New("Ungültige URL.") + } + + host := strings.ToLower(u.Hostname()) + host = strings.TrimPrefix(host, "www.") + + // ModelKey aus Pfad/Fragment ableiten + path := strings.Trim(u.Path, "/") + segs := strings.Split(path, "/") + + skip := map[string]bool{ + "models": true, "model": true, "profile": true, "users": true, "user": true, + } + + var key string + for _, s := range segs { + s = strings.TrimSpace(s) + if s == "" || skip[strings.ToLower(s)] { + continue + } + key = s + break + } + if key == "" && strings.TrimSpace(u.Fragment) != "" { + key = strings.Trim(strings.TrimSpace(u.Fragment), "/") + } + if key == "" { + return ParsedModelDTO{}, errors.New("Konnte keinen Modelnamen aus der URL ableiten.") + } + + // URL-decode + kleines Sanitizing + if dec, err := url.PathUnescape(key); err == nil { + key = dec + } + key = strings.TrimPrefix(strings.TrimSpace(key), "@") + key = strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z': + return r + case r >= 'A' && r <= 'Z': + return r + case r >= '0' && r <= '9': + return r + case r == '_' || r == '-' || r == '.': + return r + default: + return -1 + } + }, key) + if key == "" { + return ParsedModelDTO{}, errors.New("Ungültiger Modelname in URL.") + } + + return ParsedModelDTO{ + Input: u.String(), // ✅ speicherst du als URL + IsURL: true, + Host: host, + Path: u.Path, + ModelKey: key, // ✅ kommt IMMER aus URL + }, nil +} + +func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) { + + // ✅ NEU: Parse-Endpoint (nur URL erlaubt) + mux.HandleFunc("/api/models/parse", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + var req parseReq + if err := modelsReadJSON(r, &req); err != nil { + modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + dto, err := parseModelFromURL(req.Input) + if err != nil { + modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + modelsWriteJSON(w, http.StatusOK, dto) + }) + + mux.HandleFunc("/api/models/list", func(w http.ResponseWriter, r *http.Request) { + modelsWriteJSON(w, http.StatusOK, store.List()) + }) + + mux.HandleFunc("/api/models/upsert", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + var req ParsedModelDTO + if err := modelsReadJSON(r, &req); err != nil { + modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + + // ✅ Server-seitig: nur URL akzeptieren (wird zusätzlich im Store geprüft) + if !req.IsURL { + modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "Nur URL erlaubt."}) + return + } + + m, err := store.UpsertFromParsed(req) + if err != nil { + modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + modelsWriteJSON(w, http.StatusOK, m) + }) + + mux.HandleFunc("/api/models/flags", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + var req ModelFlagsPatch + if err := modelsReadJSON(r, &req); err != nil { + modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + m, err := store.PatchFlags(req) + if err != nil { + modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + modelsWriteJSON(w, http.StatusOK, m) + }) + + mux.HandleFunc("/api/models/delete", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + var req struct { + ID string `json:"id"` + } + if err := modelsReadJSON(r, &req); err != nil { + modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if err := store.Delete(req.ID); err != nil { + modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + modelsWriteJSON(w, http.StatusOK, map[string]any{"ok": true}) + }) +} diff --git a/backend/models_store.go b/backend/models_store.go new file mode 100644 index 0000000..bc30aa5 --- /dev/null +++ b/backend/models_store.go @@ -0,0 +1,238 @@ +package main + +import ( + "encoding/json" + "errors" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +type StoredModel struct { + ID string `json:"id"` // i.d.R. modelKey (unique) + Input string `json:"input"` // Original-URL/Eingabe + IsURL bool `json:"isUrl"` // vom Parser + Host string `json:"host,omitempty"` + Path string `json:"path,omitempty"` + ModelKey string `json:"modelKey"` // Display/Key + + Watching bool `json:"watching"` + Favorite bool `json:"favorite"` + Hot bool `json:"hot"` + Keep bool `json:"keep"` + Liked *bool `json:"liked,omitempty"` // null => unbekannt + + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type ModelStore struct { + path string + mu sync.RWMutex + items map[string]StoredModel +} + +func NewModelStore(path string) *ModelStore { + return &ModelStore{ + path: path, + items: map[string]StoredModel{}, + } +} + +func (s *ModelStore) Load() error { + s.mu.Lock() + defer s.mu.Unlock() + + b, err := os.ReadFile(s.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil // ok + } + return err + } + + var list []StoredModel + if err := json.Unmarshal(b, &list); err != nil { + return err + } + + s.items = map[string]StoredModel{} + for _, m := range list { + if m.ID == "" { + m.ID = m.ModelKey + } + if m.ID != "" { + s.items[m.ID] = m + } + } + return nil +} + +func (s *ModelStore) saveLocked() error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + + list := make([]StoredModel, 0, len(s.items)) + for _, m := range s.items { + list = append(list, m) + } + + // Neueste zuerst + sort.Slice(list, func(i, j int) bool { return list[i].UpdatedAt > list[j].UpdatedAt }) + + b, err := json.MarshalIndent(list, "", " ") + if err != nil { + return err + } + + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + + // Windows: Rename überschreibt nicht immer zuverlässig -> erst versuchen, sonst löschen & retry + if err := os.Rename(tmp, s.path); err != nil { + _ = os.Remove(s.path) + if err2 := os.Rename(tmp, s.path); err2 != nil { + _ = os.Remove(tmp) + return err2 + } + } + return nil +} + +func (s *ModelStore) List() []StoredModel { + s.mu.RLock() + defer s.mu.RUnlock() + + out := make([]StoredModel, 0, len(s.items)) + for _, m := range s.items { + out = append(out, m) + } + sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt }) + return out +} + +type ParsedModelDTO struct { + Input string `json:"input"` + IsURL bool `json:"isUrl"` + Host string `json:"host,omitempty"` + Path string `json:"path,omitempty"` + ModelKey string `json:"modelKey"` +} + +func (s *ModelStore) UpsertFromParsed(p ParsedModelDTO) (StoredModel, error) { + if p.ModelKey == "" { + return StoredModel{}, errors.New("modelKey fehlt") + } + + input := strings.TrimSpace(p.Input) + if input == "" { + return StoredModel{}, errors.New("URL fehlt.") + } + if !p.IsURL { + return StoredModel{}, errors.New("Nur URL erlaubt.") + } + u, err := url.Parse(input) + if err != nil || u.Scheme == "" || u.Hostname() == "" { + return StoredModel{}, errors.New("Ungültige URL.") + } + if strings.TrimSpace(p.ModelKey) == "" { + return StoredModel{}, errors.New("ModelKey fehlt.") + } + + now := time.Now().UTC().Format(time.RFC3339Nano) + + s.mu.Lock() + defer s.mu.Unlock() + + id := p.ModelKey + existing, ok := s.items[id] + if !ok { + existing = StoredModel{ + ID: id, + CreatedAt: now, + } + } + + // Felder aktualisieren + existing.Input = p.Input + existing.IsURL = p.IsURL + existing.Host = p.Host + existing.Path = p.Path + existing.ModelKey = p.ModelKey + existing.UpdatedAt = now + + s.items[id] = existing + if err := s.saveLocked(); err != nil { + return StoredModel{}, err + } + return existing, nil +} + +type ModelFlagsPatch struct { + ID string `json:"id"` + Watching *bool `json:"watching,omitempty"` + Favorite *bool `json:"favorite,omitempty"` + Hot *bool `json:"hot,omitempty"` + Keep *bool `json:"keep,omitempty"` + Liked *bool `json:"liked,omitempty"` + ClearLiked bool `json:"clearLiked,omitempty"` +} + +func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) { + if patch.ID == "" { + return StoredModel{}, errors.New("id fehlt") + } + + now := time.Now().UTC().Format(time.RFC3339Nano) + + s.mu.Lock() + defer s.mu.Unlock() + + m, ok := s.items[patch.ID] + if !ok { + return StoredModel{}, errors.New("model nicht gefunden") + } + + if patch.Watching != nil { + m.Watching = *patch.Watching + } + if patch.Favorite != nil { + m.Favorite = *patch.Favorite + } + if patch.Hot != nil { + m.Hot = *patch.Hot + } + if patch.Keep != nil { + m.Keep = *patch.Keep + } + if patch.ClearLiked { + m.Liked = nil + } else if patch.Liked != nil { + m.Liked = patch.Liked + } + + m.UpdatedAt = now + s.items[m.ID] = m + + if err := s.saveLocked(); err != nil { + return StoredModel{}, err + } + return m, nil +} + +func (s *ModelStore) Delete(id string) error { + if id == "" { + return errors.New("id fehlt") + } + s.mu.Lock() + defer s.mu.Unlock() + delete(s.items, id) + return s.saveLocked() +} diff --git a/backend/myapp.exe b/backend/myapp.exe new file mode 100644 index 0000000..4ce1ff0 Binary files /dev/null and b/backend/myapp.exe differ diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json new file mode 100644 index 0000000..06fca4c --- /dev/null +++ b/backend/recorder_settings.json @@ -0,0 +1,4 @@ +{ + "recordDir": "C:\\Users\\Rother\\Desktop\\test", + "doneDir": "C:\\Users\\Rother\\Desktop\\test\\done" +} \ No newline at end of file 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..d2e7761 --- /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 [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## 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..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +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: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..072a57e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..7d4bc3d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4236 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@headlessui/react": "^2.2.9", + "@heroicons/react": "^2.2.0", + "@tailwindcss/vite": "^4.1.18", + "hls.js": "^1.6.15", + "prop-types": "^15.8.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwindcss": "^4.1.18", + "video.js": "^8.23.4" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@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.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@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.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "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.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "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/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "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.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "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.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", + "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==", + "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.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "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/@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/@react-aria/focus": { + "version": "3.21.3", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.3.tgz", + "integrity": "sha512-FsquWvjSCwC2/sBk4b+OqJyONETUIXQ2vM0YdPAuC+QFQh2DT6TIBo6dOZVSezlhudDla69xFBd6JvCFq1AbUw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.26.0", + "@react-aria/utils": "^3.32.0", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.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.26.0", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.26.0.tgz", + "integrity": "sha512-AAEcHiltjfbmP1i9iaVw34Mb7kbkiHpYdqieWufldh4aplWgsF11YQZOfaCJW4QoR2ML4Zzoa9nfFwLXA52R7Q==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.32.0", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.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/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/utils": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.32.0.tgz", + "integrity": "sha512-/7Rud06+HVBIlTwmwmJa2W8xVtgxgzm0+kLbuFooZRzKDON6hhozS1dOMR/YLMxyJOaYOTpImcP4vRR9gL1hEg==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.11.0", + "@react-types/shared": "^3.32.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.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-stately/flags": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", + "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz", + "integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.32.1", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz", + "integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==", + "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/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", + "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz", + "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz", + "integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz", + "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz", + "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz", + "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz", + "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz", + "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz", + "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz", + "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz", + "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz", + "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz", + "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz", + "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz", + "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz", + "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz", + "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz", + "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz", + "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz", + "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz", + "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz", + "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "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.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.13", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.13.tgz", + "integrity": "sha512-4o6oPMDvQv+9gMi8rE6gWmsOjtUZUYIJHv7EB+GblyYdi8U6OqLl8rhHWIUZSL1dUU2dPwTdTgybCKf9EjIrQg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.13" + }, + "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.13.13", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.13.tgz", + "integrity": "sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "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.10.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", + "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "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.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz", + "integrity": "sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.50.0", + "@typescript-eslint/type-utils": "8.50.0", + "@typescript-eslint/utils": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.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.50.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz", + "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.50.0", + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/typescript-estree": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.0.tgz", + "integrity": "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.50.0", + "@typescript-eslint/types": "^8.50.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.0.tgz", + "integrity": "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.0.tgz", + "integrity": "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==", + "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.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.0.tgz", + "integrity": "sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/typescript-estree": "8.50.0", + "@typescript-eslint/utils": "8.50.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.0.tgz", + "integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==", + "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.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.0.tgz", + "integrity": "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.50.0", + "@typescript-eslint/tsconfig-utils": "8.50.0", + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.0.tgz", + "integrity": "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.50.0", + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/typescript-estree": "8.50.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.0.tgz", + "integrity": "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.50.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@videojs/http-streaming": { + "version": "3.17.2", + "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.2.tgz", + "integrity": "sha512-VBQ3W4wnKnVKb/limLdtSD2rAd5cmHN70xoMf4OmuDd0t2kfJX04G+sfw6u2j8oOm2BXYM9E1f4acHruqKnM1g==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^4.1.1", + "aes-decrypter": "^4.0.2", + "global": "^4.4.0", + "m3u8-parser": "^7.2.0", + "mpd-parser": "^1.3.1", + "mux.js": "7.1.0", + "video.js": "^7 || ^8" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "video.js": "^8.19.0" + } + }, + "node_modules/@videojs/vhs-utils": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.1.1.tgz", + "integrity": "sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "global": "^4.4.0" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + } + }, + "node_modules/@videojs/xhr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.7.0.tgz", + "integrity": "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.5.5", + "global": "~4.4.0", + "is-function": "^1.0.1" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "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/aes-decrypter": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.2.tgz", + "integrity": "sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^4.1.1", + "global": "^4.4.0", + "pkcs7": "^1.0.4" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "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/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.8.tgz", + "integrity": "sha512-Y1fOuNDowLfgKOypdc9SPABfoWXuZHBOyCS4cD52IeZBhr4Md6CLLs6atcxVrzRmQ06E7hSlm5bHHApPKR/byA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001760", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", + "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "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/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/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/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "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/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/dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "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": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "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" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "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.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "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/global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "license": "MIT", + "dependencies": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "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/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, + "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/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "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-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", + "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==", + "license": "MIT" + }, + "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.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "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==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "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.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "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.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "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/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "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/m3u8-parser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.2.0.tgz", + "integrity": "sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^4.1.1", + "global": "^4.4.0" + } + }, + "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/min-document": { + "version": "2.19.2", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz", + "integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==", + "license": "MIT", + "dependencies": { + "dom-walk": "^0.1.0" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mpd-parser": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.3.1.tgz", + "integrity": "sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^4.0.0", + "@xmldom/xmldom": "^0.8.3", + "global": "^4.4.0" + }, + "bin": { + "mpd-to-m3u8-json": "bin/parse.js" + } + }, + "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/mux.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.1.0.tgz", + "integrity": "sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.11.2", + "global": "^4.4.0" + }, + "bin": { + "muxjs-transmux": "bin/transmux.js" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "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.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "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.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkcs7": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz", + "integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.5.5" + }, + "bin": { + "pkcs7": "bin/cli.js" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "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/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", + "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.5", + "@rollup/rollup-android-arm64": "4.53.5", + "@rollup/rollup-darwin-arm64": "4.53.5", + "@rollup/rollup-darwin-x64": "4.53.5", + "@rollup/rollup-freebsd-arm64": "4.53.5", + "@rollup/rollup-freebsd-x64": "4.53.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.5", + "@rollup/rollup-linux-arm-musleabihf": "4.53.5", + "@rollup/rollup-linux-arm64-gnu": "4.53.5", + "@rollup/rollup-linux-arm64-musl": "4.53.5", + "@rollup/rollup-linux-loong64-gnu": "4.53.5", + "@rollup/rollup-linux-ppc64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-musl": "4.53.5", + "@rollup/rollup-linux-s390x-gnu": "4.53.5", + "@rollup/rollup-linux-x64-gnu": "4.53.5", + "@rollup/rollup-linux-x64-musl": "4.53.5", + "@rollup/rollup-openharmony-arm64": "4.53.5", + "@rollup/rollup-win32-arm64-msvc": "4.53.5", + "@rollup/rollup-win32-ia32-msvc": "4.53.5", + "@rollup/rollup-win32-x64-gnu": "4.53.5", + "@rollup/rollup-win32-x64-msvc": "4.53.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "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/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/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tabbable": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "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": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.50.0.tgz", + "integrity": "sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.50.0", + "@typescript-eslint/parser": "8.50.0", + "@typescript-eslint/typescript-estree": "8.50.0", + "@typescript-eslint/utils": "8.50.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/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/video.js": { + "version": "8.23.4", + "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.4.tgz", + "integrity": "sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@videojs/http-streaming": "^3.17.2", + "@videojs/vhs-utils": "^4.1.1", + "@videojs/xhr": "2.7.0", + "aes-decrypter": "^4.0.2", + "global": "4.4.0", + "m3u8-parser": "^7.2.0", + "mpd-parser": "^1.3.1", + "mux.js": "^7.0.1", + "videojs-contrib-quality-levels": "4.1.0", + "videojs-font": "4.2.0", + "videojs-vtt.js": "0.15.5" + } + }, + "node_modules/videojs-contrib-quality-levels": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.1.0.tgz", + "integrity": "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==", + "license": "Apache-2.0", + "dependencies": { + "global": "^4.4.0" + }, + "engines": { + "node": ">=16", + "npm": ">=8" + }, + "peerDependencies": { + "video.js": "^8" + } + }, + "node_modules/videojs-font": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.2.0.tgz", + "integrity": "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==", + "license": "Apache-2.0" + }, + "node_modules/videojs-vtt.js": { + "version": "0.15.5", + "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz", + "integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==", + "license": "Apache-2.0", + "dependencies": { + "global": "^4.3.1" + } + }, + "node_modules/vite": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "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.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", + "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..c6425d5 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "frontend", + "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.9", + "@heroicons/react": "^2.2.0", + "@tailwindcss/vite": "^4.1.18", + "hls.js": "^1.6.15", + "prop-types": "^15.8.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwindcss": "^4.1.18", + "video.js": "^8.23.4" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..cfe46d5 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,413 @@ +import { useEffect, useMemo, useState } from 'react' +import './App.css' +import Button from './components/ui/Button' +import Table, { type Column } from './components/ui/Table' +import CookieModal from './components/ui/CookieModal' +import Card from './components/ui/Card' +import Tabs, { type TabItem } from './components/ui/Tabs' +import ModelPreview from './components/ui/ModelPreview' +import RecorderSettings from './components/ui/RecorderSettings' +import FinishedDownloads from './components/ui/FinishedDownloads' +import Player from './components/ui/Player' +import type { RecordJob, ParsedModel } from './types' +import RunningDownloads from './components/ui/RunningDownloads' +import ModelsTab from './components/ui/ModelsTab' + +const COOKIE_STORAGE_KEY = 'record_cookies' + +async function apiJSON(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(text || `HTTP ${res.status}`) + } + return res.json() as Promise +} + +const baseName = (p: string) => { + const n = (p || '').replaceAll('\\', '/').trim() + const parts = n.split('/') + return parts[parts.length - 1] || '' +} + +const modelNameFromOutput = (output?: string) => { + const file = baseName(output || '') + if (!file) return '—' + const stem = file.replace(/\.[^.]+$/, '') + + // _MM_DD_YYYY__HH-MM-SS + const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/) + if (m?.[1]) return m[1] + + // fallback: alles bis zum letzten '_' nehmen + const i = stem.lastIndexOf('_') + return i > 0 ? stem.slice(0, i) : stem +} + + +const formatDuration = (ms: number): string => { + if (!Number.isFinite(ms) || ms <= 0) return '—' + const total = Math.floor(ms / 1000) + const h = Math.floor(total / 3600) + const m = Math.floor((total % 3600) / 60) + const s = total % 60 + if (h > 0) return `${h}h ${m}m` + if (m > 0) return `${m}m ${s}s` + return `${s}s` +} + +const runtimeOf = (j: RecordJob) => { + const start = Date.parse(String(j.startedAt || '')) + if (!Number.isFinite(start)) return '—' + const end = j.endedAt ? Date.parse(String(j.endedAt)) : Date.now() // running -> jetzt + if (!Number.isFinite(end)) return '—' + return formatDuration(end - start) +} + + +export default function App() { + const [sourceUrl, setSourceUrl] = useState('') + const [parsed, setParsed] = useState(null) + const [parseError, setParseError] = useState(null) + const [jobs, setJobs] = useState([]) + const [doneJobs, setDoneJobs] = useState([]) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const [cookieModalOpen, setCookieModalOpen] = useState(false) + const [cookies, setCookies] = useState>({}) + const [cookiesLoaded, setCookiesLoaded] = useState(false) + const [selectedTab, setSelectedTab] = useState('running') + const [playerJob, setPlayerJob] = useState(null) + const [playerExpanded, setPlayerExpanded] = useState(false) + + const initialCookies = useMemo( + () => Object.entries(cookies).map(([name, value]) => ({ name, value })), + [cookies] + ) + + const openPlayer = (job: RecordJob) => { + setPlayerJob(job) + setPlayerExpanded(false) // startet als Mini + } + + const runningJobs = jobs.filter((j) => j.status === 'running') + + const tabs: TabItem[] = [ + { id: 'running', label: 'Laufende Downloads', count: runningJobs.length }, + { id: 'finished', label: 'Abgeschlossene Downloads', count: doneJobs.length }, + { id: 'models', label: 'Models' }, + { id: 'settings', label: 'Einstellungen' }, + ] + + const canStart = useMemo(() => sourceUrl.trim().length > 0 && !busy, [sourceUrl, busy]) + + useEffect(() => { + const raw = localStorage.getItem(COOKIE_STORAGE_KEY) + if (raw) { + try { + const obj = JSON.parse(raw) as Record + const normalized = Object.fromEntries( + Object.entries(obj).map(([k, v]) => [k.trim().toLowerCase(), String(v ?? '').trim()]) + ) + setCookies(normalized) + } catch {} + } + setCookiesLoaded(true) + }, []) + + useEffect(() => { + if (!cookiesLoaded) return + localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies)) + }, [cookies, cookiesLoaded]) + + useEffect(() => { + if (sourceUrl.trim() === '') { + setParsed(null) + setParseError(null) + return + } + + const t = setTimeout(async () => { + try { + const p = await apiJSON('/api/models/parse', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input: sourceUrl.trim() }), + }) + setParsed(p) + setParseError(null) + } catch (e: any) { + setParsed(null) + setParseError(e?.message ?? String(e)) + } + }, 300) + + return () => clearTimeout(t) + }, [sourceUrl]) + + useEffect(() => { + const interval = setInterval(() => { + setJobs((prev) => { + prev.forEach((job) => { + if (job.status !== 'running') return + apiJSON(`/api/record/status?id=${encodeURIComponent(job.id)}`) + .then((updated) => { + setJobs((curr) => + curr.map((j) => (j.id === updated.id ? updated : j)) + ) + }) + .catch(() => {}) + }) + return prev + }) + }, 1000) + + return () => clearInterval(interval) + }, []) + + useEffect(() => { + apiJSON('/api/record/list') + .then((list) => setJobs(list)) + .catch(() => { + // backend evtl. noch nicht da -> ignorieren + }) + }, []) + + useEffect(() => { + const loadDone = async () => { + try { + const list = await apiJSON('/api/record/done') + setDoneJobs(Array.isArray(list) ? list : []) + } catch { + setDoneJobs([]) + } + } + + loadDone() + const t = setInterval(loadDone, 5000) + return () => clearInterval(t) + }, []) + + function isChaturbate(url: string): boolean { + try { + return new URL(url).hostname.includes('chaturbate.com') + } catch { + return false + } + } + + function getCookie( + cookies: Record, + names: string[] + ): string | undefined { + const lower = Object.fromEntries( + Object.entries(cookies).map(([k, v]) => [k.trim().toLowerCase(), v]) + ) + for (const n of names) { + const v = lower[n.toLowerCase()] + if (v) return v + } + return undefined + } + + function hasRequiredChaturbateCookies(cookies: Record): boolean { + const cf = getCookie(cookies, ['cf_clearance']) + const sess = getCookie(cookies, ['sessionid', 'session_id', 'sessionid', 'sessionId']) + return Boolean(cf && sess) + } + + async function onStart() { + setError(null) + + const url = sourceUrl.trim() + + // ❌ Chaturbate ohne Cookies blockieren + if (isChaturbate(url) && !hasRequiredChaturbateCookies(cookies)) { + setError( + 'Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.' + ) + return + } + + setBusy(true) + + try { + const cookieString = Object.entries(cookies) + .map(([k, v]) => `${k}=${v}`) + .join('; ') + + const created = await apiJSON('/api/record', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url, + cookie: cookieString, + }), + }) + + setJobs((prev) => [created, ...prev]) + } catch (e: any) { + setError(e?.message ?? String(e)) + } finally { + setBusy(false) + } + } + + async function stopJob(id: string) { + try { + await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { + method: 'POST', + }) + } catch {} + } + + const columns: Column[] = [ + { + key: 'preview', + header: 'Vorschau', + cell: (j) => + j.status === 'running' + ? + : + }, + { + key: 'model', + header: 'Modelname', + cell: (j) => ( + + {modelNameFromOutput(j.output)} + + ), + }, + { + key: 'sourceUrl', + header: 'Source', + cell: (j) => ( + + {j.sourceUrl} + + ), + }, + { + key: 'output', + header: 'Datei', + cell: (j) => baseName(j.output || ''), + }, + { key: 'status', header: 'Status' }, + { + key: 'runtime', + header: 'Dauer', + cell: (j) => runtimeOf(j), + }, + { + key: 'actions', + header: 'Aktion', + srOnlyHeader: true, + align: 'right', + cell: (j) => + j.status === 'running' ? ( + + ) : ( + + ), + }, + ] + + return ( +
+ +

+ Recorder +

+
+ + +
+
+ } + > + + setSourceUrl(e.target.value)} + placeholder="https://…" + className="mt-1 block w-full rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white" + /> + + {isChaturbate(sourceUrl) && !hasRequiredChaturbateCookies(cookies) && ( +
+ ⚠️ Für Chaturbate werden die Cookies cf_clearance und{' '} + sessionId benötigt. +
+ )} + + + + + {selectedTab === 'running' && ( + + )} + + {selectedTab === 'finished' && ( + + )} + + + {selectedTab === 'models' && } + + {selectedTab === 'settings' && } + + setCookieModalOpen(false)} + initialCookies={initialCookies} + onApply={(list) => { + const normalized = Object.fromEntries( + list + .map((c) => [c.name.trim().toLowerCase(), c.value.trim()] as const) + .filter(([k, v]) => k.length > 0 && v.length > 0) + ) + setCookies(normalized) + }} + /> + + {playerJob && ( + setPlayerExpanded((v) => !v)} + onClose={() => setPlayerJob(null)} + /> + )} +
+ ) +} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..a873814 --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,124 @@ +import * as React from 'react' + +type Variant = 'primary' | 'secondary' | 'soft' +type Size = 'xs' | 'sm' | 'md' | 'lg' + +export type ButtonProps = Omit< + React.ButtonHTMLAttributes, + 'children' +> & { + children: React.ReactNode + variant?: Variant + size?: Size + rounded?: 'sm' | 'md' | 'full' + leadingIcon?: React.ReactNode + trailingIcon?: React.ReactNode + isLoading?: boolean + className?: string +} + +function cn(...parts: Array) { + return parts.filter(Boolean).join(' ') +} + +const base = + 'inline-flex items-center justify-center font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed' + +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', +} + +// Varianten basieren auf deinen Klassen :contentReference[oaicite:1]{index=1} +const variantMap: Record = { + primary: + 'bg-indigo-600 text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-indigo-600 ' + + 'dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500', + secondary: + 'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' + + 'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20', + soft: + 'bg-indigo-50 text-indigo-600 shadow-xs hover:bg-indigo-100 ' + + 'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:hover:bg-indigo-500/30', +} + +function Spinner() { + return ( + + ) +} + +export default function Button({ + children, + variant = 'primary', + size = 'md', + rounded = 'md', + leadingIcon, + trailingIcon, + isLoading = false, + disabled, + className, + type = 'button', + ...props +}: ButtonProps) { + const iconGap = leadingIcon || trailingIcon || isLoading ? 'gap-x-1.5' : '' + + return ( + + ) +} diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..a3e56ed --- /dev/null +++ b/frontend/src/components/ui/Card.tsx @@ -0,0 +1,75 @@ +// frontend\src\components\ui\Card.tsx + +'use client' + +import clsx from 'clsx' +import type { PropsWithChildren, ReactNode } from 'react' + +type CardProps = { + header?: ReactNode + footer?: ReactNode + grayBody?: boolean + grayFooter?: boolean + edgeToEdgeMobile?: boolean + well?: boolean + noBodyPadding?: boolean + className?: string + bodyClassName?: string +} + +export default function Card({ + header, + footer, + grayBody = false, + grayFooter = false, + edgeToEdgeMobile = false, + well = false, + noBodyPadding = false, + className, + bodyClassName, + children, +}: PropsWithChildren) { + const isWell = well + + return ( +
+ {header && ( +
+ {header} +
+ )} + +
+ {children} +
+ + {footer && ( +
+ {footer} +
+ )} +
+ ) +} diff --git a/frontend/src/components/ui/ContextMenu.tsx b/frontend/src/components/ui/ContextMenu.tsx new file mode 100644 index 0000000..7ece491 --- /dev/null +++ b/frontend/src/components/ui/ContextMenu.tsx @@ -0,0 +1,117 @@ +// frontend\src\components\ui\ContextMenu.tsx + +'use client' + +import * as React from 'react' +import { createPortal } from 'react-dom' +import clsx from 'clsx' + +export type ContextMenuItem = { + label: React.ReactNode + onClick: () => void + disabled?: boolean + danger?: boolean +} + +type Props = { + open: boolean + x: number + y: number + items: ContextMenuItem[] + onClose: () => void + className?: string +} + +export default function ContextMenu({ open, x, y, items, onClose, className }: Props) { + const menuRef = React.useRef(null) + const [pos, setPos] = React.useState<{ left: number; top: number }>({ left: x, top: y }) + + // Position clamped in viewport + React.useLayoutEffect(() => { + if (!open) return + const el = menuRef.current + if (!el) return + + const margin = 8 + const { innerWidth: vw, innerHeight: vh } = window + const r = el.getBoundingClientRect() + + const left = Math.min(Math.max(x, margin), vw - r.width - margin) + const top = Math.min(Math.max(y, margin), vh - r.height - margin) + + setPos({ left, top }) + }, [open, x, y, items.length]) + + // close on ESC / scroll / resize + React.useEffect(() => { + if (!open) return + + const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose() + const onAny = () => onClose() + + window.addEventListener('keydown', onKeyDown) + window.addEventListener('scroll', onAny, true) + window.addEventListener('resize', onAny) + + return () => { + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('scroll', onAny, true) + window.removeEventListener('resize', onAny) + } + }, [open, onClose]) + + if (!open) return null + + return createPortal( + <> + {/* click-away overlay */} +
{ + e.preventDefault() + onClose() + }} + /> + +
e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} + > +
+ {items.map((it, idx) => ( + + ))} +
+
+ , + document.body + ) +} diff --git a/frontend/src/components/ui/CookieModal.tsx b/frontend/src/components/ui/CookieModal.tsx new file mode 100644 index 0000000..3ed71ad --- /dev/null +++ b/frontend/src/components/ui/CookieModal.tsx @@ -0,0 +1,130 @@ +'use client' + +import { Dialog } from '@headlessui/react' +import { useEffect, useState, useRef } from 'react' +import Button from './Button' + +export type CookieEntry = { name: string; value: string } + +type CookieModalProps = { + open: boolean + onClose: () => void + onApply: (cookies: CookieEntry[]) => void + initialCookies: CookieEntry[] +} + +export default function CookieModal({ + open, + onClose, + onApply, + initialCookies, +}: CookieModalProps) { + const [name, setName] = useState('') + const [value, setValue] = useState('') + const [cookies, setCookies] = useState([]) + const wasOpen = useRef(false) + + // ✅ Beim Öffnen: Inputs resetten UND Cookies aus Props übernehmen + useEffect(() => { + if (open && !wasOpen.current) { + setName('') + setValue('') + setCookies(initialCookies ?? []) + } + wasOpen.current = open + }, [open, initialCookies]) + + function addCookie() { + const n = name.trim() + const v = value.trim() + if (!n || !v) return + + setCookies((prev) => { + const filtered = prev.filter((c) => c.name !== n) + return [...filtered, { name: n, value: v }] + }) + + setName('') + setValue('') + } + + function removeCookie(n: string) { + setCookies((prev) => prev.filter((c) => c.name !== n)) + } + + function applyAndClose() { + onApply(cookies) + onClose() + } + + return ( + + + ) +} diff --git a/frontend/src/components/ui/DownloadContextMenu.tsx b/frontend/src/components/ui/DownloadContextMenu.tsx new file mode 100644 index 0000000..0ec81f3 --- /dev/null +++ b/frontend/src/components/ui/DownloadContextMenu.tsx @@ -0,0 +1,75 @@ +import type { RecordJob } from '../../types' +import type { ContextMenuItem } from './ContextMenu' + +export type DownloadMenuState = { + watching?: boolean + liked?: boolean | null // true = like, false = dislike, null/undefined = neutral + favorite?: boolean + hot?: boolean + keep?: boolean +} + +export type DownloadMenuActions = { + onPlay: (job: RecordJob) => void + + onToggleWatch: (job: RecordJob) => void + onSetLike: (job: RecordJob, liked: boolean | null) => void + onToggleFavorite: (job: RecordJob) => void + onMoreFromModel: (modelName: string, job: RecordJob) => void + + onRevealInExplorer: (job: RecordJob) => void + onAddToDownloadList: (job: RecordJob) => void + onToggleHot: (job: RecordJob) => void + + onToggleKeep: (job: RecordJob) => void + onDelete: (job: RecordJob) => void +} + +type BuildArgs = { + job: RecordJob + modelName: string + state: DownloadMenuState + actions: DownloadMenuActions +} + +export function buildDownloadContextMenu({ job, modelName, state, actions }: BuildArgs): ContextMenuItem[] { + const watching = !!state.watching + const favorite = !!state.favorite + const hot = !!state.hot + const keep = !!state.keep + const liked = state.liked // true / false / null + + return [ + { label: 'Abspielen', onClick: () => actions.onPlay(job) }, + + // optionaler Separator-Style: wenn du willst, kann ContextMenu auch "separator" Items rendern + { label: watching ? 'Nicht beobachten' : 'Beobachten', onClick: () => actions.onToggleWatch(job) }, + + { + label: liked === true ? 'Gefällt mir entfernen' : 'Gefällt mir', + onClick: () => actions.onSetLike(job, liked === true ? null : true), + }, + { + label: liked === false ? 'Gefällt mir nicht entfernen' : 'Gefällt mir nicht', + onClick: () => actions.onSetLike(job, liked === false ? null : false), + }, + + { label: favorite ? 'Von Favoriten entfernen' : 'Als Favorit markieren', onClick: () => actions.onToggleFavorite(job) }, + + { + label: `Mehr von ${modelName} anzeigen`, + onClick: () => actions.onMoreFromModel(modelName, job), + disabled: !modelName || modelName === '—', + }, + + { label: 'Dateipfad im Explorer öffnen', onClick: () => actions.onRevealInExplorer(job), disabled: !job.output }, + + { label: 'Zur Downloadliste hinzufügen', onClick: () => actions.onAddToDownloadList(job) }, + + { label: hot ? 'Nicht mehr als HOT markieren' : 'Als HOT markieren', onClick: () => actions.onToggleHot(job) }, + + { label: keep ? 'Nicht behalten' : 'Behalten', onClick: () => actions.onToggleKeep(job) }, + + { label: 'Löschen', onClick: () => actions.onDelete(job), danger: true, disabled: keep }, + ] +} diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx new file mode 100644 index 0000000..455d252 --- /dev/null +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -0,0 +1,300 @@ +// FinishedDownloads.tsx +'use client' + +import * as React from 'react' +import { useMemo } from 'react' +import Table, { type Column } from './Table' +import Card from './Card' +import type { RecordJob } from '../../types' +import FinishedVideoPreview from './FinishedVideoPreview' +import ContextMenu, { type ContextMenuItem } from './ContextMenu' +import { buildDownloadContextMenu } from './DownloadContextMenu' + +type Props = { + jobs: RecordJob[] + doneJobs: RecordJob[] + onOpenPlayer: (job: RecordJob) => void +} + +const norm = (p: string) => (p || '').replaceAll('\\', '/').trim() +const baseName = (p: string) => { + const n = norm(p) + const parts = n.split('/') + return parts[parts.length - 1] || '' +} +const keyFor = (j: RecordJob) => baseName(j.output || '') || j.id + +function formatDuration(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return '—' + const totalSec = Math.floor(ms / 1000) + const h = Math.floor(totalSec / 3600) + const m = Math.floor((totalSec % 3600) / 60) + const s = totalSec % 60 + if (h > 0) return `${h}h ${m}m` + if (m > 0) return `${m}m ${s}s` + return `${s}s` +} + +function runtimeOf(job: RecordJob): string { + const start = Date.parse(String(job.startedAt || '')) + const end = Date.parse(String(job.endedAt || '')) + if (!Number.isFinite(start) || !Number.isFinite(end)) return '—' + return formatDuration(end - start) +} + +const httpCodeFromError = (err?: string) => { + const m = (err ?? '').match(/\bHTTP\s+(\d{3})\b/i) + return m ? `HTTP ${m[1]}` : null +} + +const modelNameFromOutput = (output?: string) => { + const file = baseName(output || '') + if (!file) return '—' + const stem = file.replace(/\.[^.]+$/, '') + const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/) + if (m?.[1]) return m[1] + const i = stem.lastIndexOf('_') + return i > 0 ? stem.slice(0, i) : stem +} + +export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Props) { + const [ctx, setCtx] = React.useState<{ x: number; y: number; job: RecordJob } | null>(null) + + const openCtx = (job: RecordJob, e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setCtx({ x: e.clientX, y: e.clientY, job }) + } + + const openCtxAt = (job: RecordJob, x: number, y: number) => { + setCtx({ x, y, job }) + } + + const items = React.useMemo(() => { + if (!ctx) return [] + const j = ctx.job + const model = modelNameFromOutput(j.output) + + return buildDownloadContextMenu({ + job: j, + modelName: model, + state: { + watching: false, + liked: null, + favorite: false, + hot: false, + keep: false, + }, + actions: { + onPlay: onOpenPlayer, + + onToggleWatch: (job) => console.log('toggle watch', job.id), + onSetLike: (job, liked) => console.log('set like', job.id, liked), + onToggleFavorite: (job) => console.log('toggle favorite', job.id), + onMoreFromModel: (modelName) => console.log('more from', modelName), + + onRevealInExplorer: (job) => console.log('reveal in explorer', job.output), + onAddToDownloadList: (job) => console.log('add to download list', job.id), + onToggleHot: (job) => console.log('toggle hot', job.id), + + onToggleKeep: (job) => console.log('toggle keep', job.id), + onDelete: (job) => console.log('delete', job.id), + }, + }) + }, [ctx, onOpenPlayer]) + + const rows = useMemo(() => { + const map = new Map() + + for (const j of doneJobs) map.set(keyFor(j), j) + + for (const j of jobs) { + const k = keyFor(j) + if (map.has(k)) map.set(k, { ...map.get(k)!, ...j }) + } + + const list = Array.from(map.values()).filter( + (j) => j.status === 'finished' || j.status === 'failed' || j.status === 'stopped' + ) + + list.sort((a, b) => norm(b.endedAt || '').localeCompare(norm(a.endedAt || ''))) + return list + }, [jobs, doneJobs]) + + const columns: Column[] = [ + { + key: 'preview', + header: 'Vorschau', + cell: (j) => , + }, + { + key: 'model', + header: 'Modelname', + cell: (j) => { + const name = modelNameFromOutput(j.output) + return ( + + {name} + + ) + }, + }, + { + key: 'output', + header: 'Datei', + cell: (j) => baseName(j.output || ''), + }, + { + key: 'status', + header: 'Status', + cell: (j) => { + if (j.status !== 'failed') return j.status + const code = httpCodeFromError(j.error) + const label = code ? `failed (${code})` : 'failed' + return ( + + {label} + + ) + }, + }, + { + key: 'runtime', + header: 'Dauer', + cell: (j) => runtimeOf(j), + }, + { + key: 'actions', + header: 'Aktion', + align: 'right', + srOnlyHeader: true, + cell: () => , + }, + ] + + if (rows.length === 0) { + return ( + +
+ Keine abgeschlossenen Downloads im Zielordner vorhanden. +
+
+ ) + } + + return ( + <> + {/* ✅ Mobile: Cards */} +
+ {rows.map((j) => { + const model = modelNameFromOutput(j.output) + const file = baseName(j.output || '') + const dur = runtimeOf(j) + + const statusNode = + j.status === 'failed' ? ( + + failed{httpCodeFromError(j.error) ? ` (${httpCodeFromError(j.error)})` : ''} + + ) : ( + {j.status} + ) + + return ( +
onOpenPlayer(j)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) + }} + onContextMenu={(e) => openCtx(j, e)} + > + +
+
+ {model} +
+
+ {file || '—'} +
+
+ + {/* ✅ Menü-Button für Touch/Small Devices */} + +
+ } + > +
+
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onContextMenu={(e) => { + e.preventDefault() + e.stopPropagation() + openCtx(j, e) + }} + > + +
+ +
+
+ Status: {statusNode} + + Dauer: {dur} +
+ + {j.output ? ( +
+ {j.output} +
+ ) : null} +
+
+ +
+ ) + })} +
+ + {/* ✅ Desktop/Tablet: Tabelle */} +
+ keyFor(j)} + striped + fullWidth + onRowClick={onOpenPlayer} + onRowContextMenu={(job, e) => openCtx(job, e)} + /> + + + setCtx(null)} + /> + + ) +} diff --git a/frontend/src/components/ui/FinishedVideoPreview.tsx b/frontend/src/components/ui/FinishedVideoPreview.tsx new file mode 100644 index 0000000..a84364c --- /dev/null +++ b/frontend/src/components/ui/FinishedVideoPreview.tsx @@ -0,0 +1,55 @@ +// frontend/src/components/ui/FinishedVideoPreview.tsx +'use client' + +import type { RecordJob } from '../../types' +import HoverPopover from './HoverPopover' + +type Props = { + job: RecordJob + getFileName: (path: string) => string +} + +export default function FinishedVideoPreview({ job, getFileName }: Props) { + const file = getFileName(job.output || '') + const src = file ? `/api/record/video?file=${encodeURIComponent(file)}` : '' + + if (!src) { + return
+ } + + return ( + +
+
+
+ } + > + {/* Mini in Tabelle */} +
m.id} + striped + fullWidth + onRowClick={(m) => { + if (m.input) window.open(m.input, '_blank', 'noreferrer') + }} + /> + + + ) +} diff --git a/frontend/src/components/ui/Player.tsx b/frontend/src/components/ui/Player.tsx new file mode 100644 index 0000000..8582767 --- /dev/null +++ b/frontend/src/components/ui/Player.tsx @@ -0,0 +1,176 @@ +// Player.tsx +'use client' + +import * as React from 'react' +import type { RecordJob } from '../../types' +import Card from './Card' +import Button from './Button' + +import videojs from 'video.js' +import type VideoJsPlayer from 'video.js/dist/types/player' +import 'video.js/dist/video-js.css' +import { ArrowsPointingOutIcon, ArrowsPointingInIcon } from '@heroicons/react/24/outline' + +const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || '' + +export type PlayerProps = { + job: RecordJob + expanded: boolean + onClose: () => void + onToggleExpand: () => void + className?: string +} + +export default function Player({ job, expanded, onClose, onToggleExpand, className }: PlayerProps) { + const title = React.useMemo(() => baseName(job.output?.trim() || '') || job.id, [job.output, job.id]) + + React.useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose() + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [onClose]) + + const src = React.useMemo(() => { + const file = baseName(job.output?.trim() || '') + if (file && job.status !== 'running') return `/api/record/video?file=${encodeURIComponent(file)}` + return `/api/record/video?id=${encodeURIComponent(job.id)}` + }, [job.id, job.output, job.status]) + + const containerRef = React.useRef(null) + const playerRef = React.useRef(null) + const videoNodeRef = React.useRef(null) + + // ✅ 1x initialisieren (Element wird sicher in den DOM gehängt) + React.useLayoutEffect(() => { + if (!containerRef.current) return + if (playerRef.current) return + + const videoEl = document.createElement('video') + videoEl.className = 'video-js vjs-big-play-centered w-full h-full' + videoEl.setAttribute('playsinline', 'true') + + containerRef.current.appendChild(videoEl) + videoNodeRef.current = videoEl + + const p = (playerRef.current = videojs(videoEl, { + autoplay: true, + controls: true, + preload: 'auto', + playsinline: true, + responsive: true, + fluid: false, // ✅ besser für flex-layouts + fill: true, // ✅ füllt Container sauber + controlBar: { + children: [ + 'playToggle', + 'rewindToggle', + 'forwardToggle', + 'progressControl', + 'currentTimeDisplay', + 'timeDivider', + 'durationDisplay', + 'volumePanel', + 'playbackRateMenuButton', + 'fullscreenToggle', + ], + }, + playbackRates: [0.5, 1, 1.25, 1.5, 2], + })) + + return () => { + if (playerRef.current) { + playerRef.current.dispose() + playerRef.current = null + } + if (videoNodeRef.current) { + videoNodeRef.current.remove() + videoNodeRef.current = null + } + } + }, []) + + // ✅ Source wechseln ohne Remount + React.useEffect(() => { + const p = playerRef.current + if (!p || (p as any).isDisposed?.()) return + + const wasPlaying = !p.paused() + const t = p.currentTime() || 0 + + p.src({ src, type: 'video/mp4' }) + p.one('loadedmetadata', () => { + if ((p as any).isDisposed?.()) return + if (t > 0) p.currentTime(t) + if (wasPlaying) { + const ret = p.play?.() + if (ret && typeof (ret as any).catch === 'function') { + ;(ret as Promise).catch(() => {}) + } + } + }) + }, [src]) + + // ✅ bei Größenwechsel kurz resize triggern (hilft manchmal) + React.useEffect(() => { + const p = playerRef.current + if (!p || (p as any).isDisposed?.()) return + queueMicrotask(() => p.trigger('resize')) + }, [expanded]) + + return ( + +
+
{title}
+
+
+ + +
+ + } + footer={ +
+
+ Status: {job.status} + {job.output ? • {job.output} : null} +
+ {job.output ? ( + + ) : null} +
+ } + grayBody={false} + > +
+
+
+
+
+ + ) +} diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx new file mode 100644 index 0000000..21a6a44 --- /dev/null +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -0,0 +1,180 @@ +'use client' + +import { useEffect, useState } from 'react' +import Button from './Button' +import Card from './Card' + +type RecorderSettings = { + recordDir: string + doneDir: string +} + +const DEFAULTS: RecorderSettings = { + // ✅ relativ zur .exe (Backend löst das auf) + recordDir: 'records', + doneDir: 'records/done', +} + +export default function RecorderSettings() { + const [value, setValue] = useState(DEFAULTS) + const [saving, setSaving] = useState(false) + const [browsing, setBrowsing] = useState<'record' | 'done' | null>(null) + const [msg, setMsg] = useState(null) + const [err, setErr] = useState(null) + + useEffect(() => { + let alive = true + fetch('/api/settings', { cache: 'no-store' }) + .then(async (r) => { + if (!r.ok) throw new Error(await r.text()) + return r.json() + }) + .then((data: RecorderSettings) => { + if (!alive) return + setValue({ + recordDir: (data.recordDir || DEFAULTS.recordDir).toString(), + doneDir: (data.doneDir || DEFAULTS.doneDir).toString(), + }) + }) + .catch(() => { + // backend evtl. noch alt -> defaults lassen + }) + return () => { + alive = false + } + }, []) + + async function browse(target: 'record' | 'done') { + setErr(null) + setMsg(null) + setBrowsing(target) + + try { + window.focus() + const res = await fetch(`/api/settings/browse?target=${target}`, { cache: 'no-store' }) + if (res.status === 204) return // user cancelled + if (!res.ok) { + const t = await res.text().catch(() => '') + throw new Error(t || `HTTP ${res.status}`) + } + + const data = (await res.json()) as { path?: string } + const p = (data.path ?? '').trim() + if (!p) return + + setValue((v) => + target === 'record' ? { ...v, recordDir: p } : { ...v, doneDir: p } + ) + } catch (e: any) { + setErr(e?.message ?? String(e)) + } finally { + setBrowsing(null) + } + } + + async function save() { + setErr(null) + setMsg(null) + + const recordDir = value.recordDir.trim() + const doneDir = value.doneDir.trim() + + if (!recordDir || !doneDir) { + setErr('Bitte beide Pfade angeben.') + return + } + + setSaving(true) + try { + const res = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recordDir, doneDir }), + }) + if (!res.ok) { + const t = await res.text().catch(() => '') + throw new Error(t || `HTTP ${res.status}`) + } + setMsg('✅ Gespeichert.') + } catch (e: any) { + setErr(e?.message ?? String(e)) + } finally { + setSaving(false) + } + } + + return ( + +
+
+ Einstellungen +
+
+ +
+ } + grayBody + > +
+ {err && ( +
+ {err} +
+ )} + {msg && ( +
+ {msg} +
+ )} + +
+ +
+ setValue((v) => ({ ...v, recordDir: e.target.value }))} + placeholder="records (oder absolut: C:\records / /mnt/data/records)" + className="min-w-0 flex-1 rounded-md px-3 py-2 text-sm bg-white text-gray-900 + dark:bg-white/10 dark:text-white" + /> + +
+
+ +
+ +
+ setValue((v) => ({ ...v, doneDir: e.target.value }))} + placeholder="records/done" + className="min-w-0 flex-1 rounded-md px-3 py-2 text-sm bg-white text-gray-900 + dark:bg-white/10 dark:text-white" + /> + +
+
+
+
+ ) +} diff --git a/frontend/src/components/ui/RunningDownloads.tsx b/frontend/src/components/ui/RunningDownloads.tsx new file mode 100644 index 0000000..1ed6e7f --- /dev/null +++ b/frontend/src/components/ui/RunningDownloads.tsx @@ -0,0 +1,218 @@ +// RunningDownloads.tsx +'use client' + +import { useMemo } from 'react' +import Table, { type Column } from './Table' +import Card from './Card' +import Button from './Button' +import ModelPreview from './ModelPreview' +import type { RecordJob } from '../../types' + +type Props = { + jobs: RecordJob[] + onOpenPlayer: (job: RecordJob) => void + onStopJob: (id: string) => void +} + +const baseName = (p: string) => + (p || '').replaceAll('\\', '/').trim().split('/').pop() || '' + +const modelNameFromOutput = (output?: string) => { + const file = baseName(output || '') + if (!file) return '—' + const stem = file.replace(/\.[^.]+$/, '') + + // _MM_DD_YYYY__HH-MM-SS + const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/) + if (m?.[1]) return m[1] + + const i = stem.lastIndexOf('_') + return i > 0 ? stem.slice(0, i) : stem +} + +const formatDuration = (ms: number): string => { + if (!Number.isFinite(ms) || ms <= 0) return '—' + const total = Math.floor(ms / 1000) + const h = Math.floor(total / 3600) + const m = Math.floor((total % 3600) / 60) + const s = total % 60 + if (h > 0) return `${h}h ${m}m` + if (m > 0) return `${m}m ${s}s` + return `${s}s` +} + +const runtimeOf = (j: RecordJob) => { + const start = Date.parse(String(j.startedAt || '')) + if (!Number.isFinite(start)) return '—' + const end = j.endedAt ? Date.parse(String(j.endedAt)) : Date.now() + if (!Number.isFinite(end)) return '—' + return formatDuration(end - start) +} + +export default function RunningDownloads({ jobs, onOpenPlayer, onStopJob }: Props) { + const columns = useMemo[]>(() => { + return [ + { + key: 'preview', + header: 'Vorschau', + cell: (j) => , + }, + { + key: 'model', + header: 'Modelname', + cell: (j) => { + const name = modelNameFromOutput(j.output) + return ( + + {name} + + ) + }, + }, + { + key: 'sourceUrl', + header: 'Source', + cell: (j) => ( + e.stopPropagation()} + > + {j.sourceUrl} + + ), + }, + { + key: 'output', + header: 'Datei', + cell: (j) => baseName(j.output || ''), + }, + { key: 'status', header: 'Status' }, + { + key: 'runtime', + header: 'Dauer', + cell: (j) => runtimeOf(j), + }, + { + key: 'actions', + header: 'Aktion', + srOnlyHeader: true, + align: 'right', + cell: (j) => ( + + ), + }, + ] + }, [onStopJob]) + + if (jobs.length === 0) { + return ( + +
+ Keine laufenden Downloads. +
+
+ ) + } + + return ( + <> + {/* ✅ Mobile: Cards */} +
+ {jobs.map((j) => { + const model = modelNameFromOutput(j.output) + const file = baseName(j.output || '') + const dur = runtimeOf(j) + + return ( +
onOpenPlayer(j)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) + }} + > + +
+
+ {model} +
+
+ {file || '—'} +
+
+ + +
+ } + > +
+
e.stopPropagation()}> + +
+ +
+
+ Status: {j.status} + + Dauer: {dur} +
+ + {j.sourceUrl ? ( + e.stopPropagation()} + > + {j.sourceUrl} + + ) : null} +
+
+ +
+ ) + })} + + + {/* ✅ Desktop/Tablet: Tabelle */} +
+
r.id} + striped + fullWidth + onRowClick={onOpenPlayer} + /> + + + ) +} diff --git a/frontend/src/components/ui/Table.tsx b/frontend/src/components/ui/Table.tsx new file mode 100644 index 0000000..d04402f --- /dev/null +++ b/frontend/src/components/ui/Table.tsx @@ -0,0 +1,228 @@ +import * as React from 'react' + +type Align = 'left' | 'center' | 'right' + +export type Column = { + key: string + header: React.ReactNode + widthClassName?: string + align?: Align + className?: string + headerClassName?: string + /** Wenn gesetzt: so wird die Zelle gerendert */ + cell?: (row: T, rowIndex: number) => React.ReactNode + /** Standard: row[col.key] */ + accessor?: (row: T) => React.ReactNode + /** Optional: sr-only Header (z.B. für Action-Spalte) */ + srOnlyHeader?: boolean +} + +export type TableProps = { + columns: Array> + rows: T[] + getRowKey?: (row: T, index: number) => string + /** Optionaler Titelbereich über der Tabelle */ + title?: React.ReactNode + description?: React.ReactNode + actions?: React.ReactNode + + /** Styling / Layout */ + fullWidth?: boolean + card?: boolean + striped?: boolean + stickyHeader?: boolean + compact?: boolean + + /** States */ + isLoading?: boolean + emptyLabel?: React.ReactNode + + className?: string + + onRowClick?: (row: T) => void + + onRowContextMenu?: (row: T, e: React.MouseEvent) => void + +} + +function cn(...parts: Array) { + return parts.filter(Boolean).join(' ') +} + +function alignTd(a?: Align) { + if (a === 'center') return 'text-center' + if (a === 'right') return 'text-right' + return 'text-left' +} + +export default function Table({ + columns, + rows, + getRowKey, + title, + description, + actions, + fullWidth = false, + card = true, + striped = false, + stickyHeader = false, + compact = false, + isLoading = false, + emptyLabel = 'Keine Daten vorhanden.', + className, + onRowClick, + onRowContextMenu +}: TableProps) { + const cellY = compact ? 'py-2' : 'py-4' + const headY = compact ? 'py-3' : 'py-3.5' + + return ( +
+ {(title || description || actions) && ( +
+
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ {actions && ( +
{actions}
+ )} +
+ )} + +
+
+
+
+
+
+ + + {columns.map((col) => ( + + ))} + + + + + {isLoading ? ( + + + + ) : rows.length === 0 ? ( + + + + ) : ( + rows.map((row, rowIndex) => { + const key = getRowKey + ? getRowKey(row, rowIndex) + : String(rowIndex) + + return ( + onRowClick?.(row)} + onContextMenu={ + onRowContextMenu + ? (e) => { + e.preventDefault() + onRowContextMenu(row, e) + } + : undefined + } + > + {columns.map((col) => { + const content = + col.cell?.(row, rowIndex) ?? + col.accessor?.(row) ?? + (row as any)?.[col.key] + + return ( + + ) + })} + + ) + }) + )} + +
+ {col.srOnlyHeader ? ( + {col.header} + ) : ( + col.header + )} +
+ Lädt… +
+ {emptyLabel} +
+ {content} +
+
+ + + + + + ) +} diff --git a/frontend/src/components/ui/Tabs.tsx b/frontend/src/components/ui/Tabs.tsx new file mode 100644 index 0000000..0516464 --- /dev/null +++ b/frontend/src/components/ui/Tabs.tsx @@ -0,0 +1,91 @@ +'use client' + +import { ChevronDownIcon } from '@heroicons/react/16/solid' +import clsx from 'clsx' + +export type TabItem = { + id: string + label: string + count?: number +} + +type TabsProps = { + tabs: TabItem[] + value: string + onChange: (id: string) => void + className?: string + ariaLabel?: string +} + +export default function Tabs({ + tabs, + value, + onChange, + className, + ariaLabel = 'Ansicht auswählen', +}: TabsProps) { + const current = tabs.find((t) => t.id === value) ?? tabs[0] + + return ( +
+ {/* Mobile: Dropdown */} +
+ +
+ + {/* Desktop: Horizontal Tabs */} +
+ +
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..94368a1 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,39 @@ +@import "tailwindcss"; + +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.plyr-mini .plyr__controls [data-plyr="rewind"], +.plyr-mini .plyr__controls [data-plyr="fast-forward"], +.plyr-mini .plyr__controls [data-plyr="volume"], +.plyr-mini .plyr__controls [data-plyr="settings"], +.plyr-mini .plyr__controls [data-plyr="pip"], +.plyr-mini .plyr__controls [data-plyr="airplay"], +.plyr-mini .plyr__time--duration { + display: none !important; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..899b31c --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,21 @@ +// types.ts + +export type RecordJob = { + id: string + sourceUrl: string + output: string + status: 'running' | 'finished' | 'failed' | 'stopped' + startedAt: string + endedAt?: string + exitCode?: number + error?: string + logTail?: string +} + +export type ParsedModel = { + input: string + isUrl: boolean + host?: string + path?: string + modelKey: string +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..a9b5a59 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": 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..8a67f62 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "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 */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..f73976e --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + build: { + outDir: path.resolve(__dirname, '../backend/web/dist'), + emptyOutDir: true, + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, +})