This commit is contained in:
Linrador 2025-12-19 17:52:14 +01:00
commit 99837f0ed3
41 changed files with 9929 additions and 0 deletions

16
backend/go.mod Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

228
backend/models.go Normal file
View 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
View 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
View 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

Binary file not shown.

View File

@ -0,0 +1,4 @@
{
"recordDir": "C:\\Users\\Rother\\Desktop\\test",
"doneDir": "C:\\Users\\Rother\\Desktop\\test\\done"
}

24
frontend/.gitignore vendored Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

37
frontend/package.json Normal file
View 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
View 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
View File

413
frontend/src/App.tsx Normal file
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
)
}

View 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>
)
}

View 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 },
]
}

View 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)}
/>
</>
)
}

View 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>
)
}

View 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
)}
</>
)
}

View 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(() => {})
}
}}
/>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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
View 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
View 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
View 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
}

View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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,
},
},
},
})