From d3deac7b36a259451c06ed646f4492cb83cf6eb9 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:47:57 +0100 Subject: [PATCH] updated --- backend/data/models_store.db | Bin 0 -> 4096 bytes backend/data/models_store.db-shm | Bin 0 -> 32768 bytes backend/data/models_store.db-wal | Bin 0 -> 45352 bytes backend/go.mod | 10 + backend/go.sum | 21 + backend/main.go | 48 +- backend/models_store.go | 593 +++++++++++++++++------ frontend/.env | 1 + frontend/src/components/ui/ModelsTab.tsx | 283 +++++++---- 9 files changed, 709 insertions(+), 247 deletions(-) create mode 100644 backend/data/models_store.db create mode 100644 backend/data/models_store.db-shm create mode 100644 backend/data/models_store.db-wal create mode 100644 frontend/.env diff --git a/backend/data/models_store.db b/backend/data/models_store.db new file mode 100644 index 0000000000000000000000000000000000000000..4410bda55bad27954e72ab65919e742875d57998 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYFsp;RR_IAlr;ljiVtj n8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O6ovo*Y?%hH literal 0 HcmV?d00001 diff --git a/backend/data/models_store.db-shm b/backend/data/models_store.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..dad24642ae06725e4f35cb70f7f6ce9f67fd4b1f GIT binary patch literal 32768 zcmeI)F-`(e5CG5tTm%KQq@lL*0Gz{c3=?}B5?k)T5jcUhiI*_Z7`TPbCu>4WpztSe zlArlI*`1xd1I+aEK1y`+bRuqVGB;-=;^tpRM*$ z`u-e5TaN4YlKKtwKAW)&eW$v9DN7+dy z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N I0{>m$7s1dd)Bpeg literal 0 HcmV?d00001 diff --git a/backend/data/models_store.db-wal b/backend/data/models_store.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..f1269b8b0468fba9a32c673b21b0468aea6cc202 GIT binary patch literal 45352 zcmeI*OKclO7{KxMBWV+1TqKZd%43j_TB~&&J58#lqL8Fph!UK}IHKWVIq{~p;@HMs z_n}Iq4H5?q2*eEzJ-`u(6G9vcf>xB$J3>8x!lQx%AZ|fag_(V%>m($C3M7~R$ccC7 z<=x*VmUi}=-F+`~E^6^_wrkopO{E`BpWRt|@cidDj-C1a%+IFE^m+5|`hIe(_UZYj zo|zmknTD9F))(@o=##Dee(eb%WL>}f38*$sQtgB8ihQ^F^Q^D8=+}B)zBd$J*8HJm zEwmiIx?Uyn5I_I{1Q0*~0R#|0009L4J%JaG__jo%QQu3a&HPN+Sg00^a>M@ZAIa!L zSzTm@hR1bb=ZRh2LX--^O)9n`LcEZr(U0tXiHPWc?F)bGBHj9FuKU ze}XuqA0C<-&x&5v@m&6RwXV*2H>+5zy4$o#kMo96b2fG3k1m(y4aab$q5=}}xFwb7 zO(eVJCeGH4ylE72@{D|qWV2RSx2!w1+us>^JnGX*m4b1y@qAhCV=mt`tJZqXK90Gh z{jSdIw>p1a>O6mCyx^19W009ILKmY**5I_I{1hiscOQbLAJ9m#2 z`&}xWbi=<@puLKqx1+#=PNe@-dUSG1mr?GA^rJG$e@$&Mk#^<*>{1=&=}EUKmY**5I_I{1Q2Ksf$3mpBpLOcSba_3 zxg(r?Nx#Fnq`&4`zEjI{DgV};c`c=jS+WLUA@dzM*00IagfB*srAb exe liegt in Temp/go-build + isTemp := strings.Contains(low, `\appdata\local\temp`) || + strings.Contains(low, `\temp\`) || + strings.Contains(low, `\tmp\`) || + strings.Contains(low, `\go-build`) || + strings.Contains(low, `/tmp/`) || + strings.Contains(low, `/go-build`) + + if !isTemp { + return filepath.Join(exeDir, p), nil + } + } + + // Fallback: Working Directory (Dev) + wd, err := os.Getwd() if err != nil { return "", err } - base := filepath.Dir(exe) - return filepath.Join(base, p), nil + return filepath.Join(wd, p), nil } // routes.go (package main) @@ -887,7 +903,9 @@ func registerRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/record/video", recordVideo) mux.HandleFunc("/api/record/done", recordDoneList) - modelsPath, _ := resolvePathRelativeToExe("data/models_store.json") + modelsPath, _ := resolvePathRelativeToApp("data/models_store.db") + fmt.Println("📦 Models DB:", modelsPath) + store := NewModelStore(modelsPath) if err := store.Load(); err != nil { fmt.Println("⚠️ models load:", err) @@ -1094,8 +1112,8 @@ func recordVideo(w http.ResponseWriter, r *http.Request) { } s := getSettings() - recordAbs, _ := resolvePathRelativeToExe(s.RecordDir) - doneAbs, _ := resolvePathRelativeToExe(s.DoneDir) + recordAbs, _ := resolvePathRelativeToApp(s.RecordDir) + doneAbs, _ := resolvePathRelativeToApp(s.DoneDir) candidates := []string{ filepath.Join(doneAbs, file), // bevorzugt doneDir @@ -1148,7 +1166,7 @@ func recordVideo(w http.ResponseWriter, r *http.Request) { } if !filepath.IsAbs(outPath) { - abs, err := resolvePathRelativeToExe(outPath) + abs, err := resolvePathRelativeToApp(outPath) if err != nil { http.Error(w, "pfad auflösung fehlgeschlagen", http.StatusInternalServerError) return @@ -1180,7 +1198,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { } s := getSettings() - doneAbs, err := resolvePathRelativeToExe(s.DoneDir) + doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return @@ -1304,7 +1322,7 @@ func moveToDoneDir(outputPath string) (string, error) { s := getSettings() // ✅ doneDir relativ zur exe auflösen (funktion hast du schon) - doneDirAbs, err := resolvePathRelativeToExe(s.DoneDir) + doneDirAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { return "", err } diff --git a/backend/models_store.go b/backend/models_store.go index bc30aa5..ada8e61 100644 --- a/backend/models_store.go +++ b/backend/models_store.go @@ -1,19 +1,22 @@ +// models_store.go package main import ( + "database/sql" "encoding/json" "errors" "net/url" "os" "path/filepath" - "sort" "strings" "sync" "time" + + _ "modernc.org/sqlite" ) type StoredModel struct { - ID string `json:"id"` // i.d.R. modelKey (unique) + ID string `json:"id"` // unique (wir verwenden host:modelKey) Input string `json:"input"` // Original-URL/Eingabe IsURL bool `json:"isUrl"` // vom Parser Host string `json:"host,omitempty"` @@ -30,94 +33,6 @@ type StoredModel struct { UpdatedAt string `json:"updatedAt"` } -type ModelStore struct { - path string - mu sync.RWMutex - items map[string]StoredModel -} - -func NewModelStore(path string) *ModelStore { - return &ModelStore{ - path: path, - items: map[string]StoredModel{}, - } -} - -func (s *ModelStore) Load() error { - s.mu.Lock() - defer s.mu.Unlock() - - b, err := os.ReadFile(s.path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil // ok - } - return err - } - - var list []StoredModel - if err := json.Unmarshal(b, &list); err != nil { - return err - } - - s.items = map[string]StoredModel{} - for _, m := range list { - if m.ID == "" { - m.ID = m.ModelKey - } - if m.ID != "" { - s.items[m.ID] = m - } - } - return nil -} - -func (s *ModelStore) saveLocked() error { - if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { - return err - } - - list := make([]StoredModel, 0, len(s.items)) - for _, m := range s.items { - list = append(list, m) - } - - // Neueste zuerst - sort.Slice(list, func(i, j int) bool { return list[i].UpdatedAt > list[j].UpdatedAt }) - - b, err := json.MarshalIndent(list, "", " ") - if err != nil { - return err - } - - tmp := s.path + ".tmp" - if err := os.WriteFile(tmp, b, 0o644); err != nil { - return err - } - - // Windows: Rename überschreibt nicht immer zuverlässig -> erst versuchen, sonst löschen & retry - if err := os.Rename(tmp, s.path); err != nil { - _ = os.Remove(s.path) - if err2 := os.Rename(tmp, s.path); err2 != nil { - _ = os.Remove(tmp) - return err2 - } - } - return nil -} - -func (s *ModelStore) List() []StoredModel { - s.mu.RLock() - defer s.mu.RUnlock() - - out := make([]StoredModel, 0, len(s.items)) - for _, m := range s.items { - out = append(out, m) - } - sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt }) - return out -} - type ParsedModelDTO struct { Input string `json:"input"` IsURL bool `json:"isUrl"` @@ -126,7 +41,338 @@ type ParsedModelDTO struct { ModelKey string `json:"modelKey"` } +type ModelFlagsPatch struct { + ID string `json:"id"` + Watching *bool `json:"watching,omitempty"` + Favorite *bool `json:"favorite,omitempty"` + Hot *bool `json:"hot,omitempty"` + Keep *bool `json:"keep,omitempty"` + Liked *bool `json:"liked,omitempty"` + ClearLiked bool `json:"clearLiked,omitempty"` +} + +type ModelStore struct { + dbPath string + legacyJSONPath string + + db *sql.DB + initOnce sync.Once + initErr error + + // serialize writes (einfach & robust) + mu sync.Mutex +} + +// Backwards compatible: +// - wenn du ".json" übergibst (wie aktuell in main.go), wird daraus automatisch ".db" +// und die JSON-Datei wird als Legacy-Quelle für die 1x Migration genutzt. +func NewModelStore(path string) *ModelStore { + path = strings.TrimSpace(path) + + lower := strings.ToLower(path) + dbPath := path + legacy := "" + + if strings.HasSuffix(lower, ".json") { + legacy = path + dbPath = strings.TrimSuffix(path, filepath.Ext(path)) + ".db" // z.B. models_store.db + } else if strings.HasSuffix(lower, ".db") || strings.HasSuffix(lower, ".sqlite") || strings.HasSuffix(lower, ".sqlite3") { + legacy = filepath.Join(filepath.Dir(path), "models_store.json") + } + + return &ModelStore{ + dbPath: dbPath, + legacyJSONPath: legacy, + } +} + +// main.go ruft aktuell store.Load() auf :contentReference[oaicite:4]{index=4} +// -> wir lassen Load() als Alias für Init() drin. +func (s *ModelStore) Load() error { return s.ensureInit() } + +func (s *ModelStore) ensureInit() error { + s.initOnce.Do(func() { + s.initErr = s.init() + }) + return s.initErr +} + +func (s *ModelStore) init() error { + if strings.TrimSpace(s.dbPath) == "" { + return errors.New("db path fehlt") + } + if err := os.MkdirAll(filepath.Dir(s.dbPath), 0o755); err != nil { + return err + } + + db, err := sql.Open("sqlite", s.dbPath) + if err != nil { + return err + } + // SQLite am besten single-conn im Server-Prozess + db.SetMaxOpenConns(1) + + // Pragmas (einzeln ausführen) + _, _ = db.Exec(`PRAGMA foreign_keys = ON;`) + _, _ = db.Exec(`PRAGMA journal_mode = WAL;`) + _, _ = db.Exec(`PRAGMA synchronous = NORMAL;`) + + if err := createModelsSchema(db); err != nil { + _ = db.Close() + return err + } + + s.db = db + + // 1x Migration: wenn DB leer ist und Legacy JSON existiert + if s.legacyJSONPath != "" { + if err := s.migrateFromJSONIfEmpty(); err != nil { + // Migration-Fehler nicht hart killen, aber zurückgeben ist auch ok. + // Ich gebe zurück, damit du es direkt siehst. + return err + } + } + + return nil +} + +func createModelsSchema(db *sql.DB) error { + _, err := db.Exec(` +CREATE TABLE IF NOT EXISTS models ( + id TEXT PRIMARY KEY, + input TEXT NOT NULL, + is_url INTEGER NOT NULL, + host TEXT, + path TEXT, + model_key TEXT NOT NULL, + + watching INTEGER NOT NULL DEFAULT 0, + favorite INTEGER NOT NULL DEFAULT 0, + hot INTEGER NOT NULL DEFAULT 0, + keep INTEGER NOT NULL DEFAULT 0, + liked INTEGER NULL, -- NULL/0/1 + + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +`) + if err != nil { + return err + } + + // optionaler Unique-Index (hilft bei Konsistenz) + _, _ = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_models_host_key ON models(host, model_key);`) + _, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_models_updated ON models(updated_at);`) + return nil +} + +func canonicalHost(host string) string { + h := strings.ToLower(strings.TrimSpace(host)) + h = strings.TrimPrefix(h, "www.") + return h +} + +func canonicalID(host, modelKey string) string { + h := canonicalHost(host) + k := strings.TrimSpace(modelKey) + if h != "" { + return h + ":" + k + } + return k +} + +func boolToInt(b bool) int64 { + if b { + return 1 + } + return 0 +} + +func nullLikedFromPtr(p *bool) sql.NullInt64 { + if p == nil { + return sql.NullInt64{Valid: false} + } + return sql.NullInt64{Valid: true, Int64: boolToInt(*p)} +} + +func ptrLikedFromNull(n sql.NullInt64) *bool { + if !n.Valid { + return nil + } + v := n.Int64 != 0 + return &v +} + +func (s *ModelStore) migrateFromJSONIfEmpty() error { + // DB leer? + var cnt int + if err := s.db.QueryRow(`SELECT COUNT(1) FROM models;`).Scan(&cnt); err != nil { + return err + } + if cnt != 0 { + return nil + } + + // Legacy JSON vorhanden? + b, err := os.ReadFile(s.legacyJSONPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + if len(bytesTrimSpace(b)) == 0 { + return nil + } + + var list []StoredModel + if err := json.Unmarshal(b, &list); err != nil { + return err + } + if len(list) == 0 { + return nil + } + + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + stmt, err := tx.Prepare(` +INSERT INTO models ( + id,input,is_url,host,path,model_key, + watching,favorite,hot,keep,liked, + created_at,updated_at +) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) +ON CONFLICT(id) DO UPDATE SET + input=excluded.input, + is_url=excluded.is_url, + host=excluded.host, + path=excluded.path, + model_key=excluded.model_key, + updated_at=excluded.updated_at; +`) + if err != nil { + return err + } + defer stmt.Close() + + now := time.Now().UTC().Format(time.RFC3339Nano) + + for _, m := range list { + host := canonicalHost(m.Host) + modelKey := strings.TrimSpace(m.ModelKey) + if modelKey == "" { + continue + } + + // alte IDs (oft nur modelKey) werden auf host:modelKey normalisiert + id := canonicalID(host, modelKey) + + created := strings.TrimSpace(m.CreatedAt) + updated := strings.TrimSpace(m.UpdatedAt) + if created == "" { + created = now + } + if updated == "" { + updated = now + } + + liked := nullLikedFromPtr(m.Liked) + var likedArg any + if liked.Valid { + likedArg = liked.Int64 + } else { + likedArg = nil + } + + _, err = stmt.Exec( + id, + m.Input, + boolToInt(m.IsURL), + host, + m.Path, + modelKey, + boolToInt(m.Watching), + boolToInt(m.Favorite), + boolToInt(m.Hot), + boolToInt(m.Keep), + likedArg, + created, + updated, + ) + if err != nil { + return err + } + } + + return tx.Commit() +} + +func bytesTrimSpace(b []byte) []byte { + return []byte(strings.TrimSpace(string(b))) +} + +func (s *ModelStore) List() []StoredModel { + if err := s.ensureInit(); err != nil { + return []StoredModel{} + } + + rows, err := s.db.Query(` +SELECT + id,input,is_url,host,path,model_key, + watching,favorite,hot,keep,liked, + created_at,updated_at +FROM models +ORDER BY updated_at DESC; +`) + if err != nil { + return []StoredModel{} + } + defer rows.Close() + + out := make([]StoredModel, 0, 64) + + for rows.Next() { + var ( + id, input, host, path, modelKey, createdAt, updatedAt string + isURL, watching, favorite, hot, keep int64 + liked sql.NullInt64 + ) + if err := rows.Scan( + &id, &input, &isURL, &host, &path, &modelKey, + &watching, &favorite, &hot, &keep, &liked, + &createdAt, &updatedAt, + ); err != nil { + continue + } + + out = append(out, StoredModel{ + ID: id, + Input: input, + IsURL: isURL != 0, + Host: host, + Path: path, + ModelKey: modelKey, + Watching: watching != 0, + Favorite: favorite != 0, + Hot: hot != 0, + Keep: keep != 0, + Liked: ptrLikedFromNull(liked), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) + } + + return out +} + func (s *ModelStore) UpsertFromParsed(p ParsedModelDTO) (StoredModel, error) { + if err := s.ensureInit(); err != nil { + return StoredModel{}, err + } + if p.ModelKey == "" { return StoredModel{}, errors.New("modelKey fehlt") } @@ -142,97 +388,166 @@ func (s *ModelStore) UpsertFromParsed(p ParsedModelDTO) (StoredModel, error) { if err != nil || u.Scheme == "" || u.Hostname() == "" { return StoredModel{}, errors.New("Ungültige URL.") } - if strings.TrimSpace(p.ModelKey) == "" { - return StoredModel{}, errors.New("ModelKey fehlt.") - } + + host := canonicalHost(p.Host) + modelKey := strings.TrimSpace(p.ModelKey) + id := canonicalID(host, modelKey) now := time.Now().UTC().Format(time.RFC3339Nano) s.mu.Lock() defer s.mu.Unlock() - id := p.ModelKey - existing, ok := s.items[id] - if !ok { - existing = StoredModel{ - ID: id, - CreatedAt: now, - } - } - - // Felder aktualisieren - existing.Input = p.Input - existing.IsURL = p.IsURL - existing.Host = p.Host - existing.Path = p.Path - existing.ModelKey = p.ModelKey - existing.UpdatedAt = now - - s.items[id] = existing - if err := s.saveLocked(); err != nil { + _, err = s.db.Exec(` +INSERT INTO models ( + id,input,is_url,host,path,model_key, + watching,favorite,hot,keep,liked, + created_at,updated_at +) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) +ON CONFLICT(id) DO UPDATE SET + input=excluded.input, + is_url=excluded.is_url, + host=excluded.host, + path=excluded.path, + model_key=excluded.model_key, + updated_at=excluded.updated_at; +`, + id, + u.String(), + int64(1), + host, + p.Path, + modelKey, + int64(0), int64(0), int64(0), int64(0), nil, // Flags nur bei neuem Insert (Update fasst sie nicht an) + now, + now, + ) + if err != nil { return StoredModel{}, err } - return existing, nil -} -type ModelFlagsPatch struct { - ID string `json:"id"` - Watching *bool `json:"watching,omitempty"` - Favorite *bool `json:"favorite,omitempty"` - Hot *bool `json:"hot,omitempty"` - Keep *bool `json:"keep,omitempty"` - Liked *bool `json:"liked,omitempty"` - ClearLiked bool `json:"clearLiked,omitempty"` + return s.getByID(id) } func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) { + if err := s.ensureInit(); err != nil { + return StoredModel{}, err + } if patch.ID == "" { return StoredModel{}, errors.New("id fehlt") } - now := time.Now().UTC().Format(time.RFC3339Nano) - s.mu.Lock() defer s.mu.Unlock() - m, ok := s.items[patch.ID] - if !ok { - return StoredModel{}, errors.New("model nicht gefunden") + // aktuelle Flags lesen + var ( + watching, favorite, hot, keep int64 + liked sql.NullInt64 + ) + err := s.db.QueryRow(`SELECT watching,favorite,hot,keep,liked FROM models WHERE id=?;`, patch.ID). + Scan(&watching, &favorite, &hot, &keep, &liked) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return StoredModel{}, errors.New("model nicht gefunden") + } + return StoredModel{}, err } if patch.Watching != nil { - m.Watching = *patch.Watching + watching = boolToInt(*patch.Watching) } if patch.Favorite != nil { - m.Favorite = *patch.Favorite + favorite = boolToInt(*patch.Favorite) } if patch.Hot != nil { - m.Hot = *patch.Hot + hot = boolToInt(*patch.Hot) } if patch.Keep != nil { - m.Keep = *patch.Keep + keep = boolToInt(*patch.Keep) } + if patch.ClearLiked { - m.Liked = nil + liked = sql.NullInt64{Valid: false} } else if patch.Liked != nil { - m.Liked = patch.Liked + liked = sql.NullInt64{Valid: true, Int64: boolToInt(*patch.Liked)} } - m.UpdatedAt = now - s.items[m.ID] = m + now := time.Now().UTC().Format(time.RFC3339Nano) - if err := s.saveLocked(); err != nil { + var likedArg any + if liked.Valid { + likedArg = liked.Int64 + } else { + likedArg = nil + } + + _, err = s.db.Exec(` +UPDATE models +SET watching=?, favorite=?, hot=?, keep=?, liked=?, updated_at=? +WHERE id=?; +`, watching, favorite, hot, keep, likedArg, now, patch.ID) + if err != nil { return StoredModel{}, err } - return m, nil + + return s.getByID(patch.ID) } func (s *ModelStore) Delete(id string) error { + if err := s.ensureInit(); err != nil { + return err + } if id == "" { return errors.New("id fehlt") } + s.mu.Lock() defer s.mu.Unlock() - delete(s.items, id) - return s.saveLocked() + + _, err := s.db.Exec(`DELETE FROM models WHERE id=?;`, id) + return err +} + +func (s *ModelStore) getByID(id string) (StoredModel, error) { + var ( + input, host, path, modelKey, createdAt, updatedAt string + isURL, watching, favorite, hot, keep int64 + liked sql.NullInt64 + ) + + err := s.db.QueryRow(` +SELECT + input,is_url,host,path,model_key, + watching,favorite,hot,keep,liked, + created_at,updated_at +FROM models +WHERE id=?; +`, id).Scan( + &input, &isURL, &host, &path, &modelKey, + &watching, &favorite, &hot, &keep, &liked, + &createdAt, &updatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return StoredModel{}, errors.New("model nicht gefunden") + } + return StoredModel{}, err + } + + return StoredModel{ + ID: id, + Input: input, + IsURL: isURL != 0, + Host: host, + Path: path, + ModelKey: modelKey, + Watching: watching != 0, + Favorite: favorite != 0, + Hot: hot != 0, + Keep: keep != 0, + Liked: ptrLikedFromNull(liked), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, nil } diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..3da4261 --- /dev/null +++ b/frontend/.env @@ -0,0 +1 @@ +DATABASE_URL="file:./prisma/models.db" diff --git a/frontend/src/components/ui/ModelsTab.tsx b/frontend/src/components/ui/ModelsTab.tsx index 0f17889..c67918b 100644 --- a/frontend/src/components/ui/ModelsTab.tsx +++ b/frontend/src/components/ui/ModelsTab.tsx @@ -1,6 +1,7 @@ 'use client' import * as React from 'react' +import clsx from 'clsx' import Card from './Card' import Button from './Button' import Table, { type Column } from './Table' @@ -40,17 +41,72 @@ async function apiJSON(url: string, init?: RequestInit): Promise { const badge = (on: boolean, label: string) => ( {label} ) +/** Erlaubt nur http(s) URLs. Optional: ohne Scheme -> https:// */ +function normalizeHttpUrl(raw: string): string | null { + let v = (raw ?? '').trim() + if (!v) return null + + if (!/^https?:\/\//i.test(v)) v = `https://${v}` + + try { + const u = new URL(v) + if (u.protocol !== 'http:' && u.protocol !== 'https:') return null + return u.toString() + } catch { + return null + } +} + +function IconToggle({ + title, + active, + hiddenUntilHover, + onClick, + icon, +}: { + title: string + active?: boolean + hiddenUntilHover?: boolean + onClick: (e: React.MouseEvent) => void + icon: React.ReactNode +}) { + return ( + + + + ) +} + + export default function ModelsTab() { const [models, setModels] = React.useState([]) const [loading, setLoading] = React.useState(false) @@ -79,22 +135,36 @@ export default function ModelsTab() { refresh() }, [refresh]) - // Parse (debounced) via existing /api/models/parse + // Parse (debounced) nur bei gültiger URL React.useEffect(() => { - const v = input.trim() - if (!v) { + const raw = input.trim() + if (!raw) { setParsed(null) setParseError(null) return } - const t = setTimeout(async () => { + const normalized = normalizeHttpUrl(raw) + if (!normalized) { + setParsed(null) + setParseError('Bitte nur gültige http(s) URLs einfügen (keine Modelnamen).') + return + } + + const t = window.setTimeout(async () => { try { const p = await apiJSON('/api/models/parse', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: v }), + body: JSON.stringify({ input: normalized }), }) + + if (!p?.isUrl) { + setParsed(null) + setParseError('Bitte nur URLs einfügen (keine Modelnamen).') + return + } + setParsed(p) setParseError(null) } catch (e: any) { @@ -103,24 +173,25 @@ export default function ModelsTab() { } }, 300) - return () => clearTimeout(t) + return () => window.clearTimeout(t) }, [input]) const filtered = React.useMemo(() => { const needle = q.trim().toLowerCase() if (!needle) return models return models.filter((m) => { - const hay = [ - m.modelKey, - m.host ?? '', - m.input ?? '', - ].join(' ').toLowerCase() + const hay = [m.modelKey, m.host ?? '', m.input ?? ''].join(' ').toLowerCase() return hay.includes(needle) }) }, [models, q]) const upsertFromParsed = async () => { if (!parsed) return + if (!parsed.isUrl) { + setParseError('Bitte nur URLs einfügen (keine Modelnamen).') + return + } + setAdding(true) setErr(null) try { @@ -143,34 +214,107 @@ export default function ModelsTab() { } const patch = async (id: string, body: any) => { - const updated = await apiJSON('/api/models/flags', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id, ...body }), - }) - setModels((prev) => prev.map((m) => (m.id === updated.id ? updated : m))) - } - - const del = async (id: string) => { - await apiJSON('/api/models/delete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id }), - }) - setModels((prev) => prev.filter((m) => m.id !== id)) + setErr(null) + try { + const updated = await apiJSON('/api/models/flags', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, ...body }), + }) + setModels((prev) => prev.map((m) => (m.id === updated.id ? updated : m))) + } catch (e: any) { + setErr(e?.message ?? String(e)) + } } const columns = React.useMemo[]>(() => { 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 ( +
+ {/* Beobachten */} + + + + + {/* Favorit */} + { + e.stopPropagation() + if (fav) { + patch(m.id, { favorite: false }) + } else { + // exklusiv: Favorit setzt ♥ zurück + patch(m.id, { favorite: true, clearLiked: true }) + } + }} + icon={} + /> + + {/* Gefällt mir */} + { + e.stopPropagation() + if (liked) { + patch(m.id, { clearLiked: true }) + } else { + // exklusiv: ♥ setzt Favorit zurück + patch(m.id, { liked: true, favorite: false }) + } + }} + icon={} + /> +
+ ) + }, + }, + { key: 'model', header: 'Model', cell: (m) => (
{m.modelKey}
-
- {m.host ?? '—'} -
+
{m.host ?? '—'}
), }, @@ -182,7 +326,7 @@ export default function ModelsTab() { href={m.input} target="_blank" rel="noreferrer" - className="text-indigo-600 dark:text-indigo-400 hover:underline truncate block" + className="text-indigo-600 dark:text-indigo-400 hover:underline truncate block max-w-[520px]" onClick={(e) => e.stopPropagation()} title={m.input} > @@ -191,78 +335,35 @@ export default function ModelsTab() { ), }, { - key: 'flags', - header: 'Status', + key: 'tags', + header: 'Tags', cell: (m) => (
- {badge(m.watching, '👁 Beobachten')} - {badge(m.favorite, '★ Favorit')} - {badge(m.hot, '🔥 HOT')} - {badge(m.keep, '📌 Behalten')} - - {m.liked === true ? '👍' : m.liked === false ? '👎' : '—'} - -
- ), - }, - { - key: 'actions', - header: 'Aktion', - align: 'right', - srOnlyHeader: true, - cell: (m) => ( -
- - - - - - - - - - + {m.hot ? badge(true, '🔥 HOT') : null} + {m.keep ? badge(true, '📌 Behalten') : null}
), }, ] }, []) + return (
- Model hinzufügen
} - grayBody - > + Model hinzufügen} grayBody>
setInput(e.target.value)} - placeholder="URL oder Modelname…" + placeholder="https://…" className="flex-1 rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white" /> @@ -280,18 +381,14 @@ export default function ModelsTab() {
) : null} - {err ? ( -
{err}
- ) : null} + {err ?
{err}
: null}
-
- Models ({filtered.length}) -
+
Models ({filtered.length})
setQ(e.target.value)} @@ -307,10 +404,10 @@ export default function ModelsTab() { columns={columns} getRowKey={(m) => m.id} striped + compact fullWidth - onRowClick={(m) => { - if (m.input) window.open(m.input, '_blank', 'noreferrer') - }} + stickyHeader + onRowClick={(m) => m.input && window.open(m.input, '_blank', 'noreferrer')} />