updated
This commit is contained in:
parent
e3387dd6fe
commit
50515d44b0
Binary file not shown.
Binary file not shown.
Binary file not shown.
750
backend/main.go
750
backend/main.go
@ -6,13 +6,16 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha1"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@ -1260,7 +1263,12 @@ func removeGeneratedForID(id string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) NEU: generated/<id>/ (enthält thumbs.jpg, preview.mp4, meta.json, t_*.jpg, ...)
|
// 1) NEU: generated/meta/<id>/ ...
|
||||||
|
if root, _ := generatedMetaRoot(); strings.TrimSpace(root) != "" {
|
||||||
|
_ = os.RemoveAll(filepath.Join(root, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// (optional aber sinnvoll) 1b) Legacy: generated/<id>/ (falls noch alte Assets existieren)
|
||||||
if root, _ := generatedRoot(); strings.TrimSpace(root) != "" {
|
if root, _ := generatedRoot(); strings.TrimSpace(root) != "" {
|
||||||
_ = os.RemoveAll(filepath.Join(root, id))
|
_ = os.RemoveAll(filepath.Join(root, id))
|
||||||
}
|
}
|
||||||
@ -2297,10 +2305,419 @@ func stripHotPrefix(s string) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------
|
||||||
|
// Covers: generated/covers/<category>.<ext>
|
||||||
|
// --------------------------
|
||||||
|
|
||||||
|
func splitTagsLoose(raw string) []string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
||||||
|
switch r {
|
||||||
|
case '\n', ',', ';', '|':
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, p := range parts {
|
||||||
|
t := strings.TrimSpace(p)
|
||||||
|
if t == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
low := strings.ToLower(t)
|
||||||
|
if _, ok := seen[low]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[low] = struct{}{}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTag(tagsRaw string, want string) bool {
|
||||||
|
want = strings.ToLower(strings.TrimSpace(want))
|
||||||
|
if want == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, t := range splitTagsLoose(tagsRaw) {
|
||||||
|
if strings.ToLower(strings.TrimSpace(t)) == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Passe diese Struct/Methoden an dein echtes ModelStore-API an.
|
||||||
|
type coverModel struct {
|
||||||
|
Key string // z.B. model key/name
|
||||||
|
Tags string // raw tags (csv/newline/…)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listModelsForCovers() ([]coverModel, error) {
|
||||||
|
if coverModelStore == nil {
|
||||||
|
return nil, fmt.Errorf("model store not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
ms := coverModelStore.List() // ✅ existiert bei dir
|
||||||
|
|
||||||
|
out := make([]coverModel, 0, len(ms))
|
||||||
|
for _, m := range ms {
|
||||||
|
key := strings.TrimSpace(m.ModelKey)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, coverModel{
|
||||||
|
Key: key,
|
||||||
|
Tags: m.Tags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath string, err error) {
|
||||||
|
category = strings.TrimSpace(category)
|
||||||
|
if category == "" {
|
||||||
|
return "", fmt.Errorf("category empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: früh abbrechen, wenn Request schon tot ist
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
models, err := listModelsForCovers()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Kandidaten-Models nach Tag filtern
|
||||||
|
cands := make([]coverModel, 0, 64)
|
||||||
|
for _, m := range models {
|
||||||
|
key := strings.TrimSpace(m.Key)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hasTag(m.Tags, category) {
|
||||||
|
cands = append(cands, coverModel{Key: key, Tags: m.Tags})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cands) == 0 {
|
||||||
|
return "", fmt.Errorf("no model with tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Kandidaten mischen und nacheinander probieren (robuster als 1 random pick)
|
||||||
|
rand.Shuffle(len(cands), func(i, j int) { cands[i], cands[j] = cands[j], cands[i] })
|
||||||
|
|
||||||
|
// 3) done dirs (einmalig auflösen)
|
||||||
|
s := getSettings()
|
||||||
|
doneAbs, derr := resolvePathRelativeToApp(s.DoneDir)
|
||||||
|
if derr != nil || strings.TrimSpace(doneAbs) == "" {
|
||||||
|
return "", fmt.Errorf("doneDir resolve failed: %v", derr)
|
||||||
|
}
|
||||||
|
|
||||||
|
type candFile struct {
|
||||||
|
videoPath string
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
|
||||||
|
isVideo := func(name string) bool {
|
||||||
|
low := strings.ToLower(name)
|
||||||
|
if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(name))
|
||||||
|
return ext == ".mp4" || ext == ".ts"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Für jedes passende Model: Dateien sammeln, random wählen, Thumb prüfen
|
||||||
|
for _, m := range cands {
|
||||||
|
// Context check pro Iteration
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
modelKey := strings.TrimSpace(m.Key)
|
||||||
|
if modelKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kandidaten: done/<model>/ und done/keep/<model>/
|
||||||
|
dirs := []string{
|
||||||
|
filepath.Join(doneAbs, modelKey),
|
||||||
|
filepath.Join(doneAbs, "keep", modelKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
files := make([]candFile, 0, 128)
|
||||||
|
|
||||||
|
for _, d := range dirs {
|
||||||
|
ents, err := os.ReadDir(d)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range ents {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if !isVideo(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
full := filepath.Join(d, name)
|
||||||
|
stem := strings.TrimSuffix(name, filepath.Ext(name))
|
||||||
|
id := stripHotPrefix(strings.TrimSpace(stem))
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, candFile{videoPath: full, id: id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
// ✅ dieses Model hat (noch) keine Downloads -> nächstes Model probieren
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// random file innerhalb des Models
|
||||||
|
cf := files[rand.Intn(len(files))]
|
||||||
|
|
||||||
|
// thumbs sicherstellen (best effort)
|
||||||
|
_ = ensureAssetsForVideo(cf.videoPath)
|
||||||
|
|
||||||
|
tp, terr := generatedThumbFile(cf.id)
|
||||||
|
if terr != nil {
|
||||||
|
// nächstes Model probieren
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fi, serr := os.Stat(tp); serr == nil && !fi.IsDir() && fi.Size() > 0 {
|
||||||
|
return tp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Thumb fehlt -> nächstes Model probieren
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no downloads/thumbs for category")
|
||||||
|
}
|
||||||
|
|
||||||
|
func coversRoot() (string, error) {
|
||||||
|
return resolvePathRelativeToApp(filepath.Join("generated", "covers"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureCoversDir() (string, error) {
|
||||||
|
root, err := coversRoot()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
|
return "", fmt.Errorf("covers root ist leer")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var coverKeyRe = regexp.MustCompile(`[^a-z0-9._-]+`)
|
||||||
|
|
||||||
|
func sanitizeCoverKey(category string) (string, error) {
|
||||||
|
c := strings.ToLower(strings.TrimSpace(category))
|
||||||
|
|
||||||
|
if c == "" {
|
||||||
|
sum := sha1.Sum([]byte(category))
|
||||||
|
c = "tag_" + hex.EncodeToString(sum[:8]) // 16 hex chars reichen
|
||||||
|
}
|
||||||
|
|
||||||
|
if c == "" {
|
||||||
|
return "", fmt.Errorf("category fehlt")
|
||||||
|
}
|
||||||
|
// Windows & FS safe
|
||||||
|
c = strings.ReplaceAll(c, " ", "_")
|
||||||
|
c = coverKeyRe.ReplaceAllString(c, "_")
|
||||||
|
c = strings.Trim(c, "._-")
|
||||||
|
if c == "" {
|
||||||
|
return "", fmt.Errorf("category ungültig")
|
||||||
|
}
|
||||||
|
if len(c) > 120 {
|
||||||
|
c = c[:120]
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectImageExt(contentType string, b []byte) (ext string, ct string) {
|
||||||
|
ct = strings.ToLower(strings.TrimSpace(contentType))
|
||||||
|
|
||||||
|
// wenn server CT liefert
|
||||||
|
switch {
|
||||||
|
case strings.Contains(ct, "image/jpeg") || strings.Contains(ct, "image/jpg"):
|
||||||
|
return ".jpg", "image/jpeg"
|
||||||
|
case strings.Contains(ct, "image/png"):
|
||||||
|
return ".png", "image/png"
|
||||||
|
case strings.Contains(ct, "image/webp"):
|
||||||
|
return ".webp", "image/webp"
|
||||||
|
case strings.Contains(ct, "image/gif"):
|
||||||
|
return ".gif", "image/gif"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Magic bytes fallback
|
||||||
|
if len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF {
|
||||||
|
return ".jpg", "image/jpeg"
|
||||||
|
}
|
||||||
|
if len(b) >= 8 && bytes.Equal(b[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}) {
|
||||||
|
return ".png", "image/png"
|
||||||
|
}
|
||||||
|
if len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP" {
|
||||||
|
return ".webp", "image/webp"
|
||||||
|
}
|
||||||
|
if len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a") {
|
||||||
|
return ".gif", "image/gif"
|
||||||
|
}
|
||||||
|
|
||||||
|
// default: jpg
|
||||||
|
return ".jpg", "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
func coverPathForCategory(key string, ext string) (string, error) {
|
||||||
|
root, err := coversRoot()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
|
return "", fmt.Errorf("covers root ist leer")
|
||||||
|
}
|
||||||
|
if ext == "" {
|
||||||
|
ext = ".jpg"
|
||||||
|
}
|
||||||
|
return filepath.Join(root, key+ext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findExistingCoverFile(key string) (string, os.FileInfo, bool) {
|
||||||
|
root, err := coversRoot()
|
||||||
|
if err != nil || strings.TrimSpace(root) == "" {
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
// probiere gängige Endungen
|
||||||
|
exts := []string{".jpg", ".png", ".webp", ".gif"}
|
||||||
|
for _, ext := range exts {
|
||||||
|
p := filepath.Join(root, key+ext)
|
||||||
|
if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
||||||
|
return p, fi, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadBytes(ctx context.Context, rawURL string, ua string) ([]byte, string, error) {
|
||||||
|
rawURL = strings.TrimSpace(rawURL)
|
||||||
|
if rawURL == "" {
|
||||||
|
return nil, "", fmt.Errorf("src fehlt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 1) Lokaler Pfad: nur /generated/... erlauben
|
||||||
|
if strings.HasPrefix(rawURL, "/") {
|
||||||
|
// URL-Pfad normalisieren und Traversal verhindern
|
||||||
|
clean := path.Clean(rawURL) // URL-style cleaning
|
||||||
|
if !strings.HasPrefix(clean, "/generated/") {
|
||||||
|
return nil, "", fmt.Errorf("src ungültig")
|
||||||
|
}
|
||||||
|
if strings.Contains(clean, "..") {
|
||||||
|
return nil, "", fmt.Errorf("src ungültig")
|
||||||
|
}
|
||||||
|
|
||||||
|
// "/generated/..." -> "generated/..." (relativ zur App)
|
||||||
|
rel := strings.TrimPrefix(clean, "/")
|
||||||
|
abs, err := resolvePathRelativeToApp(rel)
|
||||||
|
if err != nil || strings.TrimSpace(abs) == "" {
|
||||||
|
return nil, "", fmt.Errorf("src ungültig")
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(abs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("download failed: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// max 10MB lesen
|
||||||
|
b, err := io.ReadAll(io.LimitReader(f, 10*1024*1024))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("download failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(b) == 0 {
|
||||||
|
return nil, "", fmt.Errorf("download empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content-Type grob nach Extension (Magic-bytes macht detectImageExt später sowieso)
|
||||||
|
ext := strings.ToLower(filepath.Ext(abs))
|
||||||
|
ct := "application/octet-stream"
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
ct = "image/jpeg"
|
||||||
|
case ".png":
|
||||||
|
ct = "image/png"
|
||||||
|
case ".webp":
|
||||||
|
ct = "image/webp"
|
||||||
|
case ".gif":
|
||||||
|
ct = "image/gif"
|
||||||
|
}
|
||||||
|
|
||||||
|
return b, ct, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 2) Remote URL: wie bisher nur http/https
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return nil, "", fmt.Errorf("src ungültig")
|
||||||
|
}
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return nil, "", fmt.Errorf("src schema nicht erlaubt")
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(ua) == "" {
|
||||||
|
ua = "Mozilla/5.0"
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", ua)
|
||||||
|
req.Header.Set("Accept", "image/*,*/*;q=0.8")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 12 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // max 10MB
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if len(b) == 0 {
|
||||||
|
return nil, "", fmt.Errorf("download empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
return b, resp.Header.Get("Content-Type"), nil
|
||||||
|
}
|
||||||
|
|
||||||
func generatedRoot() (string, error) {
|
func generatedRoot() (string, error) {
|
||||||
return resolvePathRelativeToApp("generated")
|
return resolvePathRelativeToApp("generated")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generatedMetaRoot() (string, error) {
|
||||||
|
return resolvePathRelativeToApp(filepath.Join("generated", "meta"))
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy (falls noch alte Assets liegen):
|
// Legacy (falls noch alte Assets liegen):
|
||||||
func generatedThumbsRoot() (string, error) {
|
func generatedThumbsRoot() (string, error) {
|
||||||
return resolvePathRelativeToApp(filepath.Join("generated", "thumbs"))
|
return resolvePathRelativeToApp(filepath.Join("generated", "thumbs"))
|
||||||
@ -2309,57 +2726,240 @@ func generatedTeaserRoot() (string, error) {
|
|||||||
return resolvePathRelativeToApp(filepath.Join("generated", "teaser"))
|
return resolvePathRelativeToApp(filepath.Join("generated", "teaser"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generatedCover(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
category := r.URL.Query().Get("category")
|
||||||
|
key, err := sanitizeCoverKey(category)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "category ungültig: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh")))
|
||||||
|
force := refresh == "1" || refresh == "true" || refresh == "yes"
|
||||||
|
|
||||||
|
// Optional: src (Frontend kann ein konkretes Thumb vorgeben)
|
||||||
|
src := strings.TrimSpace(r.URL.Query().Get("src"))
|
||||||
|
|
||||||
|
// 1) Cache hit: direkt von Disk (nur wenn nicht force)
|
||||||
|
if !force {
|
||||||
|
if p, fi, ok := findExistingCoverFile(key); ok {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(p))
|
||||||
|
switch ext {
|
||||||
|
case ".png":
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
case ".webp":
|
||||||
|
w.Header().Set("Content-Type", "image/webp")
|
||||||
|
case ".gif":
|
||||||
|
w.Header().Set("Content-Type", "image/gif")
|
||||||
|
default:
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(p)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
http.ServeContent(w, r, filepath.Base(p), fi.ModTime(), f)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Kein Cache (oder force): Cover erzeugen und persistieren
|
||||||
|
if _, err := ensureCoversDir(); err != nil {
|
||||||
|
http.Error(w, "covers-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
b []byte
|
||||||
|
mimeType string
|
||||||
|
ext string
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2a) Wenn src gegeben: daraus bauen (lokal /generated/... oder http(s))
|
||||||
|
if src != "" {
|
||||||
|
var derr error
|
||||||
|
b, mimeType, derr = downloadBytes(ctx, src, r.Header.Get("User-Agent"))
|
||||||
|
if derr != nil {
|
||||||
|
http.Error(w, "src download failed: "+derr.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ext, mimeType = detectImageExt(mimeType, b)
|
||||||
|
if len(b) == 0 {
|
||||||
|
http.Error(w, "src leer", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 2b) Sonst: Backend wählt random Thumb passend zur Category
|
||||||
|
thumbPath, perr := pickRandomThumbForCategory(ctx, category)
|
||||||
|
if perr != nil {
|
||||||
|
if p, fi, ok := findExistingCoverFile(key); ok {
|
||||||
|
// vorhandenes Cover liefern statt Placeholder
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=600")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
ext := strings.ToLower(filepath.Ext(p))
|
||||||
|
switch ext {
|
||||||
|
case ".png":
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
case ".webp":
|
||||||
|
w.Header().Set("Content-Type", "image/webp")
|
||||||
|
case ".gif":
|
||||||
|
w.Header().Set("Content-Type", "image/gif")
|
||||||
|
default:
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := os.Open(p)
|
||||||
|
if err != nil {
|
||||||
|
servePreviewStatusSVG(w, "No Cover")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
http.ServeContent(w, r, filepath.Base(p), fi.ModTime(), f)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
servePreviewStatusSVG(w, "No Cover")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err = os.ReadFile(thumbPath)
|
||||||
|
if err != nil || len(b) == 0 {
|
||||||
|
http.Error(w, "cover read fehlgeschlagen", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// thumbs bei dir sind JPEG
|
||||||
|
ext = ".jpg"
|
||||||
|
mimeType = "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Vorherige Cover-Dateien mit allen Endungen entfernen (damit es nur 1 gibt)
|
||||||
|
root, _ := coversRoot()
|
||||||
|
for _, e := range []string{".jpg", ".png", ".webp", ".gif"} {
|
||||||
|
_ = os.Remove(filepath.Join(root, key+e))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Persistieren
|
||||||
|
dst, err := coverPathForCategory(key, ext)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "cover path: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := atomicWriteFile(dst, b); err != nil {
|
||||||
|
http.Error(w, "cover write: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Ausliefern
|
||||||
|
// Nicht zu aggressiv cachen, weil Tags/Randomisierung sich ändern können.
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=600")
|
||||||
|
w.Header().Set("Content-Type", mimeType)
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------
|
// --------------------------
|
||||||
// generated/<id>/meta.json
|
// generated/meta/<id>/meta.json
|
||||||
// --------------------------
|
// --------------------------
|
||||||
|
|
||||||
type videoMetaV1 struct {
|
type videoMetaV2 struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
DurationSeconds float64 `json:"durationSeconds"`
|
||||||
|
FileSize int64 `json:"fileSize"`
|
||||||
|
FileModUnix int64 `json:"fileModUnix"`
|
||||||
|
|
||||||
|
VideoWidth int `json:"videoWidth,omitempty"`
|
||||||
|
VideoHeight int `json:"videoHeight,omitempty"`
|
||||||
|
FPS float64 `json:"fps,omitempty"`
|
||||||
|
|
||||||
|
UpdatedAtUnix int64 `json:"updatedAtUnix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// liest v2 (oder v1) und validiert gegen fi (Size/ModTime)
|
||||||
|
func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int, fps float64, ok bool) {
|
||||||
|
b, err := os.ReadFile(metaPath)
|
||||||
|
if err != nil || len(b) == 0 {
|
||||||
|
return 0, 0, 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) erst v2 versuchen
|
||||||
|
var m2 videoMetaV2
|
||||||
|
if err := json.Unmarshal(b, &m2); err == nil && (m2.Version == 2 || m2.Version == 1) {
|
||||||
|
// gleiche Felder für v1/v2: duration/size/mod
|
||||||
|
if m2.FileSize != fi.Size() || m2.FileModUnix != fi.ModTime().Unix() {
|
||||||
|
return 0, 0, 0, 0, false
|
||||||
|
}
|
||||||
|
if m2.DurationSeconds <= 0 {
|
||||||
|
return 0, 0, 0, 0, false
|
||||||
|
}
|
||||||
|
return m2.DurationSeconds, m2.VideoWidth, m2.VideoHeight, m2.FPS, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Fallback: altes v1 Format (nur Duration)
|
||||||
|
var m1 struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
DurationSeconds float64 `json:"durationSeconds"`
|
DurationSeconds float64 `json:"durationSeconds"`
|
||||||
FileSize int64 `json:"fileSize"`
|
FileSize int64 `json:"fileSize"`
|
||||||
FileModUnix int64 `json:"fileModUnix"`
|
FileModUnix int64 `json:"fileModUnix"`
|
||||||
UpdatedAtUnix int64 `json:"updatedAtUnix"`
|
UpdatedAtUnix int64 `json:"updatedAtUnix"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &m1); err != nil {
|
||||||
|
return 0, 0, 0, 0, false
|
||||||
|
}
|
||||||
|
if m1.Version != 1 {
|
||||||
|
return 0, 0, 0, 0, false
|
||||||
|
}
|
||||||
|
if m1.FileSize != fi.Size() || m1.FileModUnix != fi.ModTime().Unix() {
|
||||||
|
return 0, 0, 0, 0, false
|
||||||
|
}
|
||||||
|
if m1.DurationSeconds <= 0 {
|
||||||
|
return 0, 0, 0, 0, false
|
||||||
|
}
|
||||||
|
return m1.DurationSeconds, 0, 0, 0, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func generatedMetaFile(id string) (string, error) {
|
func writeVideoMetaV2(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64) error {
|
||||||
dir, err := generatedDirForID(id) // erzeugt KEIN Verzeichnis
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Join(dir, "meta.json"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readVideoMetaDuration(metaPath string, fi os.FileInfo) (sec float64, ok bool) {
|
|
||||||
b, err := os.ReadFile(metaPath)
|
|
||||||
if err != nil || len(b) == 0 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
var m videoMetaV1
|
|
||||||
if err := json.Unmarshal(b, &m); err != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
if m.Version != 1 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
// Invalidation: wenn Datei geändert wurde -> Meta ignorieren
|
|
||||||
if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
if m.DurationSeconds <= 0 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return m.DurationSeconds, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64) error {
|
|
||||||
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
|
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
m := videoMetaV1{
|
m := videoMetaV2{
|
||||||
Version: 1,
|
Version: 2,
|
||||||
DurationSeconds: dur,
|
DurationSeconds: dur,
|
||||||
FileSize: fi.Size(),
|
FileSize: fi.Size(),
|
||||||
FileModUnix: fi.ModTime().Unix(),
|
FileModUnix: fi.ModTime().Unix(),
|
||||||
|
VideoWidth: w,
|
||||||
|
VideoHeight: h,
|
||||||
|
FPS: fps,
|
||||||
UpdatedAtUnix: time.Now().Unix(),
|
UpdatedAtUnix: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
buf, err := json.Marshal(m)
|
buf, err := json.Marshal(m)
|
||||||
@ -2370,18 +2970,37 @@ func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64) error {
|
|||||||
return atomicWriteFile(metaPath, buf)
|
return atomicWriteFile(metaPath, buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Neu: /generated/<id>/thumbs.jpg + /generated/<id>/preview.mp4
|
// ✅ Convenience: nur Duration aus meta.json (v2/v1) lesen
|
||||||
|
func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) {
|
||||||
|
d, _, _, _, ok := readVideoMeta(metaPath, fi)
|
||||||
|
return d, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Convenience: Duration-only schreiben (v2), ohne Props (w/h/fps bleiben 0)
|
||||||
|
func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64) error {
|
||||||
|
return writeVideoMetaV2(metaPath, fi, dur, 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedMetaFile(id string) (string, error) {
|
||||||
|
dir, err := generatedDirForID(id) // erzeugt KEIN Verzeichnis
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "meta.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Neu: /generated/meta/<id>/...
|
||||||
func generatedDirForID(id string) (string, error) {
|
func generatedDirForID(id string) (string, error) {
|
||||||
id, err := sanitizeID(id)
|
id, err := sanitizeID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
root, err := generatedRoot()
|
root, err := generatedMetaRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(root) == "" {
|
if strings.TrimSpace(root) == "" {
|
||||||
return "", fmt.Errorf("generated root ist leer")
|
return "", fmt.Errorf("generated meta root ist leer")
|
||||||
}
|
}
|
||||||
return filepath.Join(root, id), nil
|
return filepath.Join(root, id), nil
|
||||||
}
|
}
|
||||||
@ -2414,12 +3033,12 @@ func generatedPreviewFile(id string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ensureGeneratedDirs() error {
|
func ensureGeneratedDirs() error {
|
||||||
root, err := generatedRoot()
|
root, err := generatedMetaRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(root) == "" {
|
if strings.TrimSpace(root) == "" {
|
||||||
return fmt.Errorf("generated root ist leer")
|
return fmt.Errorf("generated meta root ist leer")
|
||||||
}
|
}
|
||||||
return os.MkdirAll(root, 0o755)
|
return os.MkdirAll(root, 0o755)
|
||||||
}
|
}
|
||||||
@ -3174,6 +3793,14 @@ var ffmpegInputTol = []string{
|
|||||||
"-max_error_rate", "1.0",
|
"-max_error_rate", "1.0",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var coverModelStore *ModelStore
|
||||||
|
|
||||||
|
func setCoverModelStore(s *ModelStore) {
|
||||||
|
coverModelStore = s
|
||||||
|
// random seed (einmalig)
|
||||||
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, durSec float64) error {
|
func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, durSec float64) error {
|
||||||
if durSec <= 0 {
|
if durSec <= 0 {
|
||||||
durSec = 8
|
durSec = 8
|
||||||
@ -4646,6 +5273,8 @@ func registerRoutes(mux *http.ServeMux) *ModelStore {
|
|||||||
mux.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
mux.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
||||||
|
|
||||||
mux.HandleFunc("/api/generated/teaser", generatedTeaser)
|
mux.HandleFunc("/api/generated/teaser", generatedTeaser)
|
||||||
|
mux.HandleFunc("/api/generated/cover", generatedCover)
|
||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
mux.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets)
|
mux.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets)
|
||||||
|
|
||||||
@ -4657,6 +5286,8 @@ func registerRoutes(mux *http.ServeMux) *ModelStore {
|
|||||||
fmt.Println("⚠️ models load:", err)
|
fmt.Println("⚠️ models load:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCoverModelStore(store)
|
||||||
|
|
||||||
// ✅ registriert /api/models/list, /parse, /upsert, /flags, /delete
|
// ✅ registriert /api/models/list, /parse, /upsert, /flags, /delete
|
||||||
RegisterModelAPI(mux, store)
|
RegisterModelAPI(mux, store)
|
||||||
|
|
||||||
@ -4684,6 +5315,10 @@ func main() {
|
|||||||
go startMyFreeCamsAutoStartWorker(store)
|
go startMyFreeCamsAutoStartWorker(store)
|
||||||
go startDiskSpaceGuard() // ✅ reagiert auch ohne Frontend
|
go startDiskSpaceGuard() // ✅ reagiert auch ohne Frontend
|
||||||
|
|
||||||
|
if _, err := ensureCoversDir(); err != nil {
|
||||||
|
fmt.Println("⚠️ covers dir:", err)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999")
|
fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999")
|
||||||
if err := http.ListenAndServe(":9999", mux); err != nil {
|
if err := http.ListenAndServe(":9999", mux); err != nil {
|
||||||
fmt.Println("❌ HTTP-Server Fehler:", err)
|
fmt.Println("❌ HTTP-Server Fehler:", err)
|
||||||
@ -4698,40 +5333,9 @@ type RecordRequest struct {
|
|||||||
Hidden bool `json:"hidden,omitempty"`
|
Hidden bool `json:"hidden,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type videoMeta struct {
|
|
||||||
SizeBytes int64 `json:"sizeBytes"`
|
|
||||||
ModTimeUnix int64 `json:"modTimeUnix"`
|
|
||||||
DurationSeconds float64 `json:"durationSeconds"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func readVideoMeta(metaPath string) (videoMeta, bool) {
|
|
||||||
b, err := os.ReadFile(metaPath)
|
|
||||||
if err != nil || len(b) == 0 {
|
|
||||||
return videoMeta{}, false
|
|
||||||
}
|
|
||||||
var m videoMeta
|
|
||||||
if json.Unmarshal(b, &m) != nil {
|
|
||||||
return videoMeta{}, false
|
|
||||||
}
|
|
||||||
if m.SizeBytes <= 0 || m.ModTimeUnix <= 0 || m.DurationSeconds <= 0 {
|
|
||||||
return videoMeta{}, false
|
|
||||||
}
|
|
||||||
return m, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func durationFromMetaIfFresh(videoPath, assetDir string, fi os.FileInfo) (float64, bool) {
|
func durationFromMetaIfFresh(videoPath, assetDir string, fi os.FileInfo) (float64, bool) {
|
||||||
metaPath := filepath.Join(assetDir, "meta.json")
|
metaPath := filepath.Join(assetDir, "meta.json")
|
||||||
m, ok := readVideoMeta(metaPath)
|
return readVideoMetaDuration(metaPath, fi)
|
||||||
if !ok {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
if m.SizeBytes != fi.Size() {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
if m.ModTimeUnix != fi.ModTime().Unix() {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return m.DurationSeconds, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// shared: wird vom HTTP-Handler UND vom Autostart-Worker genutzt
|
// shared: wird vom HTTP-Handler UND vom Autostart-Worker genutzt
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// backend\models.go
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// models_store.go
|
// backend\models_store.go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
Binary file not shown.
1
backend/web/dist/assets/index-CAKbyWZn.css
vendored
1
backend/web/dist/assets/index-CAKbyWZn.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-hlx7oHN0.css
vendored
Normal file
1
backend/web/dist/assets/index-hlx7oHN0.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>App</title>
|
<title>App</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Czq-AJKF.js"></script>
|
<script type="module" crossorigin src="/assets/index-DSZfASIn.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CAKbyWZn.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-hlx7oHN0.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import { SignalIcon, HeartIcon, HandThumbUpIcon, EyeIcon } from '@heroicons/reac
|
|||||||
import PerformanceMonitor from './components/ui/PerformanceMonitor'
|
import PerformanceMonitor from './components/ui/PerformanceMonitor'
|
||||||
import { useNotify } from './components/ui/notify'
|
import { useNotify } from './components/ui/notify'
|
||||||
import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
|
import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
|
||||||
|
import CategoriesTab from './components/ui/CategoriesTab'
|
||||||
|
|
||||||
const COOKIE_STORAGE_KEY = 'record_cookies'
|
const COOKIE_STORAGE_KEY = 'record_cookies'
|
||||||
|
|
||||||
@ -655,6 +656,7 @@ export default function App() {
|
|||||||
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length },
|
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length },
|
||||||
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount },
|
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount },
|
||||||
{ id: 'models', label: 'Models', count: modelsCount },
|
{ id: 'models', label: 'Models', count: modelsCount },
|
||||||
|
{ id: 'categories', label: 'Kategorien' },
|
||||||
{ id: 'settings', label: 'Einstellungen' },
|
{ id: 'settings', label: 'Einstellungen' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1014,6 +1016,20 @@ export default function App() {
|
|||||||
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
|
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
|
||||||
}, [refreshDoneNow])
|
}, [refreshDoneNow])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onNav = (ev: Event) => {
|
||||||
|
const d = (ev as CustomEvent<any>).detail || {}
|
||||||
|
if (d.tab === 'finished') setSelectedTab('finished')
|
||||||
|
if (d.tab === 'categories') setSelectedTab('categories')
|
||||||
|
if (d.tab === 'models') setSelectedTab('models')
|
||||||
|
if (d.tab === 'running') setSelectedTab('running')
|
||||||
|
if (d.tab === 'settings') setSelectedTab('settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('app:navigate-tab', onNav as EventListener)
|
||||||
|
return () => window.removeEventListener('app:navigate-tab', onNav as EventListener)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// ---- Player model sync (wie bei dir) ----
|
// ---- Player model sync (wie bei dir) ----
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playerJob) {
|
if (!playerJob) {
|
||||||
@ -2138,6 +2154,7 @@ export default function App() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedTab === 'models' ? <ModelsTab /> : null}
|
{selectedTab === 'models' ? <ModelsTab /> : null}
|
||||||
|
{selectedTab === 'categories' ? <CategoriesTab /> : null}
|
||||||
{selectedTab === 'settings' ? <RecorderSettings onAssetsGenerated={bumpAssets} /> : null}
|
{selectedTab === 'settings' ? <RecorderSettings onAssetsGenerated={bumpAssets} /> : null}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@ -2165,6 +2182,11 @@ export default function App() {
|
|||||||
runningJobs={runningJobs}
|
runningJobs={runningJobs}
|
||||||
cookies={cookies}
|
cookies={cookies}
|
||||||
blurPreviews={recSettings.blurPreviews}
|
blurPreviews={recSettings.blurPreviews}
|
||||||
|
onToggleHot={handleToggleHot}
|
||||||
|
onDelete={handleDeleteJob}
|
||||||
|
onToggleFavorite={handleToggleFavorite}
|
||||||
|
onToggleLike={handleToggleLike}
|
||||||
|
onToggleWatch={handleToggleWatch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{playerJob ? (
|
{playerJob ? (
|
||||||
|
|||||||
344
frontend/src/components/ui/CategoriesTab.tsx
Normal file
344
frontend/src/components/ui/CategoriesTab.tsx
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
// frontend\src\components\ui\CategoriesTab.tsx
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import Card from './Card'
|
||||||
|
import Button from './Button'
|
||||||
|
import type { StoredModel } from './ModelsTab' // falls nicht exportiert: lokalen Typ verwenden
|
||||||
|
import type { RecordJob } from '../../types'
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
function coverSrc(category: string, token?: number, refresh?: boolean) {
|
||||||
|
const base = `/api/generated/cover?category=${encodeURIComponent(category)}`
|
||||||
|
const v = token ? `&v=${token}` : ''
|
||||||
|
const r = refresh ? `&refresh=1` : ''
|
||||||
|
return `${base}${v}${r}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTags(raw?: string): string[] {
|
||||||
|
if (!raw) return []
|
||||||
|
const tags = raw
|
||||||
|
.split(/[\n,;|]+/g)
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
return Array.from(new Set(tags))
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseName(p: string): string {
|
||||||
|
return String(p || '').replaceAll('\\', '/').split('/').pop() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHotPrefix(name: string): string {
|
||||||
|
return name.toUpperCase().startsWith('HOT ') ? name.slice(4).trim() : name
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID = basename ohne ext, ohne HOT prefix (wie bei dir im Backend)
|
||||||
|
function assetIdFromOutput(output?: string): string {
|
||||||
|
const file = stripHotPrefix(baseName(output || ''))
|
||||||
|
if (!file) return ''
|
||||||
|
return file.replace(/\.[^.]+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelKey aus Dateiname ableiten (wie in App.tsx)
|
||||||
|
const reModel = /^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}/
|
||||||
|
function modelKeyFromFilename(fileOrPath: string): string | null {
|
||||||
|
const file = stripHotPrefix(baseName(fileOrPath))
|
||||||
|
const stem = file.replace(/\.[^.]+$/, '')
|
||||||
|
const m = stem.match(reModel)
|
||||||
|
if (m?.[1]?.trim()) return m[1].trim()
|
||||||
|
const i = stem.lastIndexOf('_')
|
||||||
|
if (i > 0) return stem.slice(0, i).trim()
|
||||||
|
return stem ? stem.trim() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ passt zu Backend: generated/meta/<id>/thumbs.jpg
|
||||||
|
function thumbUrlFromOutput(output: string): string | null {
|
||||||
|
const id = assetIdFromOutput(output)
|
||||||
|
if (!id) return null
|
||||||
|
return `/generated/meta/${encodeURIComponent(id)}/thumbs.jpg`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureCover(category: string, thumbPath: string, refresh: boolean) {
|
||||||
|
const url =
|
||||||
|
`/api/generated/cover?category=${encodeURIComponent(category)}` +
|
||||||
|
`&src=${encodeURIComponent(thumbPath)}` +
|
||||||
|
(refresh ? `&refresh=1` : ``)
|
||||||
|
|
||||||
|
await fetch(url, { method: 'GET', cache: 'no-store' as any })
|
||||||
|
}
|
||||||
|
|
||||||
|
type TagRow = {
|
||||||
|
tag: string // lowercased (so wie du es baust)
|
||||||
|
modelsCount: number
|
||||||
|
downloadsCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoriesTab() {
|
||||||
|
|
||||||
|
const [rows, setRows] = React.useState<TagRow[]>([])
|
||||||
|
const [loading, setLoading] = React.useState(false)
|
||||||
|
const [err, setErr] = React.useState<string | null>(null)
|
||||||
|
const [coverBust, setCoverBust] = React.useState<number>(() => Date.now())
|
||||||
|
|
||||||
|
// TagLower -> outputs[]
|
||||||
|
const candidatesRef = React.useRef<Record<string, string[]>>({})
|
||||||
|
|
||||||
|
const buildCandidates = React.useCallback((models: StoredModel[], doneJobs: RecordJob[]) => {
|
||||||
|
// 1) modelKeyLower -> tagsLower[]
|
||||||
|
const tagsByModelKey: Record<string, string[]> = {}
|
||||||
|
for (const m of Array.isArray(models) ? models : []) {
|
||||||
|
const mk = String((m as any)?.modelKey ?? '').trim().toLowerCase()
|
||||||
|
if (!mk) continue
|
||||||
|
const tags = splitTags((m as any)?.tags).map((t) => t.toLowerCase())
|
||||||
|
if (tags.length) tagsByModelKey[mk] = tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) tagLower -> outputs[]
|
||||||
|
const out: Record<string, string[]> = {}
|
||||||
|
for (const j of Array.isArray(doneJobs) ? doneJobs : []) {
|
||||||
|
const output = String((j as any)?.output ?? '')
|
||||||
|
if (!output) continue
|
||||||
|
|
||||||
|
const mk = (modelKeyFromFilename(output) || '').trim().toLowerCase()
|
||||||
|
if (!mk) continue
|
||||||
|
|
||||||
|
const tags = tagsByModelKey[mk]
|
||||||
|
if (!tags || tags.length === 0) continue
|
||||||
|
|
||||||
|
for (const t of tags) {
|
||||||
|
if (!t) continue
|
||||||
|
if (!out[t]) out[t] = []
|
||||||
|
out[t].push(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// de-dupe pro tag
|
||||||
|
for (const [t, list] of Object.entries(out)) {
|
||||||
|
out[t] = Array.from(new Set(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
candidatesRef.current = out
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const goToFinishedDownloadsWithTag = React.useCallback((tag: string) => {
|
||||||
|
const clean = String(tag || '').trim()
|
||||||
|
if (!clean) return
|
||||||
|
|
||||||
|
// ✅ Filter puffern (falls FinishedDownloads noch nicht gemountet ist)
|
||||||
|
try {
|
||||||
|
localStorage.setItem('finishedDownloads_pendingTags', JSON.stringify([clean]))
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// ✅ Parent soll auf FinishedDownloads tab wechseln
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('app:navigate-tab', {
|
||||||
|
detail: { tab: 'finished' },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// ✅ wenn FinishedDownloads schon gemountet ist, kommt es sofort an
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('finished-downloads:tag-filter', {
|
||||||
|
detail: { tags: [clean], mode: 'replace' },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refresh = React.useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setErr(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Models (für Tags)
|
||||||
|
const models = await apiJSON<StoredModel[]>('/api/models/list', { cache: 'no-store' as any })
|
||||||
|
|
||||||
|
// Done-Jobs (für Downloads pro Tag + optional Seeding)
|
||||||
|
const doneResp = await apiJSON<any>(
|
||||||
|
`/api/record/done?page=1&pageSize=2000&sort=completed_desc`,
|
||||||
|
{ cache: 'no-store' as any }
|
||||||
|
)
|
||||||
|
|
||||||
|
const doneJobs: RecordJob[] = Array.isArray(doneResp?.items)
|
||||||
|
? (doneResp.items as RecordJob[])
|
||||||
|
: Array.isArray(doneResp)
|
||||||
|
? (doneResp as RecordJob[])
|
||||||
|
: []
|
||||||
|
|
||||||
|
buildCandidates(Array.isArray(models) ? models : [], doneJobs)
|
||||||
|
|
||||||
|
// modelsCount pro Tag (über models)
|
||||||
|
const modelCountByTag = new Map<string, number>()
|
||||||
|
for (const m of Array.isArray(models) ? models : []) {
|
||||||
|
for (const t of splitTags((m as any)?.tags)) {
|
||||||
|
const k = t.toLowerCase()
|
||||||
|
modelCountByTag.set(k, (modelCountByTag.get(k) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candMap = candidatesRef.current || {}
|
||||||
|
|
||||||
|
const outRows: TagRow[] = Array.from(modelCountByTag.entries())
|
||||||
|
.map(([tagLower, n]) => ({
|
||||||
|
tag: tagLower,
|
||||||
|
modelsCount: n,
|
||||||
|
downloadsCount: (candMap[tagLower] || []).length,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.tag.localeCompare(b.tag, undefined, { sensitivity: 'base' }))
|
||||||
|
|
||||||
|
setRows(outRows)
|
||||||
|
|
||||||
|
// Optional: Seed Covers (limitiert)
|
||||||
|
const SEED_LIMIT = 60
|
||||||
|
let seeded = 0
|
||||||
|
for (const r of outRows) {
|
||||||
|
if (seeded >= SEED_LIMIT) break
|
||||||
|
const list = candMap[r.tag] || []
|
||||||
|
if (list.length === 0) continue
|
||||||
|
|
||||||
|
const pick = list[Math.floor(Math.random() * list.length)]
|
||||||
|
const thumb = pick ? thumbUrlFromOutput(pick) : null
|
||||||
|
if (!thumb) continue
|
||||||
|
|
||||||
|
seeded++
|
||||||
|
void ensureCover(r.tag, thumb, false).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
setCoverBust(Date.now())
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message ?? String(e))
|
||||||
|
setRows([])
|
||||||
|
candidatesRef.current = {}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [buildCandidates])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void refresh()
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
const renewCovers = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const candMap = candidatesRef.current || {}
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
rows.map(async (r) => {
|
||||||
|
const list = candMap[r.tag] || []
|
||||||
|
const pick = list.length ? list[Math.floor(Math.random() * list.length)] : ''
|
||||||
|
const thumb = pick ? thumbUrlFromOutput(pick) : null
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (thumb) {
|
||||||
|
await ensureCover(r.tag, thumb, true)
|
||||||
|
return { tag: r.tag, ok: true, status: 200, text: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(coverSrc(r.tag, Date.now(), true), {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
const text = !res.ok ? await res.text().catch(() => '') : ''
|
||||||
|
const ok = res.ok || res.status === 404
|
||||||
|
return { tag: r.tag, ok, status: res.status, text }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { tag: r.tag, ok: false, status: 0, text: e?.message ?? String(e) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const failedNo404 = results.filter((x) => !x.ok && x.status !== 404)
|
||||||
|
if (failedNo404.length) {
|
||||||
|
console.warn('Cover renew failed:', failedNo404.slice(0, 20))
|
||||||
|
const sample = failedNo404.slice(0, 8).map((f) => `${f.tag} (${f.status || 'ERR'})`).join(', ')
|
||||||
|
setErr(`Covers fehlgeschlagen: ${failedNo404.length}/${results.length} — z.B.: ${sample}`)
|
||||||
|
} else {
|
||||||
|
setErr(null)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setCoverBust(Date.now())
|
||||||
|
}
|
||||||
|
}, [rows])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
header={
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Kategorien <span className="text-gray-500 dark:text-gray-400">({rows.length})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="secondary" size="md" onClick={renewCovers} disabled={loading}>
|
||||||
|
Cover erneuern
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="md" onClick={refresh} disabled={loading}>
|
||||||
|
Aktualisieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
grayBody
|
||||||
|
>
|
||||||
|
{err ? <div className="text-xs text-red-600 dark:text-red-300">{err}</div> : null}
|
||||||
|
|
||||||
|
{rows.length === 0 && !loading ? (
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-300">Keine Kategorien/Tags gefunden.</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{rows.map((r) => {
|
||||||
|
const img = coverSrc(r.tag, coverBust)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={r.tag}
|
||||||
|
type="button"
|
||||||
|
onClick={() => goToFinishedDownloadsWithTag(r.tag)}
|
||||||
|
className={clsx(
|
||||||
|
'group text-left rounded-xl ring-1 ring-gray-200/70 dark:ring-white/10',
|
||||||
|
'bg-white/70 hover:bg-white dark:bg-white/5 dark:hover:bg-white/10',
|
||||||
|
'overflow-hidden transition',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-indigo-500'
|
||||||
|
)}
|
||||||
|
title="In FinishedDownloads öffnen (Tag-Filter setzen)"
|
||||||
|
>
|
||||||
|
<div className="relative h-24">
|
||||||
|
<img
|
||||||
|
src={img}
|
||||||
|
alt={r.tag}
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-3 py-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold text-gray-900 dark:text-white truncate">{r.tag}</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
{r.downloadsCount} Downloads • {r.modelsCount} Models
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="shrink-0 rounded-md bg-gray-100 px-2 py-1 text-xs font-semibold text-gray-900 dark:bg-white/10 dark:text-white tabular-nums">
|
||||||
|
{r.downloadsCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -317,6 +317,27 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const clearTagFilter = useCallback(() => setTagFilter([]), [])
|
const clearTagFilter = useCallback(() => setTagFilter([]), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// ✅ Wenn wir aus CategoriesTab kommen, liegt evtl. ein "pending" Filter in localStorage
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('finishedDownloads_pendingTags')
|
||||||
|
if (!raw) return
|
||||||
|
|
||||||
|
const arr = JSON.parse(raw)
|
||||||
|
const tags = Array.isArray(arr) ? arr.map((t) => String(t || '').trim()).filter(Boolean) : []
|
||||||
|
if (tags.length === 0) return
|
||||||
|
|
||||||
|
flushSync(() => setTagFilter(tags))
|
||||||
|
if (page !== 1) onPageChange(1)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem('finishedDownloads_pendingTags')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, []) // nur einmal beim Mount
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!globalFilterActive) return
|
if (!globalFilterActive) return
|
||||||
|
|
||||||
@ -1330,7 +1351,7 @@ export default function FinishedDownloads({
|
|||||||
/>
|
/>
|
||||||
{(searchQuery || '').trim() !== '' ? (
|
{(searchQuery || '').trim() !== '' ? (
|
||||||
<Button size="sm" variant="soft" onClick={clearSearch}>
|
<Button size="sm" variant="soft" onClick={clearSearch}>
|
||||||
Clear
|
Leeren
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type { RecordJob } from '../../types'
|
|||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import TagBadge from './TagBadge'
|
import TagBadge from './TagBadge'
|
||||||
|
import RecordJobActions from './RecordJobActions'
|
||||||
import {
|
import {
|
||||||
ArrowTopRightOnSquareIcon,
|
ArrowTopRightOnSquareIcon,
|
||||||
CalendarDaysIcon,
|
CalendarDaysIcon,
|
||||||
@ -28,7 +29,6 @@ import {
|
|||||||
EyeIcon as EyeSolidIcon,
|
EyeIcon as EyeSolidIcon,
|
||||||
} from '@heroicons/react/24/solid'
|
} from '@heroicons/react/24/solid'
|
||||||
|
|
||||||
|
|
||||||
function cn(...parts: Array<string | false | null | undefined>) {
|
function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
return parts.filter(Boolean).join(' ')
|
return parts.filter(Boolean).join(' ')
|
||||||
}
|
}
|
||||||
@ -86,11 +86,6 @@ function splitTags(v?: string | null) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isKeptOutputPath(output: string) {
|
|
||||||
const s = output.toLowerCase()
|
|
||||||
return s.includes('/keep/') || s.includes('\\keep\\')
|
|
||||||
}
|
|
||||||
|
|
||||||
function baseName(path: string) {
|
function baseName(path: string) {
|
||||||
return (path || '').split(/[\\/]/).pop() || ''
|
return (path || '').split(/[\\/]/).pop() || ''
|
||||||
}
|
}
|
||||||
@ -110,6 +105,21 @@ function stripHotPrefix(name: string) {
|
|||||||
return name.startsWith('HOT ') ? name.slice(4) : name
|
return name.startsWith('HOT ') ? name.slice(4) : name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHotName(name: string) {
|
||||||
|
return String(name || '').startsWith('HOT ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceBaseName(path: string, newBase: string) {
|
||||||
|
const p = String(path || '')
|
||||||
|
if (!p) return p
|
||||||
|
// ersetzt nur das letzte Segment (funktioniert für / und \)
|
||||||
|
return p.replace(/([\\/])[^\\/]*$/, `$1${newBase}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHotFileName(file: string) {
|
||||||
|
return isHotName(file) ? stripHotPrefix(file) : `HOT ${file}`
|
||||||
|
}
|
||||||
|
|
||||||
function modelNameFromOutput(output?: string) {
|
function modelNameFromOutput(output?: string) {
|
||||||
const fileRaw = baseName(output || '')
|
const fileRaw = baseName(output || '')
|
||||||
const file = stripHotPrefix(fileRaw)
|
const file = stripHotPrefix(fileRaw)
|
||||||
@ -279,6 +289,12 @@ type Props = {
|
|||||||
cookies?: Record<string, string>
|
cookies?: Record<string, string>
|
||||||
runningJobs?: RecordJob[]
|
runningJobs?: RecordJob[]
|
||||||
blurPreviews?: boolean
|
blurPreviews?: boolean
|
||||||
|
|
||||||
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onToggleHot?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onDelete?: (job: RecordJob) => void | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeModelKey(raw: string | null | undefined): string {
|
function normalizeModelKey(raw: string | null | undefined): string {
|
||||||
@ -326,7 +342,21 @@ function buildChaturbateCookieHeader(cookies?: Record<string, string>): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, cookies, runningJobs, blurPreviews }: Props) {
|
export default function ModelDetails({
|
||||||
|
open,
|
||||||
|
modelKey,
|
||||||
|
onClose,
|
||||||
|
onOpenPlayer,
|
||||||
|
cookies,
|
||||||
|
runningJobs,
|
||||||
|
blurPreviews,
|
||||||
|
|
||||||
|
onToggleWatch,
|
||||||
|
onToggleFavorite,
|
||||||
|
onToggleLike,
|
||||||
|
onToggleHot,
|
||||||
|
onDelete,
|
||||||
|
}: Props) {
|
||||||
|
|
||||||
const [models, setModels] = React.useState<StoredModel[]>([])
|
const [models, setModels] = React.useState<StoredModel[]>([])
|
||||||
const [modelsLoading, setModelsLoading] = React.useState(false)
|
const [modelsLoading, setModelsLoading] = React.useState(false)
|
||||||
@ -349,6 +379,42 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
|||||||
|
|
||||||
const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null)
|
const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null)
|
||||||
|
|
||||||
|
const refetchModels = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/models/list', { cache: 'no-store' })
|
||||||
|
const data = (await r.json().catch(() => null)) as any
|
||||||
|
setModels(Array.isArray(data) ? data : [])
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refetchDone = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const url = `/api/record/done?all=1&sort=completed_desc&includeKeep=1`
|
||||||
|
const r = await fetch(url, { cache: 'no-store' })
|
||||||
|
const data = await r.json().catch(() => null)
|
||||||
|
const items = Array.isArray(data)
|
||||||
|
? (data as RecordJob[])
|
||||||
|
: Array.isArray((data as any)?.items)
|
||||||
|
? ((data as any).items as RecordJob[])
|
||||||
|
: []
|
||||||
|
setDone(items)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// erzeugt ein "Job"-Objekt, das für deine Toggle-Handler reicht
|
||||||
|
function jobFromModelKey(key: string): RecordJob {
|
||||||
|
// muss zum Regex in App.tsx passen: <model>_MM_DD_YYYY__HH-MM-SS.ext
|
||||||
|
return {
|
||||||
|
id: `model:${key}`,
|
||||||
|
output: `${key}_01_01_2000__00-00-00.mp4`,
|
||||||
|
status: 'finished',
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
|
||||||
const openImage = React.useCallback((src?: string | null, alt?: string) => {
|
const openImage = React.useCallback((src?: string | null, alt?: string) => {
|
||||||
const s = String(src ?? '').trim()
|
const s = String(src ?? '').trim()
|
||||||
if (!s) return
|
if (!s) return
|
||||||
@ -638,6 +704,105 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleToggleHot = React.useCallback(
|
||||||
|
async (job: RecordJob) => {
|
||||||
|
const out = job.output || ''
|
||||||
|
const oldFile = baseName(out)
|
||||||
|
if (!oldFile) {
|
||||||
|
await onToggleHot?.(job)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFile = toggleHotFileName(oldFile)
|
||||||
|
|
||||||
|
// ✅ 1) UI sofort updaten (optimistisch)
|
||||||
|
setDone((prev) =>
|
||||||
|
prev.map((j) => {
|
||||||
|
const f = baseName(j.output || '')
|
||||||
|
if (f !== oldFile) return j
|
||||||
|
return { ...j, output: replaceBaseName(j.output || '', newFile) }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// ✅ 2) Backend/App Handler
|
||||||
|
await onToggleHot?.(job)
|
||||||
|
|
||||||
|
// ✅ 3) Server-Truth nachziehen (falls Backend anders renamed)
|
||||||
|
refetchDone()
|
||||||
|
},
|
||||||
|
[onToggleHot, refetchDone]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDelete = React.useCallback(
|
||||||
|
async (job: RecordJob) => {
|
||||||
|
const out = job.output || ''
|
||||||
|
const file = baseName(out)
|
||||||
|
|
||||||
|
// ✅ UI sofort: raus aus Liste
|
||||||
|
if (file) {
|
||||||
|
setDone((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
||||||
|
}
|
||||||
|
|
||||||
|
await onDelete?.(job)
|
||||||
|
refetchDone()
|
||||||
|
},
|
||||||
|
[onDelete, refetchDone]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleToggleFavoriteModel = React.useCallback(async () => {
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
// ✅ UI sofort (optimistisch)
|
||||||
|
setModels((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
(m.modelKey || '').toLowerCase() === key
|
||||||
|
? { ...m, favorite: !Boolean(m.favorite) }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ✅ Backend/App Handler
|
||||||
|
await onToggleFavorite?.(jobFromModelKey(key))
|
||||||
|
|
||||||
|
// ✅ Server-Truth nachziehen
|
||||||
|
refetchModels()
|
||||||
|
}, [key, onToggleFavorite, refetchModels])
|
||||||
|
|
||||||
|
const handleToggleLikeModel = React.useCallback(async () => {
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
// liked ist bei dir boolean | null -> wir togglen "true <-> false"
|
||||||
|
setModels((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
(m.modelKey || '').toLowerCase() === key
|
||||||
|
? { ...m, liked: m.liked === true ? false : true }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await onToggleLike?.(jobFromModelKey(key))
|
||||||
|
refetchModels()
|
||||||
|
}, [key, onToggleLike, refetchModels])
|
||||||
|
|
||||||
|
const handleToggleWatchModel = React.useCallback(async () => {
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
// ✅ UI sofort (optimistisch)
|
||||||
|
setModels((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
(m.modelKey || '').toLowerCase() === key
|
||||||
|
? { ...m, watching: !Boolean(m.watching) }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ✅ Backend/App Handler
|
||||||
|
await onToggleWatch?.(jobFromModelKey(key))
|
||||||
|
|
||||||
|
// ✅ Server-Truth nachziehen
|
||||||
|
refetchModels()
|
||||||
|
}, [key, onToggleWatch, refetchModels])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
@ -757,14 +922,23 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
|||||||
{/* Local flags icons (unten rechts im Hero) */}
|
{/* Local flags icons (unten rechts im Hero) */}
|
||||||
<div className="absolute bottom-3 right-3 flex items-center gap-2">
|
<div className="absolute bottom-3 right-3 flex items-center gap-2">
|
||||||
{/* Favorite = Star */}
|
{/* Favorite = Star */}
|
||||||
<span
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
handleToggleFavoriteModel()
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center rounded-full p-2 ring-1 ring-inset backdrop-blur',
|
'inline-flex items-center justify-center rounded-full p-2 ring-1 ring-inset backdrop-blur',
|
||||||
|
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
|
||||||
model?.favorite
|
model?.favorite
|
||||||
? 'bg-amber-500/25 ring-amber-200/30'
|
? 'bg-amber-500/25 ring-amber-200/30'
|
||||||
: 'bg-black/20 ring-white/15'
|
: 'bg-black/20 ring-white/15'
|
||||||
)}
|
)}
|
||||||
title={model?.favorite ? 'Favorit' : 'Nicht favorisiert'}
|
title={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||||
|
aria-pressed={Boolean(model?.favorite)}
|
||||||
|
aria-label={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||||
>
|
>
|
||||||
<span className="relative inline-block size-4">
|
<span className="relative inline-block size-4">
|
||||||
<StarOutlineIcon
|
<StarOutlineIcon
|
||||||
@ -782,17 +956,26 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</button>
|
||||||
|
|
||||||
{/* Like = Heart */}
|
{/* Like = Heart */}
|
||||||
<span
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
handleToggleLikeModel()
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center rounded-full p-2 ring-1 ring-inset backdrop-blur',
|
'inline-flex items-center justify-center rounded-full p-2 ring-1 ring-inset backdrop-blur',
|
||||||
|
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
|
||||||
model?.liked
|
model?.liked
|
||||||
? 'bg-rose-500/25 ring-rose-200/30'
|
? 'bg-rose-500/25 ring-rose-200/30'
|
||||||
: 'bg-black/20 ring-white/15'
|
: 'bg-black/20 ring-white/15'
|
||||||
)}
|
)}
|
||||||
title={model?.liked ? 'Gefällt mir' : 'Nicht geliked'}
|
title={model?.liked ? 'Like entfernen' : 'Liken'}
|
||||||
|
aria-pressed={model?.liked === true}
|
||||||
|
aria-label={model?.liked ? 'Like entfernen' : 'Liken'}
|
||||||
>
|
>
|
||||||
<span className="relative inline-block size-4">
|
<span className="relative inline-block size-4">
|
||||||
<HeartOutlineIcon
|
<HeartOutlineIcon
|
||||||
@ -810,17 +993,26 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</button>
|
||||||
|
|
||||||
{/* Watched = Eye */}
|
{/* Watched = Eye */}
|
||||||
<span
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
handleToggleWatchModel()
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center rounded-full p-2 ring-1 ring-inset backdrop-blur',
|
'inline-flex items-center justify-center rounded-full p-2 ring-1 ring-inset backdrop-blur',
|
||||||
|
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
|
||||||
model?.watching
|
model?.watching
|
||||||
? 'bg-sky-500/25 ring-sky-200/30'
|
? 'bg-sky-500/25 ring-sky-200/30'
|
||||||
: 'bg-black/20 ring-white/15'
|
: 'bg-black/20 ring-white/15'
|
||||||
)}
|
)}
|
||||||
title={model?.watching ? 'Watched' : 'Nicht auf Watch'}
|
title={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
|
||||||
|
aria-pressed={Boolean(model?.watching)}
|
||||||
|
aria-label={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
|
||||||
>
|
>
|
||||||
<span className="relative inline-block size-4">
|
<span className="relative inline-block size-4">
|
||||||
<EyeOutlineIcon
|
<EyeOutlineIcon
|
||||||
@ -838,7 +1030,7 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1196,7 +1388,6 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
|||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{doneMatchesPage.map((j) => {
|
{doneMatchesPage.map((j) => {
|
||||||
const file = baseName(j.output || '')
|
const file = baseName(j.output || '')
|
||||||
const kept = isKeptOutputPath(j.output || '')
|
|
||||||
const hot = file.startsWith('HOT ')
|
const hot = file.startsWith('HOT ')
|
||||||
const ended = j.endedAt ? fmtDateTime(j.endedAt as any) : '—'
|
const ended = j.endedAt ? fmtDateTime(j.endedAt as any) : '—'
|
||||||
return (
|
return (
|
||||||
@ -1218,7 +1409,7 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<div className="min-w-0 flex-1 truncate text-sm font-medium text-gray-900 dark:text-white">
|
<div className="min-w-0 flex-1 truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||||
{file || '—'}
|
{stripHotPrefix(file) || '—'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hot ? (
|
{hot ? (
|
||||||
@ -1226,16 +1417,10 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
|||||||
HOT
|
HOT
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{kept ? (
|
|
||||||
<span className={pill('shrink-0 bg-indigo-500/10 text-indigo-900 ring-indigo-200 dark:text-indigo-200 dark:ring-indigo-400/20')}>
|
|
||||||
KEEP
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-600 dark:text-gray-300">
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
<span>
|
<span>
|
||||||
Ende: <span className="font-medium text-gray-900 dark:text-white">{ended}</span>
|
Datum: <span className="font-medium text-gray-900 dark:text-white">{ended}</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Dauer:{' '}
|
Dauer:{' '}
|
||||||
@ -1252,11 +1437,28 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{onOpenPlayer ? (
|
<div className="shrink-0 flex items-center gap-2">
|
||||||
<span className="hidden shrink-0 text-xs font-medium text-indigo-600 opacity-0 transition group-hover:opacity-100 dark:text-indigo-400 sm:inline">
|
{/* Record actions (Details etc.) */}
|
||||||
Öffnen
|
<span
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<RecordJobActions
|
||||||
|
job={j}
|
||||||
|
variant="table"
|
||||||
|
// ✅ Status an RecordJobActions geben (damit Icons korrekt aussehen)
|
||||||
|
isHot={hot} // hot hast du oben schon: const hot = file.startsWith('HOT ')
|
||||||
|
isFavorite={Boolean(model?.favorite)}
|
||||||
|
isLiked={model?.liked === true}
|
||||||
|
isWatching={Boolean(model?.watching)}
|
||||||
|
onToggleHot={handleToggleHot}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
order={['hot', 'delete']}
|
||||||
|
className="flex items-center"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -105,6 +105,31 @@ function canonicalHost(raw?: string): string {
|
|||||||
.replace(/^www\./, '')
|
.replace(/^www\./, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function baseNameFromPath(p: string): string {
|
||||||
|
const n = String(p || '').replaceAll('\\', '/')
|
||||||
|
const parts = n.split('/')
|
||||||
|
return parts[parts.length - 1] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHotPrefixLocal(file: string): string {
|
||||||
|
const f = String(file || '')
|
||||||
|
return f.toUpperCase().startsWith('HOT ') ? f.slice(4).trim() : f
|
||||||
|
}
|
||||||
|
|
||||||
|
// exakt wie in FinishedDownloads / backend: <modelKey>_MM_DD_YYYY__HH-MM-SS.ext
|
||||||
|
function modelKeyFromOutput(output?: string): string {
|
||||||
|
const fileRaw = baseNameFromPath(output || '')
|
||||||
|
const file = stripHotPrefixLocal(fileRaw)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
function modelHref(m: StoredModel): string | null {
|
function modelHref(m: StoredModel): string | null {
|
||||||
// 1) Wenn Backend eine echte URL gespeichert hat
|
// 1) Wenn Backend eine echte URL gespeichert hat
|
||||||
if (m.isUrl && /^https?:\/\//i.test(String(m.input ?? ''))) {
|
if (m.isUrl && /^https?:\/\//i.test(String(m.input ?? ''))) {
|
||||||
@ -167,6 +192,14 @@ function IconToggle({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSortValue(v: any): { isNull: boolean; kind: 'number' | 'string'; value: number | string } {
|
||||||
|
if (v === null || v === undefined) return { isNull: true, kind: 'string', value: '' }
|
||||||
|
if (v instanceof Date) return { isNull: false, kind: 'number', value: v.getTime() }
|
||||||
|
if (typeof v === 'number') return { isNull: false, kind: 'number', value: v }
|
||||||
|
if (typeof v === 'boolean') return { isNull: false, kind: 'number', value: v ? 1 : 0 }
|
||||||
|
if (typeof v === 'bigint') return { isNull: false, kind: 'number', value: Number(v) }
|
||||||
|
return { isNull: false, kind: 'string', value: String(v).toLocaleLowerCase() }
|
||||||
|
}
|
||||||
|
|
||||||
export default function ModelsTab() {
|
export default function ModelsTab() {
|
||||||
const [models, setModels] = React.useState<StoredModel[]>([])
|
const [models, setModels] = React.useState<StoredModel[]>([])
|
||||||
@ -183,6 +216,46 @@ export default function ModelsTab() {
|
|||||||
// 🏷️ Tag-Filter (klickbar)
|
// 🏷️ Tag-Filter (klickbar)
|
||||||
const [tagFilter, setTagFilter] = React.useState<string[]>([])
|
const [tagFilter, setTagFilter] = React.useState<string[]>([])
|
||||||
|
|
||||||
|
const [videoCounts, setVideoCounts] = React.useState<Record<string, number>>({})
|
||||||
|
const [videoCountsLoading, setVideoCountsLoading] = React.useState(false)
|
||||||
|
|
||||||
|
// 🔽 Table sorting (global über alle filtered Einträge)
|
||||||
|
const [sort, setSort] = React.useState<{ key: string; direction: 'asc' | 'desc' } | null>({
|
||||||
|
key: 'videos',
|
||||||
|
direction: 'desc',
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshVideoCounts = React.useCallback(async () => {
|
||||||
|
setVideoCountsLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/record/done?all=1&includeKeep=1', { cache: 'no-store' as any })
|
||||||
|
if (!res.ok) return
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
|
||||||
|
// dein /done liefert { items, count }, aber wir sind tolerant:
|
||||||
|
const items: RecordJob[] = Array.isArray(data?.items)
|
||||||
|
? (data.items as RecordJob[])
|
||||||
|
: Array.isArray(data)
|
||||||
|
? (data as RecordJob[])
|
||||||
|
: []
|
||||||
|
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
|
||||||
|
for (const j of items) {
|
||||||
|
const mk = modelKeyFromOutput(j.output)
|
||||||
|
if (!mk || mk === '—') continue
|
||||||
|
const k = mk.trim().toLowerCase()
|
||||||
|
if (!k) continue
|
||||||
|
counts[k] = (counts[k] ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
setVideoCounts(counts)
|
||||||
|
} finally {
|
||||||
|
setVideoCountsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const activeTagSet = React.useMemo(() => {
|
const activeTagSet = React.useMemo(() => {
|
||||||
return new Set(tagFilter.map((t) => t.toLowerCase()))
|
return new Set(tagFilter.map((t) => t.toLowerCase()))
|
||||||
}, [tagFilter])
|
}, [tagFilter])
|
||||||
@ -197,6 +270,17 @@ export default function ModelsTab() {
|
|||||||
|
|
||||||
const clearTagFilter = React.useCallback(() => setTagFilter([]), [])
|
const clearTagFilter = React.useCallback(() => setTagFilter([]), [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onSet = (ev: Event) => {
|
||||||
|
const e = ev as CustomEvent<{ tags?: string[] }>
|
||||||
|
const tags = Array.isArray(e.detail?.tags) ? e.detail.tags : []
|
||||||
|
setTagFilter(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('models:set-tag-filter', onSet as any)
|
||||||
|
return () => window.removeEventListener('models:set-tag-filter', onSet as any)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const [input, setInput] = React.useState('')
|
const [input, setInput] = React.useState('')
|
||||||
const [parsed, setParsed] = React.useState<ParsedModel | null>(null)
|
const [parsed, setParsed] = React.useState<ParsedModel | null>(null)
|
||||||
const [parseError, setParseError] = React.useState<string | null>(null)
|
const [parseError, setParseError] = React.useState<string | null>(null)
|
||||||
@ -258,12 +342,17 @@ export default function ModelsTab() {
|
|||||||
try {
|
try {
|
||||||
const list = await apiJSON<StoredModel[]>('/api/models/list', { cache: 'no-store' })
|
const list = await apiJSON<StoredModel[]>('/api/models/list', { cache: 'no-store' })
|
||||||
setModels(Array.isArray(list) ? list : [])
|
setModels(Array.isArray(list) ? list : [])
|
||||||
|
void refreshVideoCounts() // ✅ Counts parallel aktualisieren
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(e?.message ?? String(e))
|
setErr(e?.message ?? String(e))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [refreshVideoCounts])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void refreshVideoCounts()
|
||||||
|
}, [refreshVideoCounts])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void refresh()
|
void refresh()
|
||||||
@ -350,6 +439,200 @@ export default function ModelsTab() {
|
|||||||
|
|
||||||
const deferredQ = React.useDeferredValue(q)
|
const deferredQ = React.useDeferredValue(q)
|
||||||
|
|
||||||
|
const columns = React.useMemo<Column<StoredModel>[]>(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'statusAll',
|
||||||
|
header: 'Status',
|
||||||
|
align: 'center',
|
||||||
|
cell: (m) => {
|
||||||
|
const liked = m.liked === true
|
||||||
|
const fav = m.favorite === true
|
||||||
|
const watch = m.watching === true
|
||||||
|
|
||||||
|
const hideUntilHover = !watch && !fav && !liked
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group flex items-center justify-center gap-1 w-[110px]">
|
||||||
|
{/* Beobachten */}
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
hideUntilHover && !watch
|
||||||
|
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
||||||
|
: 'opacity-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant={watch ? 'soft' : 'secondary'}
|
||||||
|
size="xs"
|
||||||
|
className={clsx(
|
||||||
|
'px-2 py-1 leading-none',
|
||||||
|
!watch && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
|
||||||
|
)}
|
||||||
|
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
patch(m.id, { watched: !watch })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'text-base leading-none',
|
||||||
|
watch ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
👁
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Favorit */}
|
||||||
|
<IconToggle
|
||||||
|
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||||
|
active={fav}
|
||||||
|
hiddenUntilHover={hideUntilHover}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (fav) patch(m.id, { favorite: false })
|
||||||
|
else patch(m.id, { favorite: true, liked: false })
|
||||||
|
}}
|
||||||
|
icon={<span className={fav ? 'text-amber-500' : 'text-gray-400 dark:text-gray-500'}>★</span>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Gefällt mir */}
|
||||||
|
<IconToggle
|
||||||
|
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
|
||||||
|
active={liked}
|
||||||
|
hiddenUntilHover={hideUntilHover}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (liked) patch(m.id, { liked: false })
|
||||||
|
else patch(m.id, { liked: true, favorite: false })
|
||||||
|
}}
|
||||||
|
icon={<span className={liked ? 'text-rose-500' : 'text-gray-400 dark:text-gray-500'}>♥</span>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'model',
|
||||||
|
header: 'Model',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (m) => (m.modelKey || '').toLowerCase(),
|
||||||
|
cell: (m) => {
|
||||||
|
const href = modelHref(m)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="font-medium truncate">{m.modelKey}</div>
|
||||||
|
|
||||||
|
{/* kleiner Link-Indikator (damit klar ist: Klick öffnet) */}
|
||||||
|
{href ? (
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-xs text-gray-400 dark:text-gray-500"
|
||||||
|
title={href}
|
||||||
|
onClick={(e) => {
|
||||||
|
// nicht row-click doppeln
|
||||||
|
e.stopPropagation()
|
||||||
|
window.open(href, '_blank', 'noreferrer')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↗
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{m.host ?? '—'}</div>
|
||||||
|
|
||||||
|
{/* URL optional als dritte Zeile, aber kurz + trunc */}
|
||||||
|
{href ? (
|
||||||
|
<div className="text-xs text-indigo-600 dark:text-indigo-400 truncate max-w-[520px]">
|
||||||
|
{href}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'videos',
|
||||||
|
header: 'Videos',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (m) => {
|
||||||
|
const k = String(m.modelKey || '').trim().toLowerCase()
|
||||||
|
const n = videoCounts[k]
|
||||||
|
return typeof n === 'number' ? n : 0
|
||||||
|
},
|
||||||
|
align: 'right',
|
||||||
|
cell: (m) => {
|
||||||
|
const k = String(m.modelKey || '').trim().toLowerCase()
|
||||||
|
const n = videoCounts[k]
|
||||||
|
return (
|
||||||
|
<span className="tabular-nums text-sm text-gray-900 dark:text-white w-[64px] inline-block text-right">
|
||||||
|
{typeof n === 'number' ? n : videoCountsLoading ? '…' : 0}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'tags',
|
||||||
|
header: 'Tags',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (m) => splitTags(m.tags).length,
|
||||||
|
cell: (m) => {
|
||||||
|
const tags = splitTags(m.tags)
|
||||||
|
const shown = tags.slice(0, 6)
|
||||||
|
const rest = tags.length - shown.length
|
||||||
|
const full = tags.join(', ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 max-w-[520px]" title={full || undefined}>
|
||||||
|
{m.hot ? badge(true, '🔥 HOT') : null}
|
||||||
|
{m.keep ? badge(true, '📌 Behalten') : null}
|
||||||
|
|
||||||
|
{shown.map((t) => (
|
||||||
|
<TagBadge
|
||||||
|
key={t}
|
||||||
|
tag={t}
|
||||||
|
title={t}
|
||||||
|
active={activeTagSet.has(t.toLowerCase())}
|
||||||
|
onClick={toggleTagFilter}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{rest > 0 ? <TagBadge title={full}>+{rest}</TagBadge> : null}
|
||||||
|
|
||||||
|
{!m.hot && !m.keep && tags.length === 0 ? (
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500">—</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
align: 'right',
|
||||||
|
cell: (m) => (
|
||||||
|
<div className="flex justify-end w-[56px]">
|
||||||
|
<RecordJobActions
|
||||||
|
job={jobForDetails(m.modelKey)}
|
||||||
|
variant="table"
|
||||||
|
order={['details']}
|
||||||
|
className="flex items-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [activeTagSet, toggleTagFilter, videoCounts, videoCountsLoading])
|
||||||
|
|
||||||
const filtered = React.useMemo(() => {
|
const filtered = React.useMemo(() => {
|
||||||
const needle = deferredQ.trim().toLowerCase()
|
const needle = deferredQ.trim().toLowerCase()
|
||||||
|
|
||||||
@ -372,11 +655,46 @@ export default function ModelsTab() {
|
|||||||
})
|
})
|
||||||
}, [models, modelsWithHay, deferredQ, activeTagSet])
|
}, [models, modelsWithHay, deferredQ, activeTagSet])
|
||||||
|
|
||||||
|
const sortedAll = React.useMemo(() => {
|
||||||
|
if (!sort) return filtered
|
||||||
|
|
||||||
|
const col = columns.find((c) => c.key === sort.key)
|
||||||
|
if (!col) return filtered
|
||||||
|
|
||||||
|
const dirMul = sort.direction === 'asc' ? 1 : -1
|
||||||
|
|
||||||
|
const decorated = filtered.map((r, i) => ({ r, i }))
|
||||||
|
decorated.sort((x, y) => {
|
||||||
|
let res = 0
|
||||||
|
|
||||||
|
if (col.sortFn) {
|
||||||
|
res = col.sortFn(x.r, y.r)
|
||||||
|
} else {
|
||||||
|
const ax = col.sortValue ? col.sortValue(x.r) : (x.r as any)?.[col.key]
|
||||||
|
const by = col.sortValue ? col.sortValue(y.r) : (y.r as any)?.[col.key]
|
||||||
|
|
||||||
|
const na = normalizeSortValue(ax)
|
||||||
|
const nb = normalizeSortValue(by)
|
||||||
|
|
||||||
|
// nulls immer nach hinten
|
||||||
|
if (na.isNull && !nb.isNull) res = 1
|
||||||
|
else if (!na.isNull && nb.isNull) res = -1
|
||||||
|
else if (na.kind === 'number' && nb.kind === 'number') res = na.value < nb.value ? -1 : na.value > nb.value ? 1 : 0
|
||||||
|
else res = String(na.value).localeCompare(String(nb.value), undefined, { numeric: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res === 0) return x.i - y.i // stable
|
||||||
|
return res * dirMul
|
||||||
|
})
|
||||||
|
|
||||||
|
return decorated.map((d) => d.r)
|
||||||
|
}, [filtered, sort, columns])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}, [q, tagFilter])
|
}, [q, tagFilter])
|
||||||
|
|
||||||
const totalItems = filtered.length
|
const totalItems = sortedAll.length
|
||||||
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
|
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -385,8 +703,8 @@ export default function ModelsTab() {
|
|||||||
|
|
||||||
const pageRows = React.useMemo(() => {
|
const pageRows = React.useMemo(() => {
|
||||||
const start = (page - 1) * pageSize
|
const start = (page - 1) * pageSize
|
||||||
return filtered.slice(start, start + pageSize)
|
return sortedAll.slice(start, start + pageSize)
|
||||||
}, [filtered, page, pageSize])
|
}, [sortedAll, page, pageSize])
|
||||||
|
|
||||||
const upsertFromParsed = async () => {
|
const upsertFromParsed = async () => {
|
||||||
if (!parsed) return
|
if (!parsed) return
|
||||||
@ -417,7 +735,7 @@ export default function ModelsTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const patch = async (id: string, body: any) => {
|
async function patch(id: string, body: any) {
|
||||||
setErr(null)
|
setErr(null)
|
||||||
|
|
||||||
// ✅ In-flight guard
|
// ✅ In-flight guard
|
||||||
@ -498,172 +816,6 @@ export default function ModelsTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = React.useMemo<Column<StoredModel>[]>(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: 'statusAll',
|
|
||||||
header: 'Status',
|
|
||||||
align: 'center',
|
|
||||||
cell: (m) => {
|
|
||||||
const liked = m.liked === true
|
|
||||||
const fav = m.favorite === true
|
|
||||||
const watch = m.watching === true
|
|
||||||
|
|
||||||
// wenn gar nichts aktiv ist -> wirklich leer, Icons erst bei Hover
|
|
||||||
const hideUntilHover = !watch && !fav && !liked
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="group flex items-center justify-center gap-1">
|
|
||||||
{/* Beobachten */}
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
hideUntilHover && !watch
|
|
||||||
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
|
||||||
: 'opacity-100'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant={watch ? 'soft' : 'secondary'}
|
|
||||||
size="xs"
|
|
||||||
className={clsx(
|
|
||||||
'px-2 py-1 leading-none',
|
|
||||||
!watch && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
|
|
||||||
)}
|
|
||||||
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
patch(m.id, { watched: !watch })
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className={clsx('text-base leading-none', watch ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500')}>
|
|
||||||
👁
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Favorit */}
|
|
||||||
<IconToggle
|
|
||||||
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
|
||||||
active={fav}
|
|
||||||
hiddenUntilHover={hideUntilHover}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (fav) {
|
|
||||||
patch(m.id, { favorite: false })
|
|
||||||
} else {
|
|
||||||
// exklusiv: Favorit setzt ♥ zurück
|
|
||||||
patch(m.id, { favorite: true, liked: false })
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
icon={<span className={fav ? 'text-amber-500' : 'text-gray-400 dark:text-gray-500'}>★</span>}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gefällt mir */}
|
|
||||||
<IconToggle
|
|
||||||
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
|
|
||||||
active={liked}
|
|
||||||
hiddenUntilHover={hideUntilHover}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (liked) {
|
|
||||||
patch(m.id, { liked: false })
|
|
||||||
} else {
|
|
||||||
// exklusiv: ♥ setzt Favorit zurück
|
|
||||||
patch(m.id, { liked: true, favorite: false })
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
icon={<span className={liked ? 'text-rose-500' : 'text-gray-400 dark:text-gray-500'}>♥</span>}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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) => {
|
|
||||||
const href = modelHref(m)
|
|
||||||
const label = href ?? (m.isUrl ? (m.input || '—') : '—')
|
|
||||||
|
|
||||||
if (!href) {
|
|
||||||
return <span className="text-gray-400 dark:text-gray-500">—</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-indigo-600 dark:text-indigo-400 hover:underline truncate block max-w-[520px]"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
title={href}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'tags',
|
|
||||||
header: 'Tags',
|
|
||||||
cell: (m) => {
|
|
||||||
const tags = splitTags(m.tags)
|
|
||||||
const shown = tags.slice(0, 6)
|
|
||||||
const rest = tags.length - shown.length
|
|
||||||
const full = tags.join(', ')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap gap-2" title={full || undefined}>
|
|
||||||
{m.hot ? badge(true, '🔥 HOT') : null}
|
|
||||||
{m.keep ? badge(true, '📌 Behalten') : null}
|
|
||||||
|
|
||||||
{shown.map((t) => (
|
|
||||||
<TagBadge
|
|
||||||
key={t}
|
|
||||||
tag={t}
|
|
||||||
title={t}
|
|
||||||
active={activeTagSet.has(t.toLowerCase())}
|
|
||||||
onClick={toggleTagFilter}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{rest > 0 ? <TagBadge title={full}>+{rest}</TagBadge> : null}
|
|
||||||
|
|
||||||
{!m.hot && !m.keep && tags.length === 0 ? (
|
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500">—</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: '',
|
|
||||||
align: 'right',
|
|
||||||
cell: (m) => (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<RecordJobActions
|
|
||||||
job={jobForDetails(m.modelKey)}
|
|
||||||
variant="table"
|
|
||||||
order={['details']}
|
|
||||||
className="flex items-center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}, [activeTagSet, toggleTagFilter, patch])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card
|
<Card
|
||||||
@ -776,6 +928,8 @@ export default function ModelsTab() {
|
|||||||
}
|
}
|
||||||
noBodyPadding
|
noBodyPadding
|
||||||
>
|
>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[980px]">
|
||||||
<Table
|
<Table
|
||||||
rows={pageRows}
|
rows={pageRows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@ -784,11 +938,15 @@ export default function ModelsTab() {
|
|||||||
compact
|
compact
|
||||||
fullWidth
|
fullWidth
|
||||||
stickyHeader
|
stickyHeader
|
||||||
|
sort={sort}
|
||||||
|
onSortChange={(next) => setSort(next)}
|
||||||
onRowClick={(m) => {
|
onRowClick={(m) => {
|
||||||
const href = modelHref(m)
|
const href = modelHref(m)
|
||||||
if (href) window.open(href, '_blank', 'noreferrer')
|
if (href) window.open(href, '_blank', 'noreferrer')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Pagination
|
<Pagination
|
||||||
page={page}
|
page={page}
|
||||||
|
|||||||
@ -93,6 +93,15 @@ function justifyForAlign(a?: Align) {
|
|||||||
return 'justify-start'
|
return 'justify-start'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function xPadForColumn(colIndex: number, totalCols: number) {
|
||||||
|
const isFirst = colIndex === 0
|
||||||
|
const isLast = colIndex === totalCols - 1
|
||||||
|
// außen weniger Padding, innen normal
|
||||||
|
if (isFirst) return 'pl-2 pr-2'
|
||||||
|
if (isLast) return 'pl-2 pr-2'
|
||||||
|
return 'px-2'
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSortValue(v: any): { isNull: boolean; kind: 'number' | 'string'; value: number | string } {
|
function normalizeSortValue(v: any): { isNull: boolean; kind: 'number' | 'string'; value: number | string } {
|
||||||
if (v === null || v === undefined) return { isNull: true, kind: 'string', value: '' }
|
if (v === null || v === undefined) return { isNull: true, kind: 'string', value: '' }
|
||||||
if (v instanceof Date) return { isNull: false, kind: 'number', value: v.getTime() }
|
if (v instanceof Date) return { isNull: false, kind: 'number', value: v.getTime() }
|
||||||
@ -199,7 +208,7 @@ export default function Table<T>({
|
|||||||
<div className={cn(title || description || actions ? 'mt-8' : '')}>
|
<div className={cn(title || description || actions ? 'mt-8' : '')}>
|
||||||
<div className="flow-root">
|
<div className="flow-root">
|
||||||
<div className="overflow-x-auto">
|
<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('inline-block min-w-full align-middle', fullWidth ? '' : 'sm:px-6 lg:px-8')}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
card &&
|
card &&
|
||||||
@ -215,7 +224,8 @@ export default function Table<T>({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((col) => {
|
{columns.map((col, colIndex) => {
|
||||||
|
const xPad = xPadForColumn(colIndex, columns.length)
|
||||||
const isSorted = !!sortState && sortState.key === col.key
|
const isSorted = !!sortState && sortState.key === col.key
|
||||||
const dir = isSorted ? sortState!.direction : undefined
|
const dir = isSorted ? sortState!.direction : undefined
|
||||||
const ariaSort =
|
const ariaSort =
|
||||||
@ -241,7 +251,8 @@ export default function Table<T>({
|
|||||||
aria-sort={ariaSort as any}
|
aria-sort={ariaSort as any}
|
||||||
className={cn(
|
className={cn(
|
||||||
headY,
|
headY,
|
||||||
'px-3 text-xs font-semibold tracking-wide text-gray-700 dark:text-gray-200 whitespace-nowrap',
|
xPad,
|
||||||
|
'text-xs font-semibold tracking-wide text-gray-700 dark:text-gray-200 whitespace-nowrap',
|
||||||
alignTd(col.align),
|
alignTd(col.align),
|
||||||
col.widthClassName,
|
col.widthClassName,
|
||||||
col.headerClassName
|
col.headerClassName
|
||||||
@ -294,13 +305,13 @@ export default function Table<T>({
|
|||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={columns.length} className={cn(cellY, 'px-3 text-sm text-gray-500 dark:text-gray-400')}>
|
<td colSpan={columns.length} className={cn(cellY, 'px-2 text-sm text-gray-500 dark:text-gray-400')}>
|
||||||
Lädt…
|
Lädt…
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : sortedRows.length === 0 ? (
|
) : sortedRows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={columns.length} className={cn(cellY, 'px-3 text-sm text-gray-500 dark:text-gray-400')}>
|
<td colSpan={columns.length} className={cn(cellY, 'px-2 text-sm text-gray-500 dark:text-gray-400')}>
|
||||||
{emptyLabel}
|
{emptyLabel}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -327,7 +338,8 @@ export default function Table<T>({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{columns.map((col) => {
|
{columns.map((col, colIndex) => {
|
||||||
|
const xPad = xPadForColumn(colIndex, columns.length)
|
||||||
const content =
|
const content =
|
||||||
col.cell?.(row, rowIndex) ??
|
col.cell?.(row, rowIndex) ??
|
||||||
col.accessor?.(row) ??
|
col.accessor?.(row) ??
|
||||||
@ -338,7 +350,8 @@ export default function Table<T>({
|
|||||||
key={col.key}
|
key={col.key}
|
||||||
className={cn(
|
className={cn(
|
||||||
cellY,
|
cellY,
|
||||||
'px-3 text-sm whitespace-nowrap',
|
xPad,
|
||||||
|
'text-sm whitespace-nowrap',
|
||||||
alignTd(col.align),
|
alignTd(col.align),
|
||||||
col.className,
|
col.className,
|
||||||
col.key === columns[0]?.key
|
col.key === columns[0]?.key
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user