updated
This commit is contained in:
commit
99837f0ed3
16
backend/go.mod
Normal file
16
backend/go.mod
Normal file
@ -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
|
||||
)
|
||||
79
backend/go.sum
Normal file
79
backend/go.sum
Normal file
@ -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=
|
||||
1787
backend/main.go
Normal file
1787
backend/main.go
Normal file
File diff suppressed because it is too large
Load Diff
228
backend/models.go
Normal file
228
backend/models.go
Normal file
@ -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)
|
||||
}
|
||||
191
backend/models_api.go
Normal file
191
backend/models_api.go
Normal file
@ -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})
|
||||
})
|
||||
}
|
||||
238
backend/models_store.go
Normal file
238
backend/models_store.go
Normal file
@ -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()
|
||||
}
|
||||
BIN
backend/myapp.exe
Normal file
BIN
backend/myapp.exe
Normal file
Binary file not shown.
4
backend/recorder_settings.json
Normal file
4
backend/recorder_settings.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"recordDir": "C:\\Users\\Rother\\Desktop\\test",
|
||||
"doneDir": "C:\\Users\\Rother\\Desktop\\test\\done"
|
||||
}
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -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?
|
||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4236
frontend/package-lock.json
generated
Normal file
4236
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
0
frontend/src/App.css
Normal file
0
frontend/src/App.css
Normal file
413
frontend/src/App.tsx
Normal file
413
frontend/src/App.tsx
Normal file
@ -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<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
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<T>
|
||||
}
|
||||
|
||||
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(/\.[^.]+$/, '')
|
||||
|
||||
// <model>_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<ParsedModel | null>(null)
|
||||
const [parseError, setParseError] = useState<string | null>(null)
|
||||
const [jobs, setJobs] = useState<RecordJob[]>([])
|
||||
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [cookieModalOpen, setCookieModalOpen] = useState(false)
|
||||
const [cookies, setCookies] = useState<Record<string, string>>({})
|
||||
const [cookiesLoaded, setCookiesLoaded] = useState(false)
|
||||
const [selectedTab, setSelectedTab] = useState('running')
|
||||
const [playerJob, setPlayerJob] = useState<RecordJob | null>(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<string, string>
|
||||
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<ParsedModel>('/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<RecordJob>(`/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<RecordJob[]>('/api/record/list')
|
||||
.then((list) => setJobs(list))
|
||||
.catch(() => {
|
||||
// backend evtl. noch nicht da -> ignorieren
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const loadDone = async () => {
|
||||
try {
|
||||
const list = await apiJSON<RecordJob[]>('/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<string, string>,
|
||||
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<string, string>): 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<RecordJob>('/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<RecordJob>[] = [
|
||||
{
|
||||
key: 'preview',
|
||||
header: 'Vorschau',
|
||||
cell: (j) =>
|
||||
j.status === 'running'
|
||||
? <ModelPreview jobId={j.id} />
|
||||
: <img src={`/api/record/preview?id=${j.id}`} />
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
header: 'Modelname',
|
||||
cell: (j) => (
|
||||
<span className="truncate" title={modelNameFromOutput(j.output)}>
|
||||
{modelNameFromOutput(j.output)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sourceUrl',
|
||||
header: 'Source',
|
||||
cell: (j) => (
|
||||
<a
|
||||
href={j.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-indigo-600 dark:text-indigo-400 hover:underline"
|
||||
>
|
||||
{j.sourceUrl}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
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' ? (
|
||||
<Button size="md" variant="primary" onClick={() => stopJob(j.id)}>
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">—</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mx-auto py-4 max-w-7xl sm:px-6 lg:px-8 space-y-6">
|
||||
<Card
|
||||
header={
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Recorder
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setCookieModalOpen(true)}>
|
||||
Cookies
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onStart} disabled={!canStart}>
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-200">
|
||||
Source URL
|
||||
</label>
|
||||
<input
|
||||
value={sourceUrl}
|
||||
onChange={(e) => 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) && (
|
||||
<div className="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
⚠️ Für Chaturbate werden die Cookies <code>cf_clearance</code> und{' '}
|
||||
<code>sessionId</code> benötigt.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
value={selectedTab}
|
||||
onChange={setSelectedTab}
|
||||
ariaLabel="Tabs"
|
||||
|
||||
/>
|
||||
|
||||
{selectedTab === 'running' && (
|
||||
<RunningDownloads
|
||||
jobs={runningJobs}
|
||||
onOpenPlayer={openPlayer}
|
||||
onStopJob={stopJob}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedTab === 'finished' && (
|
||||
<FinishedDownloads
|
||||
jobs={jobs}
|
||||
doneJobs={doneJobs}
|
||||
onOpenPlayer={openPlayer}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{selectedTab === 'models' && <ModelsTab />}
|
||||
|
||||
{selectedTab === 'settings' && <RecorderSettings />}
|
||||
|
||||
<CookieModal
|
||||
open={cookieModalOpen}
|
||||
onClose={() => 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 && (
|
||||
<Player
|
||||
job={playerJob}
|
||||
expanded={playerExpanded}
|
||||
onToggleExpand={() => setPlayerExpanded((v) => !v)}
|
||||
onClose={() => setPlayerJob(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
frontend/src/components/ui/Button.tsx
Normal file
124
frontend/src/components/ui/Button.tsx
Normal file
@ -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<HTMLButtonElement>,
|
||||
'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<string | false | null | undefined>) {
|
||||
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<Size, string> = {
|
||||
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<Variant, string> = {
|
||||
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 (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="size-4 animate-spin"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M22 12a10 10 0 0 1-10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
opacity="0.9"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
base,
|
||||
roundedMap[rounded],
|
||||
sizeMap[size],
|
||||
variantMap[variant],
|
||||
iconGap,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="-ml-0.5">
|
||||
<Spinner />
|
||||
</span>
|
||||
) : (
|
||||
leadingIcon && <span className="-ml-0.5">{leadingIcon}</span>
|
||||
)}
|
||||
|
||||
<span>{children}</span>
|
||||
|
||||
{trailingIcon && !isLoading && (
|
||||
<span className="-mr-0.5">{trailingIcon}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
75
frontend/src/components/ui/Card.tsx
Normal file
75
frontend/src/components/ui/Card.tsx
Normal file
@ -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<CardProps>) {
|
||||
const isWell = well
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'overflow-hidden',
|
||||
edgeToEdgeMobile ? 'sm:rounded-lg' : 'rounded-lg',
|
||||
isWell
|
||||
? 'bg-gray-50 dark:bg-gray-800 shadow-none'
|
||||
: 'bg-white shadow-sm dark:bg-gray-800 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{header && (
|
||||
<div className="shrink-0 px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-white/10">
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'min-h-0', // ✅ wichtig bei flex layouts
|
||||
noBodyPadding ? 'p-0' : 'px-4 py-5 sm:p-6',
|
||||
grayBody && 'bg-gray-50 dark:bg-gray-800',
|
||||
bodyClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div
|
||||
className={clsx(
|
||||
'shrink-0 px-4 py-4 sm:px-6', // ✅ shrink-0
|
||||
grayFooter && 'bg-gray-50 dark:bg-gray-800/50',
|
||||
'border-t border-gray-200 dark:border-white/10'
|
||||
)}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
frontend/src/components/ui/ContextMenu.tsx
Normal file
117
frontend/src/components/ui/ContextMenu.tsx
Normal file
@ -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<HTMLDivElement>(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 */}
|
||||
<div
|
||||
className="fixed inset-0 z-50"
|
||||
onMouseDown={onClose}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={clsx(
|
||||
'fixed z-50 min-w-[220px] overflow-hidden rounded-lg',
|
||||
'bg-white dark:bg-gray-800',
|
||||
'ring-1 ring-black/10 dark:ring-white/10 shadow-lg',
|
||||
className
|
||||
)}
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
role="menu"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="py-1">
|
||||
{items.map((it, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
disabled={it.disabled}
|
||||
onClick={() => {
|
||||
if (it.disabled) return
|
||||
it.onClick()
|
||||
onClose()
|
||||
}}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-2 text-sm',
|
||||
'hover:bg-black/5 dark:hover:bg-white/10',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
it.danger ? 'text-red-700 dark:text-red-300' : 'text-gray-900 dark:text-gray-100'
|
||||
)}
|
||||
role="menuitem"
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
130
frontend/src/components/ui/CookieModal.tsx
Normal file
130
frontend/src/components/ui/CookieModal.tsx
Normal file
@ -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<CookieEntry[]>([])
|
||||
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 (
|
||||
<Dialog open={open} onClose={onClose} className="relative z-50">
|
||||
<div className="fixed inset-0 bg-black/40" aria-hidden="true" />
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<Dialog.Panel className="w-full max-w-lg rounded-lg bg-white dark:bg-gray-800 p-6 shadow-xl dark:outline dark:-outline-offset-1 dark:outline-white/10">
|
||||
<Dialog.Title className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Zusätzliche Cookies
|
||||
</Dialog.Title>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name (z. B. cf_clearance)"
|
||||
className="col-span-1 rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Wert"
|
||||
className="col-span-1 sm:col-span-2 rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<Button size="sm" variant="secondary" onClick={addCookie} disabled={!name.trim() || !value.trim()}>
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{cookies.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Noch keine Cookies hinzugefügt.</div>
|
||||
) : (
|
||||
<table className="min-w-full text-sm border divide-y dark:divide-white/10">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">Name</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Wert</th>
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y dark:divide-white/10">
|
||||
{cookies.map((c) => (
|
||||
<tr key={c.name}>
|
||||
<td className="px-3 py-2 font-mono">{c.name}</td>
|
||||
<td className="px-3 py-2 truncate max-w-[240px]">{c.value}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button
|
||||
onClick={() => removeCookie(c.name)}
|
||||
className="text-xs text-red-600 hover:underline dark:text-red-400"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
<Button variant="primary" onClick={applyAndClose}>Übernehmen</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
75
frontend/src/components/ui/DownloadContextMenu.tsx
Normal file
75
frontend/src/components/ui/DownloadContextMenu.tsx
Normal file
@ -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 },
|
||||
]
|
||||
}
|
||||
300
frontend/src/components/ui/FinishedDownloads.tsx
Normal file
300
frontend/src/components/ui/FinishedDownloads.tsx
Normal file
@ -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<ContextMenuItem[]>(() => {
|
||||
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<string, RecordJob>()
|
||||
|
||||
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<RecordJob>[] = [
|
||||
{
|
||||
key: 'preview',
|
||||
header: 'Vorschau',
|
||||
cell: (j) => <FinishedVideoPreview job={j} getFileName={baseName} />,
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
header: 'Modelname',
|
||||
cell: (j) => {
|
||||
const name = modelNameFromOutput(j.output)
|
||||
return (
|
||||
<span className="truncate" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<span className="text-red-700 dark:text-red-300" title={j.error || ''}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'runtime',
|
||||
header: 'Dauer',
|
||||
cell: (j) => runtimeOf(j),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Aktion',
|
||||
align: 'right',
|
||||
srOnlyHeader: true,
|
||||
cell: () => <span className="text-xs text-gray-400">—</span>,
|
||||
},
|
||||
]
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Card grayBody>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Keine abgeschlossenen Downloads im Zielordner vorhanden.
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ✅ Mobile: Cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
{rows.map((j) => {
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const file = baseName(j.output || '')
|
||||
const dur = runtimeOf(j)
|
||||
|
||||
const statusNode =
|
||||
j.status === 'failed' ? (
|
||||
<span className="text-red-700 dark:text-red-300" title={j.error || ''}>
|
||||
failed{httpCodeFromError(j.error) ? ` (${httpCodeFromError(j.error)})` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium">{j.status}</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={keyFor(j)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onOpenPlayer(j)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
||||
}}
|
||||
onContextMenu={(e) => openCtx(j, e)}
|
||||
>
|
||||
<Card
|
||||
header={
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||
{model}
|
||||
</div>
|
||||
<div className="truncate text-xs text-gray-600 dark:text-gray-300">
|
||||
{file || '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ Menü-Button für Touch/Small Devices */}
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded px-2 py-1 text-lg leading-none hover:bg-black/5 dark:hover:bg-white/10"
|
||||
aria-label="Aktionen"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
openCtxAt(j, r.left, r.bottom + 6)
|
||||
}}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
openCtx(j, e)
|
||||
}}
|
||||
>
|
||||
<FinishedVideoPreview job={j} getFileName={baseName} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||||
Status: {statusNode}
|
||||
<span className="mx-2 opacity-60">•</span>
|
||||
Dauer: <span className="font-medium">{dur}</span>
|
||||
</div>
|
||||
|
||||
{j.output ? (
|
||||
<div className="mt-1 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{j.output}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ✅ Desktop/Tablet: Tabelle */}
|
||||
<div className="hidden sm:block">
|
||||
<Table
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
getRowKey={(j) => keyFor(j)}
|
||||
striped
|
||||
fullWidth
|
||||
onRowClick={onOpenPlayer}
|
||||
onRowContextMenu={(job, e) => openCtx(job, e)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
open={!!ctx}
|
||||
x={ctx?.x ?? 0}
|
||||
y={ctx?.y ?? 0}
|
||||
items={items}
|
||||
onClose={() => setCtx(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
55
frontend/src/components/ui/FinishedVideoPreview.tsx
Normal file
55
frontend/src/components/ui/FinishedVideoPreview.tsx
Normal file
@ -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 <div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5" />
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
content={
|
||||
<div className="w-[420px]">
|
||||
<div className="aspect-video">
|
||||
<video
|
||||
src={src}
|
||||
className="w-full h-full bg-black"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Mini in Tabelle */}
|
||||
<video
|
||||
src={src}
|
||||
className="w-20 h-16 object-cover rounded bg-black"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
loop
|
||||
autoPlay
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</HoverPopover>
|
||||
)
|
||||
}
|
||||
118
frontend/src/components/ui/HoverPopover.tsx
Normal file
118
frontend/src/components/ui/HoverPopover.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
// HoverPopover.tsx
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import Card from './Card'
|
||||
|
||||
type Pos = { left: number; top: number }
|
||||
|
||||
export default function HoverPopover({
|
||||
children,
|
||||
content,
|
||||
}: PropsWithChildren<{ content: ReactNode }>) {
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState<Pos | null>(null)
|
||||
|
||||
const computePos = () => {
|
||||
const trigger = triggerRef.current
|
||||
const pop = popoverRef.current
|
||||
if (!trigger || !pop) return
|
||||
|
||||
const gap = 8
|
||||
const pad = 8 // Abstand zum Viewport-Rand
|
||||
const r = trigger.getBoundingClientRect()
|
||||
const pr = pop.getBoundingClientRect()
|
||||
|
||||
// Prefer: unten
|
||||
let top = r.bottom + gap
|
||||
const bottomOverflow = top + pr.height > window.innerHeight - pad
|
||||
|
||||
if (bottomOverflow) {
|
||||
// Versuch: oben
|
||||
const above = r.top - pr.height - gap
|
||||
if (above >= pad) top = above
|
||||
else {
|
||||
// Notfalls clamp ins Viewport
|
||||
top = Math.max(pad, window.innerHeight - pr.height - pad)
|
||||
}
|
||||
}
|
||||
|
||||
// Left clamp
|
||||
let left = r.left
|
||||
if (left + pr.width > window.innerWidth - pad) {
|
||||
left = window.innerWidth - pr.width - pad
|
||||
}
|
||||
left = Math.max(pad, left)
|
||||
|
||||
setPos({ left, top })
|
||||
}
|
||||
|
||||
// Beim Öffnen: erst rendern, dann messen/positionieren
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
// rAF sorgt dafür, dass DOM wirklich steht bevor wir messen
|
||||
const id = requestAnimationFrame(() => computePos())
|
||||
return () => cancelAnimationFrame(id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
// Reposition bei Scroll/Resize solange offen
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onMove = () => requestAnimationFrame(() => computePos())
|
||||
window.addEventListener('resize', onMove)
|
||||
window.addEventListener('scroll', onMove, true) // capture: auch in scroll-containern
|
||||
return () => {
|
||||
window.removeEventListener('resize', onMove)
|
||||
window.removeEventListener('scroll', onMove, true)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
className="inline-flex"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50"
|
||||
style={{
|
||||
left: pos?.left ?? -9999,
|
||||
top: pos?.top ?? -9999,
|
||||
visibility: pos ? 'visible' : 'hidden',
|
||||
}}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<Card
|
||||
className="shadow-lg ring-1 ring-black/10 dark:ring-white/10 w-[360px]"
|
||||
noBodyPadding
|
||||
>
|
||||
{content}
|
||||
</Card>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
110
frontend/src/components/ui/LiveHlsVideo.tsx
Normal file
110
frontend/src/components/ui/LiveHlsVideo.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
|
||||
export default function LiveHlsVideo({
|
||||
src,
|
||||
muted,
|
||||
className,
|
||||
}: {
|
||||
src: string
|
||||
muted: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
const [broken, setBroken] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let hls: Hls | null = null
|
||||
|
||||
const video = ref.current
|
||||
if (!video) return
|
||||
|
||||
setBroken(false)
|
||||
video.muted = muted
|
||||
|
||||
async function waitForManifest() {
|
||||
const started = Date.now()
|
||||
while (!cancelled && Date.now() - started < 20_000) {
|
||||
try {
|
||||
const r = await fetch(src, { cache: 'no-store' })
|
||||
if (r.status === 204) {
|
||||
// Preview wird noch erzeugt -> weiter pollen
|
||||
} else if (r.ok) {
|
||||
return true
|
||||
}
|
||||
|
||||
} catch {}
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const ok = await waitForManifest()
|
||||
if (!ok || cancelled) {
|
||||
if (!cancelled) setBroken(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Safari kann HLS nativ
|
||||
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src
|
||||
video.play().catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
if (!Hls.isSupported()) {
|
||||
setBroken(true)
|
||||
return
|
||||
}
|
||||
|
||||
hls = new Hls({
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 2,
|
||||
maxBufferLength: 4,
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_evt, data) => {
|
||||
if (data.fatal) setBroken(true)
|
||||
})
|
||||
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(video)
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
video.play().catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
start()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
hls?.destroy()
|
||||
}
|
||||
}, [src, muted])
|
||||
|
||||
if (broken) return <div className="text-xs text-gray-400 italic">–</div>
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
className={className}
|
||||
playsInline
|
||||
autoPlay
|
||||
// wichtig: Mini bleibt muted über Prop, Popover nicht
|
||||
muted={muted}
|
||||
// click hilft, falls Autoplay mit Sound geblockt
|
||||
onClick={() => {
|
||||
const v = ref.current
|
||||
if (v) {
|
||||
v.muted = false
|
||||
v.play().catch(() => {})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
61
frontend/src/components/ui/Modal.tsx
Normal file
61
frontend/src/components/ui/Modal.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
|
||||
type ModalProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
children?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
icon,
|
||||
}: ModalProps) {
|
||||
return (
|
||||
<Transition show={open} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
||||
{/* Backdrop */}
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300" enterFrom="opacity-0" enterTo="opacity-100"
|
||||
leave="ease-in duration-200" leaveFrom="opacity-100" leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-gray-500/75 dark:bg-gray-900/50" />
|
||||
</Transition.Child>
|
||||
|
||||
{/* Modal Panel */}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center px-4 py-6 sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300" enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200" leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative w-full max-w-lg transform overflow-hidden rounded-lg bg-white p-6 text-left shadow-xl transition-all dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10">
|
||||
{icon && (
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-500/10">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
{title && (
|
||||
<Dialog.Title className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
)}
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-300">{children}</div>
|
||||
{footer && <div className="mt-6 flex justify-end gap-3">{footer}</div>}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)
|
||||
}
|
||||
39
frontend/src/components/ui/ModelPreview.tsx
Normal file
39
frontend/src/components/ui/ModelPreview.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
// frontend\src\components\ui\ModelPreview.tsx
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import HoverPopover from './HoverPopover'
|
||||
import LiveHlsVideo from './LiveHlsVideo'
|
||||
|
||||
export default function ModelPreview({ jobId }: { jobId: string }) {
|
||||
const low = useMemo(
|
||||
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&file=index.m3u8`,
|
||||
[jobId]
|
||||
)
|
||||
const hq = useMemo(
|
||||
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&file=index_hq.m3u8`,
|
||||
[jobId]
|
||||
)
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
content={
|
||||
<div className="w-[420px]">
|
||||
<div className="aspect-video">
|
||||
<LiveHlsVideo
|
||||
src={hq}
|
||||
muted={false}
|
||||
className="w-full h-full bg-black"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LiveHlsVideo
|
||||
src={low}
|
||||
muted
|
||||
className="w-20 h-16 object-cover rounded bg-gray-100 dark:bg-white/5"
|
||||
/>
|
||||
</HoverPopover>
|
||||
)
|
||||
}
|
||||
318
frontend/src/components/ui/ModelsTab.tsx
Normal file
318
frontend/src/components/ui/ModelsTab.tsx
Normal file
@ -0,0 +1,318 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Card from './Card'
|
||||
import Button from './Button'
|
||||
import Table, { type Column } from './Table'
|
||||
|
||||
type ParsedModel = {
|
||||
input: string
|
||||
isUrl: boolean
|
||||
host?: string
|
||||
path?: string
|
||||
modelKey: string
|
||||
}
|
||||
|
||||
export type StoredModel = {
|
||||
id: string
|
||||
input: string
|
||||
isUrl: boolean
|
||||
host?: string
|
||||
path?: string
|
||||
modelKey: string
|
||||
watching: boolean
|
||||
favorite: boolean
|
||||
hot: boolean
|
||||
keep: boolean
|
||||
liked?: boolean | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
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<T>
|
||||
}
|
||||
|
||||
const badge = (on: boolean, label: string) => (
|
||||
<span
|
||||
className={[
|
||||
'inline-flex items-center rounded-md px-2 py-0.5 text-xs',
|
||||
on
|
||||
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-200'
|
||||
: 'bg-gray-50 text-gray-600 dark:bg-white/5 dark:text-gray-300',
|
||||
].join(' ')}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
|
||||
export default function ModelsTab() {
|
||||
const [models, setModels] = React.useState<StoredModel[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [err, setErr] = React.useState<string | null>(null)
|
||||
|
||||
const [q, setQ] = React.useState('')
|
||||
const [input, setInput] = React.useState('')
|
||||
const [parsed, setParsed] = React.useState<ParsedModel | null>(null)
|
||||
const [parseError, setParseError] = React.useState<string | null>(null)
|
||||
const [adding, setAdding] = React.useState(false)
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
setLoading(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const list = await apiJSON<StoredModel[]>('/api/models/list')
|
||||
setModels(Array.isArray(list) ? list : [])
|
||||
} catch (e: any) {
|
||||
setErr(e?.message ?? String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
// Parse (debounced) via existing /api/models/parse
|
||||
React.useEffect(() => {
|
||||
const v = input.trim()
|
||||
if (!v) {
|
||||
setParsed(null)
|
||||
setParseError(null)
|
||||
return
|
||||
}
|
||||
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const p = await apiJSON<ParsedModel>('/api/models/parse', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: v }),
|
||||
})
|
||||
setParsed(p)
|
||||
setParseError(null)
|
||||
} catch (e: any) {
|
||||
setParsed(null)
|
||||
setParseError(e?.message ?? String(e))
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(t)
|
||||
}, [input])
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
if (!needle) return models
|
||||
return models.filter((m) => {
|
||||
const hay = [
|
||||
m.modelKey,
|
||||
m.host ?? '',
|
||||
m.input ?? '',
|
||||
].join(' ').toLowerCase()
|
||||
return hay.includes(needle)
|
||||
})
|
||||
}, [models, q])
|
||||
|
||||
const upsertFromParsed = async () => {
|
||||
if (!parsed) return
|
||||
setAdding(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const saved = await apiJSON<StoredModel>('/api/models/upsert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(parsed),
|
||||
})
|
||||
setModels((prev) => {
|
||||
const rest = prev.filter((x) => x.id !== saved.id)
|
||||
return [saved, ...rest]
|
||||
})
|
||||
setInput('')
|
||||
setParsed(null)
|
||||
} catch (e: any) {
|
||||
setErr(e?.message ?? String(e))
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const patch = async (id: string, body: any) => {
|
||||
const updated = await apiJSON<StoredModel>('/api/models/flags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, ...body }),
|
||||
})
|
||||
setModels((prev) => prev.map((m) => (m.id === updated.id ? updated : m)))
|
||||
}
|
||||
|
||||
const del = async (id: string) => {
|
||||
await apiJSON('/api/models/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
setModels((prev) => prev.filter((m) => m.id !== id))
|
||||
}
|
||||
|
||||
const columns = React.useMemo<Column<StoredModel>[]>(() => {
|
||||
return [
|
||||
{
|
||||
key: 'model',
|
||||
header: 'Model',
|
||||
cell: (m) => (
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{m.modelKey}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{m.host ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'url',
|
||||
header: 'URL',
|
||||
cell: (m) => (
|
||||
<a
|
||||
href={m.input}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-indigo-600 dark:text-indigo-400 hover:underline truncate block"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={m.input}
|
||||
>
|
||||
{m.input}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'flags',
|
||||
header: 'Status',
|
||||
cell: (m) => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{badge(m.watching, '👁 Beobachten')}
|
||||
{badge(m.favorite, '★ Favorit')}
|
||||
{badge(m.hot, '🔥 HOT')}
|
||||
{badge(m.keep, '📌 Behalten')}
|
||||
<span className="inline-flex items-center rounded-md px-2 py-0.5 text-xs bg-gray-50 text-gray-600 dark:bg-white/5 dark:text-gray-300">
|
||||
{m.liked === true ? '👍' : m.liked === false ? '👎' : '—'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Aktion',
|
||||
align: 'right',
|
||||
srOnlyHeader: true,
|
||||
cell: (m) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { watching: !m.watching }) }}>
|
||||
👁
|
||||
</Button>
|
||||
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { favorite: !m.favorite }) }}>
|
||||
★
|
||||
</Button>
|
||||
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { hot: !m.hot }) }}>
|
||||
🔥
|
||||
</Button>
|
||||
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { keep: !m.keep }) }}>
|
||||
📌
|
||||
</Button>
|
||||
|
||||
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { liked: true }) }}>
|
||||
👍
|
||||
</Button>
|
||||
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { liked: false }) }}>
|
||||
👎
|
||||
</Button>
|
||||
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { clearLiked: true }) }}>
|
||||
✕
|
||||
</Button>
|
||||
|
||||
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); del(m.id) }} title="Löschen">
|
||||
🗑
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card
|
||||
header={<div className="text-sm font-medium text-gray-900 dark:text-white">Model hinzufügen</div>}
|
||||
grayBody
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="URL oder Modelname…"
|
||||
className="flex-1 rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
<Button
|
||||
className="px-3 py-2 text-sm"
|
||||
onClick={upsertFromParsed}
|
||||
disabled={!parsed || adding}
|
||||
title={!parsed ? 'Ungültig / nicht geparst' : 'In Models speichern'}
|
||||
>
|
||||
Hinzufügen
|
||||
</Button>
|
||||
<Button className="px-3 py-2 text-sm" onClick={refresh} disabled={loading}>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{parseError ? (
|
||||
<div className="text-xs text-red-600 dark:text-red-300">{parseError}</div>
|
||||
) : parsed ? (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||||
Gefunden: <span className="font-medium">{parsed.modelKey}</span>
|
||||
{parsed.host ? <span className="opacity-70"> • {parsed.host}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{err ? (
|
||||
<div className="text-xs text-red-600 dark:text-red-300">{err}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
header={
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Models ({filtered.length})
|
||||
</div>
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Suchen…"
|
||||
className="w-[220px] rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
noBodyPadding
|
||||
>
|
||||
<Table
|
||||
rows={filtered}
|
||||
columns={columns}
|
||||
getRowKey={(m) => m.id}
|
||||
striped
|
||||
fullWidth
|
||||
onRowClick={(m) => {
|
||||
if (m.input) window.open(m.input, '_blank', 'noreferrer')
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
176
frontend/src/components/ui/Player.tsx
Normal file
176
frontend/src/components/ui/Player.tsx
Normal file
@ -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<HTMLDivElement | null>(null)
|
||||
const playerRef = React.useRef<VideoJsPlayer | null>(null)
|
||||
const videoNodeRef = React.useRef<HTMLVideoElement | null>(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<void>).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 (
|
||||
<Card
|
||||
className={[
|
||||
'fixed z-50 shadow-xl border',
|
||||
expanded ? 'inset-6' : 'bottom-4 right-4 w-[360px]',
|
||||
'flex flex-col',
|
||||
className ?? '',
|
||||
].join(' ')}
|
||||
noBodyPadding
|
||||
bodyClassName="flex flex-col flex-1 min-h-0 p-0"
|
||||
header={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{title}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
className="px-2 py-1 rounded"
|
||||
onClick={onToggleExpand}
|
||||
aria-label={expanded ? 'Minimieren' : 'Maximieren'}
|
||||
title={expanded ? 'Minimieren' : 'Maximieren'}
|
||||
>
|
||||
{expanded ? (
|
||||
<ArrowsPointingInIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<ArrowsPointingOutIcon className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button className="px-2 py-1 rounded text-sm" onClick={onClose} title="Schließen">
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
<div className="min-w-0 truncate">
|
||||
Status: <span className="font-medium">{job.status}</span>
|
||||
{job.output ? <span className="ml-2 opacity-70">• {job.output}</span> : null}
|
||||
</div>
|
||||
{job.output ? (
|
||||
<button className="hover:underline" onClick={() => navigator.clipboard.writeText(job.output || '')}>
|
||||
Pfad kopieren
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
grayBody={false}
|
||||
>
|
||||
<div className={expanded ? 'flex-1 min-h-0' : 'aspect-video'}>
|
||||
<div className={['w-full h-full min-h-0', !expanded ? 'vjs-mini' : ''].join(' ')}>
|
||||
<div ref={containerRef} className="w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
180
frontend/src/components/ui/RecorderSettings.tsx
Normal file
180
frontend/src/components/ui/RecorderSettings.tsx
Normal file
@ -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<RecorderSettings>(DEFAULTS)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [browsing, setBrowsing] = useState<'record' | 'done' | null>(null)
|
||||
const [msg, setMsg] = useState<string | null>(null)
|
||||
const [err, setErr] = useState<string | null>(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 (
|
||||
<Card
|
||||
header={
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Einstellungen
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="primary" onClick={save} disabled={saving}>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
grayBody
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{err && (
|
||||
<div className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-500/10 dark:text-red-200">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
{msg && (
|
||||
<div className="rounded-md bg-green-50 px-3 py-2 text-sm text-green-700 dark:bg-green-500/10 dark:text-green-200">
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12 sm:items-center">
|
||||
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
|
||||
Aufnahme-Ordner
|
||||
</label>
|
||||
<div className="sm:col-span-9 flex gap-2">
|
||||
<input
|
||||
value={value.recordDir}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => browse('record')}
|
||||
disabled={saving || browsing !== null}
|
||||
>
|
||||
Durchsuchen...
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12 sm:items-center">
|
||||
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
|
||||
Fertige Downloads nach
|
||||
</label>
|
||||
<div className="sm:col-span-9 flex gap-2">
|
||||
<input
|
||||
value={value.doneDir}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => browse('done')}
|
||||
disabled={saving || browsing !== null}
|
||||
>
|
||||
Durchsuchen...
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
218
frontend/src/components/ui/RunningDownloads.tsx
Normal file
218
frontend/src/components/ui/RunningDownloads.tsx
Normal file
@ -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(/\.[^.]+$/, '')
|
||||
|
||||
// <model>_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<Column<RecordJob>[]>(() => {
|
||||
return [
|
||||
{
|
||||
key: 'preview',
|
||||
header: 'Vorschau',
|
||||
cell: (j) => <ModelPreview jobId={j.id} />,
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
header: 'Modelname',
|
||||
cell: (j) => {
|
||||
const name = modelNameFromOutput(j.output)
|
||||
return (
|
||||
<span className="truncate" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sourceUrl',
|
||||
header: 'Source',
|
||||
cell: (j) => (
|
||||
<a
|
||||
href={j.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-indigo-600 dark:text-indigo-400 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{j.sourceUrl}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onStopJob(j.id)
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [onStopJob])
|
||||
|
||||
if (jobs.length === 0) {
|
||||
return (
|
||||
<Card grayBody>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Keine laufenden Downloads.
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ✅ Mobile: Cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
{jobs.map((j) => {
|
||||
const model = modelNameFromOutput(j.output)
|
||||
const file = baseName(j.output || '')
|
||||
const dur = runtimeOf(j)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={j.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onOpenPlayer(j)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
header={
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||
{model}
|
||||
</div>
|
||||
<div className="truncate text-xs text-gray-600 dark:text-gray-300">
|
||||
{file || '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onStopJob(j.id)
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<ModelPreview jobId={j.id} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||||
Status: <span className="font-medium">{j.status}</span>
|
||||
<span className="mx-2 opacity-60">•</span>
|
||||
Dauer: <span className="font-medium">{dur}</span>
|
||||
</div>
|
||||
|
||||
{j.sourceUrl ? (
|
||||
<a
|
||||
href={j.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-1 block truncate text-xs text-indigo-600 dark:text-indigo-400 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{j.sourceUrl}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ✅ Desktop/Tablet: Tabelle */}
|
||||
<div className="hidden sm:block">
|
||||
<Table
|
||||
rows={jobs}
|
||||
columns={columns}
|
||||
getRowKey={(r) => r.id}
|
||||
striped
|
||||
fullWidth
|
||||
onRowClick={onOpenPlayer}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
228
frontend/src/components/ui/Table.tsx
Normal file
228
frontend/src/components/ui/Table.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
import * as React from 'react'
|
||||
|
||||
type Align = 'left' | 'center' | 'right'
|
||||
|
||||
export type Column<T> = {
|
||||
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<T> = {
|
||||
columns: Array<Column<T>>
|
||||
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<HTMLTableRowElement>) => void
|
||||
|
||||
}
|
||||
|
||||
function cn(...parts: Array<string | false | null | undefined>) {
|
||||
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<T>({
|
||||
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<T>) {
|
||||
const cellY = compact ? 'py-2' : 'py-4'
|
||||
const headY = compact ? 'py-3' : 'py-3.5'
|
||||
|
||||
return (
|
||||
<div className={cn(fullWidth ? '' : 'px-4 sm:px-6 lg:px-8', className)}>
|
||||
{(title || description || actions) && (
|
||||
<div className="sm:flex sm:items-center">
|
||||
<div className="sm:flex-auto">
|
||||
{title && (
|
||||
<h1 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
{description && (
|
||||
<p className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn(title || description || actions ? 'mt-8' : '')}>
|
||||
<div className="flow-root">
|
||||
<div className="overflow-x-auto">
|
||||
<div className={cn('inline-block min-w-full py-2 align-middle', fullWidth ? '' : 'sm:px-6 lg:px-8')}>
|
||||
<div
|
||||
className={cn(
|
||||
card &&
|
||||
'overflow-hidden shadow-sm outline-1 outline-black/5 sm:rounded-lg dark:shadow-none dark:-outline-offset-1 dark:outline-white/10'
|
||||
)}
|
||||
>
|
||||
<table className="relative min-w-full divide-y divide-gray-300 dark:divide-white/15">
|
||||
<thead
|
||||
className={cn(
|
||||
card && 'bg-gray-50 dark:bg-gray-800/75',
|
||||
stickyHeader &&
|
||||
'sticky top-0 z-10 backdrop-blur-sm backdrop-filter'
|
||||
)}
|
||||
>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
scope="col"
|
||||
className={cn(
|
||||
headY,
|
||||
'px-3 text-sm font-semibold text-gray-900 dark:text-gray-200',
|
||||
alignTd(col.align),
|
||||
col.widthClassName,
|
||||
col.headerClassName
|
||||
)}
|
||||
>
|
||||
{col.srOnlyHeader ? (
|
||||
<span className="sr-only">{col.header}</span>
|
||||
) : (
|
||||
col.header
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody
|
||||
className={cn(
|
||||
'divide-y divide-gray-200 dark:divide-white/10',
|
||||
card
|
||||
? 'bg-white dark:bg-gray-800/50'
|
||||
: 'bg-white dark:bg-gray-900'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className={cn(cellY, 'px-3 text-sm text-gray-500 dark:text-gray-400')}
|
||||
>
|
||||
Lädt…
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className={cn(cellY, 'px-3 text-sm text-gray-500 dark:text-gray-400')}
|
||||
>
|
||||
{emptyLabel}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row, rowIndex) => {
|
||||
const key = getRowKey
|
||||
? getRowKey(row, rowIndex)
|
||||
: String(rowIndex)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
className={cn(
|
||||
striped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
|
||||
onRowClick && "cursor-pointer"
|
||||
)}
|
||||
onClick={() => 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 (
|
||||
<td
|
||||
key={col.key}
|
||||
className={cn(
|
||||
cellY,
|
||||
'px-3 text-sm whitespace-nowrap',
|
||||
alignTd(col.align),
|
||||
col.className,
|
||||
// “Primäre” Spalte wirkt wie in den Beispielen: etwas stärker
|
||||
col.key === columns[0]?.key
|
||||
? 'font-medium text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
frontend/src/components/ui/Tabs.tsx
Normal file
91
frontend/src/components/ui/Tabs.tsx
Normal file
@ -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 (
|
||||
<div className={className}>
|
||||
{/* Mobile: Dropdown */}
|
||||
<div className="grid grid-cols-1 sm:hidden">
|
||||
<select
|
||||
value={current.id}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={ariaLabel}
|
||||
className="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline outline-1 outline-gray-300 focus:outline-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-gray-100 dark:outline-white/10 dark:focus:outline-indigo-500"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<option key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end fill-gray-500 dark:fill-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Horizontal Tabs */}
|
||||
<div className="hidden sm:block">
|
||||
<nav className="border-b border-gray-200 dark:border-white/10" aria-label={ariaLabel}>
|
||||
<ul className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => {
|
||||
const selected = tab.id === current.id
|
||||
return (
|
||||
<li key={tab.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={clsx(
|
||||
selected
|
||||
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-white',
|
||||
'group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium'
|
||||
)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{tab.count !== undefined && (
|
||||
<span
|
||||
className={clsx(
|
||||
selected
|
||||
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
|
||||
: 'bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-gray-300',
|
||||
'ml-3 rounded-full px-2.5 py-0.5 text-xs font-medium'
|
||||
)}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
frontend/src/index.css
Normal file
39
frontend/src/index.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
21
frontend/src/types.ts
Normal file
21
frontend/src/types.ts
Normal file
@ -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
|
||||
}
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal file
@ -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"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@ -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"]
|
||||
}
|
||||
20
frontend/vite.config.ts
Normal file
20
frontend/vite.config.ts
Normal file
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user