This commit is contained in:
Linrador 2026-01-20 13:48:05 +01:00
parent e3387dd6fe
commit 50515d44b0
17 changed files with 1738 additions and 372 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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

View File

@ -1,3 +1,5 @@
// backend\models.go
package main
import (

View File

@ -1,4 +1,4 @@
// models_store.go
// backend\models_store.go
package main
import (

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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>

View File

@ -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 ? (

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

View File

@ -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>

View File

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

View File

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

View File

@ -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