updated
This commit is contained in:
parent
e3387dd6fe
commit
50515d44b0
Binary file not shown.
Binary file not shown.
Binary file not shown.
734
backend/main.go
734
backend/main.go
@ -6,13 +6,16 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@ -1260,7 +1263,12 @@ func removeGeneratedForID(id string) {
|
||||
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) != "" {
|
||||
_ = os.RemoveAll(filepath.Join(root, id))
|
||||
}
|
||||
@ -2297,10 +2305,419 @@ func stripHotPrefix(s string) string {
|
||||
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) {
|
||||
return resolvePathRelativeToApp("generated")
|
||||
}
|
||||
|
||||
func generatedMetaRoot() (string, error) {
|
||||
return resolvePathRelativeToApp(filepath.Join("generated", "meta"))
|
||||
}
|
||||
|
||||
// Legacy (falls noch alte Assets liegen):
|
||||
func generatedThumbsRoot() (string, error) {
|
||||
return resolvePathRelativeToApp(filepath.Join("generated", "thumbs"))
|
||||
@ -2309,57 +2726,240 @@ func generatedTeaserRoot() (string, error) {
|
||||
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"`
|
||||
UpdatedAtUnix int64 `json:"updatedAtUnix"`
|
||||
|
||||
VideoWidth int `json:"videoWidth,omitempty"`
|
||||
VideoHeight int `json:"videoHeight,omitempty"`
|
||||
FPS float64 `json:"fps,omitempty"`
|
||||
|
||||
UpdatedAtUnix int64 `json:"updatedAtUnix"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func readVideoMetaDuration(metaPath string, fi os.FileInfo) (sec float64, ok bool) {
|
||||
// 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, false
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
var m videoMetaV1
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return 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
|
||||
}
|
||||
if m.Version != 1 {
|
||||
return 0, false
|
||||
|
||||
// 2) Fallback: altes v1 Format (nur Duration)
|
||||
var m1 struct {
|
||||
Version int `json:"version"`
|
||||
DurationSeconds float64 `json:"durationSeconds"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
FileModUnix int64 `json:"fileModUnix"`
|
||||
UpdatedAtUnix int64 `json:"updatedAtUnix"`
|
||||
}
|
||||
// Invalidation: wenn Datei geändert wurde -> Meta ignorieren
|
||||
if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() {
|
||||
return 0, false
|
||||
if err := json.Unmarshal(b, &m1); err != nil {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
if m.DurationSeconds <= 0 {
|
||||
return 0, false
|
||||
if m1.Version != 1 {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
return m.DurationSeconds, true
|
||||
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 writeVideoMeta(metaPath string, fi os.FileInfo, dur float64) error {
|
||||
func writeVideoMetaV2(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64) error {
|
||||
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
|
||||
return nil
|
||||
}
|
||||
m := videoMetaV1{
|
||||
Version: 1,
|
||||
m := videoMetaV2{
|
||||
Version: 2,
|
||||
DurationSeconds: dur,
|
||||
FileSize: fi.Size(),
|
||||
FileModUnix: fi.ModTime().Unix(),
|
||||
VideoWidth: w,
|
||||
VideoHeight: h,
|
||||
FPS: fps,
|
||||
UpdatedAtUnix: time.Now().Unix(),
|
||||
}
|
||||
buf, err := json.Marshal(m)
|
||||
@ -2370,18 +2970,37 @@ func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64) error {
|
||||
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) {
|
||||
id, err := sanitizeID(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
root, err := generatedRoot()
|
||||
root, err := generatedMetaRoot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
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
|
||||
}
|
||||
@ -2414,12 +3033,12 @@ func generatedPreviewFile(id string) (string, error) {
|
||||
}
|
||||
|
||||
func ensureGeneratedDirs() error {
|
||||
root, err := generatedRoot()
|
||||
root, err := generatedMetaRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return fmt.Errorf("generated root ist leer")
|
||||
return fmt.Errorf("generated meta root ist leer")
|
||||
}
|
||||
return os.MkdirAll(root, 0o755)
|
||||
}
|
||||
@ -3174,6 +3793,14 @@ var ffmpegInputTol = []string{
|
||||
"-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 {
|
||||
if durSec <= 0 {
|
||||
durSec = 8
|
||||
@ -4646,6 +5273,8 @@ func registerRoutes(mux *http.ServeMux) *ModelStore {
|
||||
mux.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
||||
|
||||
mux.HandleFunc("/api/generated/teaser", generatedTeaser)
|
||||
mux.HandleFunc("/api/generated/cover", generatedCover)
|
||||
|
||||
// Tasks
|
||||
mux.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets)
|
||||
|
||||
@ -4657,6 +5286,8 @@ func registerRoutes(mux *http.ServeMux) *ModelStore {
|
||||
fmt.Println("⚠️ models load:", err)
|
||||
}
|
||||
|
||||
setCoverModelStore(store)
|
||||
|
||||
// ✅ registriert /api/models/list, /parse, /upsert, /flags, /delete
|
||||
RegisterModelAPI(mux, store)
|
||||
|
||||
@ -4684,6 +5315,10 @@ func main() {
|
||||
go startMyFreeCamsAutoStartWorker(store)
|
||||
go startDiskSpaceGuard() // ✅ reagiert auch ohne Frontend
|
||||
|
||||
if _, err := ensureCoversDir(); err != nil {
|
||||
fmt.Println("⚠️ covers dir:", err)
|
||||
}
|
||||
|
||||
fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999")
|
||||
if err := http.ListenAndServe(":9999", mux); err != nil {
|
||||
fmt.Println("❌ HTTP-Server Fehler:", err)
|
||||
@ -4698,40 +5333,9 @@ type RecordRequest struct {
|
||||
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) {
|
||||
metaPath := filepath.Join(assetDir, "meta.json")
|
||||
m, ok := readVideoMeta(metaPath)
|
||||
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
|
||||
return readVideoMetaDuration(metaPath, fi)
|
||||
}
|
||||
|
||||
// shared: wird vom HTTP-Handler UND vom Autostart-Worker genutzt
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// backend\models.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// models_store.go
|
||||
// backend\models_store.go
|
||||
package main
|
||||
|
||||
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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>App</title>
|
||||
<script type="module" crossorigin src="/assets/index-Czq-AJKF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CAKbyWZn.css">
|
||||
<script type="module" crossorigin src="/assets/index-DSZfASIn.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-hlx7oHN0.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -17,6 +17,7 @@ import { SignalIcon, HeartIcon, HandThumbUpIcon, EyeIcon } from '@heroicons/reac
|
||||
import PerformanceMonitor from './components/ui/PerformanceMonitor'
|
||||
import { useNotify } from './components/ui/notify'
|
||||
import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
|
||||
import CategoriesTab from './components/ui/CategoriesTab'
|
||||
|
||||
const COOKIE_STORAGE_KEY = 'record_cookies'
|
||||
|
||||
@ -655,6 +656,7 @@ export default function App() {
|
||||
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length },
|
||||
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount },
|
||||
{ id: 'models', label: 'Models', count: modelsCount },
|
||||
{ id: 'categories', label: 'Kategorien' },
|
||||
{ id: 'settings', label: 'Einstellungen' },
|
||||
]
|
||||
|
||||
@ -1014,6 +1016,20 @@ export default function App() {
|
||||
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
|
||||
}, [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) ----
|
||||
useEffect(() => {
|
||||
if (!playerJob) {
|
||||
@ -2138,6 +2154,7 @@ export default function App() {
|
||||
) : null}
|
||||
|
||||
{selectedTab === 'models' ? <ModelsTab /> : null}
|
||||
{selectedTab === 'categories' ? <CategoriesTab /> : null}
|
||||
{selectedTab === 'settings' ? <RecorderSettings onAssetsGenerated={bumpAssets} /> : null}
|
||||
</main>
|
||||
|
||||
@ -2165,6 +2182,11 @@ export default function App() {
|
||||
runningJobs={runningJobs}
|
||||
cookies={cookies}
|
||||
blurPreviews={recSettings.blurPreviews}
|
||||
onToggleHot={handleToggleHot}
|
||||
onDelete={handleDeleteJob}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onToggleLike={handleToggleLike}
|
||||
onToggleWatch={handleToggleWatch}
|
||||
/>
|
||||
|
||||
{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([]), [])
|
||||
|
||||
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(() => {
|
||||
if (!globalFilterActive) return
|
||||
|
||||
@ -1330,7 +1351,7 @@ export default function FinishedDownloads({
|
||||
/>
|
||||
{(searchQuery || '').trim() !== '' ? (
|
||||
<Button size="sm" variant="soft" onClick={clearSearch}>
|
||||
Clear
|
||||
Leeren
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -8,6 +8,7 @@ import type { RecordJob } from '../../types'
|
||||
import Modal from './Modal'
|
||||
import Button from './Button'
|
||||
import TagBadge from './TagBadge'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
CalendarDaysIcon,
|
||||
@ -28,7 +29,6 @@ import {
|
||||
EyeIcon as EyeSolidIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
|
||||
|
||||
function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
@ -86,11 +86,6 @@ function splitTags(v?: string | null) {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function isKeptOutputPath(output: string) {
|
||||
const s = output.toLowerCase()
|
||||
return s.includes('/keep/') || s.includes('\\keep\\')
|
||||
}
|
||||
|
||||
function baseName(path: string) {
|
||||
return (path || '').split(/[\\/]/).pop() || ''
|
||||
}
|
||||
@ -110,6 +105,21 @@ function stripHotPrefix(name: string) {
|
||||
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) {
|
||||
const fileRaw = baseName(output || '')
|
||||
const file = stripHotPrefix(fileRaw)
|
||||
@ -279,6 +289,12 @@ type Props = {
|
||||
cookies?: Record<string, string>
|
||||
runningJobs?: RecordJob[]
|
||||
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 {
|
||||
@ -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 [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 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 s = String(src ?? '').trim()
|
||||
if (!s) return
|
||||
@ -638,6 +704,105 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
||||
</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 (
|
||||
<Modal
|
||||
open={open}
|
||||
@ -757,14 +922,23 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
||||
{/* Local flags icons (unten rechts im Hero) */}
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-2">
|
||||
{/* Favorite = Star */}
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleToggleFavoriteModel()
|
||||
}}
|
||||
className={cn(
|
||||
'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
|
||||
? 'bg-amber-500/25 ring-amber-200/30'
|
||||
: '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">
|
||||
<StarOutlineIcon
|
||||
@ -782,17 +956,26 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Like = Heart */}
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleToggleLikeModel()
|
||||
}}
|
||||
className={cn(
|
||||
'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
|
||||
? 'bg-rose-500/25 ring-rose-200/30'
|
||||
: '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">
|
||||
<HeartOutlineIcon
|
||||
@ -810,17 +993,26 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Watched = Eye */}
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleToggleWatchModel()
|
||||
}}
|
||||
className={cn(
|
||||
'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
|
||||
? 'bg-sky-500/25 ring-sky-200/30'
|
||||
: '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">
|
||||
<EyeOutlineIcon
|
||||
@ -838,7 +1030,7 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1196,7 +1388,6 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
||||
<div className="grid gap-2">
|
||||
{doneMatchesPage.map((j) => {
|
||||
const file = baseName(j.output || '')
|
||||
const kept = isKeptOutputPath(j.output || '')
|
||||
const hot = file.startsWith('HOT ')
|
||||
const ended = j.endedAt ? fmtDateTime(j.endedAt as any) : '—'
|
||||
return (
|
||||
@ -1218,7 +1409,7 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
||||
<div className="min-w-0">
|
||||
<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">
|
||||
{file || '—'}
|
||||
{stripHotPrefix(file) || '—'}
|
||||
</div>
|
||||
|
||||
{hot ? (
|
||||
@ -1226,16 +1417,10 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
||||
HOT
|
||||
</span>
|
||||
) : 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 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>
|
||||
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>
|
||||
Dauer:{' '}
|
||||
@ -1252,11 +1437,28 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onOpenPlayer ? (
|
||||
<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">
|
||||
Öffnen
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{/* Record actions (Details etc.) */}
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -105,6 +105,31 @@ function canonicalHost(raw?: string): string {
|
||||
.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 {
|
||||
// 1) Wenn Backend eine echte URL gespeichert hat
|
||||
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() {
|
||||
const [models, setModels] = React.useState<StoredModel[]>([])
|
||||
@ -183,6 +216,46 @@ export default function ModelsTab() {
|
||||
// 🏷️ Tag-Filter (klickbar)
|
||||
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(() => {
|
||||
return new Set(tagFilter.map((t) => t.toLowerCase()))
|
||||
}, [tagFilter])
|
||||
@ -197,6 +270,17 @@ export default function ModelsTab() {
|
||||
|
||||
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 [parsed, setParsed] = React.useState<ParsedModel | null>(null)
|
||||
const [parseError, setParseError] = React.useState<string | null>(null)
|
||||
@ -258,12 +342,17 @@ export default function ModelsTab() {
|
||||
try {
|
||||
const list = await apiJSON<StoredModel[]>('/api/models/list', { cache: 'no-store' })
|
||||
setModels(Array.isArray(list) ? list : [])
|
||||
void refreshVideoCounts() // ✅ Counts parallel aktualisieren
|
||||
} catch (e: any) {
|
||||
setErr(e?.message ?? String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [refreshVideoCounts])
|
||||
|
||||
React.useEffect(() => {
|
||||
void refreshVideoCounts()
|
||||
}, [refreshVideoCounts])
|
||||
|
||||
React.useEffect(() => {
|
||||
void refresh()
|
||||
@ -350,6 +439,200 @@ export default function ModelsTab() {
|
||||
|
||||
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 needle = deferredQ.trim().toLowerCase()
|
||||
|
||||
@ -372,11 +655,46 @@ export default function ModelsTab() {
|
||||
})
|
||||
}, [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(() => {
|
||||
setPage(1)
|
||||
}, [q, tagFilter])
|
||||
|
||||
const totalItems = filtered.length
|
||||
const totalItems = sortedAll.length
|
||||
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -385,8 +703,8 @@ export default function ModelsTab() {
|
||||
|
||||
const pageRows = React.useMemo(() => {
|
||||
const start = (page - 1) * pageSize
|
||||
return filtered.slice(start, start + pageSize)
|
||||
}, [filtered, page, pageSize])
|
||||
return sortedAll.slice(start, start + pageSize)
|
||||
}, [sortedAll, page, pageSize])
|
||||
|
||||
const upsertFromParsed = async () => {
|
||||
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)
|
||||
|
||||
// ✅ 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 (
|
||||
<div className="space-y-4">
|
||||
<Card
|
||||
@ -776,19 +928,25 @@ export default function ModelsTab() {
|
||||
}
|
||||
noBodyPadding
|
||||
>
|
||||
<Table
|
||||
rows={pageRows}
|
||||
columns={columns}
|
||||
getRowKey={(m) => m.id}
|
||||
striped
|
||||
compact
|
||||
fullWidth
|
||||
stickyHeader
|
||||
onRowClick={(m) => {
|
||||
const href = modelHref(m)
|
||||
if (href) window.open(href, '_blank', 'noreferrer')
|
||||
}}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[980px]">
|
||||
<Table
|
||||
rows={pageRows}
|
||||
columns={columns}
|
||||
getRowKey={(m) => m.id}
|
||||
striped
|
||||
compact
|
||||
fullWidth
|
||||
stickyHeader
|
||||
sort={sort}
|
||||
onSortChange={(next) => setSort(next)}
|
||||
onRowClick={(m) => {
|
||||
const href = modelHref(m)
|
||||
if (href) window.open(href, '_blank', 'noreferrer')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
|
||||
@ -93,6 +93,15 @@ function justifyForAlign(a?: Align) {
|
||||
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 } {
|
||||
if (v === null || v === undefined) return { isNull: true, kind: 'string', value: '' }
|
||||
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="flow-root">
|
||||
<div className="overflow-x-auto">
|
||||
<div className={cn('inline-block min-w-full py-2 align-middle', fullWidth ? '' : 'sm:px-6 lg:px-8')}>
|
||||
<div className={cn('inline-block min-w-full align-middle', fullWidth ? '' : 'sm:px-6 lg:px-8')}>
|
||||
<div
|
||||
className={cn(
|
||||
card &&
|
||||
@ -215,7 +224,8 @@ export default function Table<T>({
|
||||
)}
|
||||
>
|
||||
<tr>
|
||||
{columns.map((col) => {
|
||||
{columns.map((col, colIndex) => {
|
||||
const xPad = xPadForColumn(colIndex, columns.length)
|
||||
const isSorted = !!sortState && sortState.key === col.key
|
||||
const dir = isSorted ? sortState!.direction : undefined
|
||||
const ariaSort =
|
||||
@ -241,7 +251,8 @@ export default function Table<T>({
|
||||
aria-sort={ariaSort as any}
|
||||
className={cn(
|
||||
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),
|
||||
col.widthClassName,
|
||||
col.headerClassName
|
||||
@ -294,13 +305,13 @@ export default function Table<T>({
|
||||
>
|
||||
{isLoading ? (
|
||||
<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…
|
||||
</td>
|
||||
</tr>
|
||||
) : sortedRows.length === 0 ? (
|
||||
<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}
|
||||
</td>
|
||||
</tr>
|
||||
@ -327,7 +338,8 @@ export default function Table<T>({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{columns.map((col) => {
|
||||
{columns.map((col, colIndex) => {
|
||||
const xPad = xPadForColumn(colIndex, columns.length)
|
||||
const content =
|
||||
col.cell?.(row, rowIndex) ??
|
||||
col.accessor?.(row) ??
|
||||
@ -338,7 +350,8 @@ export default function Table<T>({
|
||||
key={col.key}
|
||||
className={cn(
|
||||
cellY,
|
||||
'px-3 text-sm whitespace-nowrap',
|
||||
xPad,
|
||||
'text-sm whitespace-nowrap',
|
||||
alignTd(col.align),
|
||||
col.className,
|
||||
col.key === columns[0]?.key
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user