updated
This commit is contained in:
parent
86ac5b9fb3
commit
d3deac7b36
BIN
backend/data/models_store.db
Normal file
BIN
backend/data/models_store.db
Normal file
Binary file not shown.
BIN
backend/data/models_store.db-shm
Normal file
BIN
backend/data/models_store.db-shm
Normal file
Binary file not shown.
BIN
backend/data/models_store.db-wal
Normal file
BIN
backend/data/models_store.db-wal
Normal file
Binary file not shown.
@ -11,6 +11,16 @@ require (
|
||||
require (
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.41.0 // indirect
|
||||
)
|
||||
|
||||
@ -4,11 +4,19 @@ github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2j
|
||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s=
|
||||
github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ=
|
||||
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
@ -18,6 +26,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@ -47,11 +57,14 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@ -77,3 +90,11 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.41.0 h1:bJXddp4ZpsqMsNN1vS0jWo4IJTZzb8nWpcgvyCFG9Ck=
|
||||
modernc.org/sqlite v1.41.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
|
||||
@ -98,7 +98,7 @@ func detectFFmpegPath() string {
|
||||
if p := strings.TrimSpace(s.FFmpegPath); p != "" {
|
||||
// Relativ zur EXE auflösen, falls nötig
|
||||
if !filepath.IsAbs(p) {
|
||||
if abs, err := resolvePathRelativeToExe(p); err == nil {
|
||||
if abs, err := resolvePathRelativeToApp(p); err == nil {
|
||||
p = abs
|
||||
}
|
||||
}
|
||||
@ -543,7 +543,7 @@ func recordPreview(w http.ResponseWriter, r *http.Request) {
|
||||
outPath = filepath.Clean(outPath)
|
||||
|
||||
if !filepath.IsAbs(outPath) {
|
||||
if abs, err := resolvePathRelativeToExe(outPath); err == nil {
|
||||
if abs, err := resolvePathRelativeToApp(outPath); err == nil {
|
||||
outPath = abs
|
||||
}
|
||||
}
|
||||
@ -586,8 +586,8 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
|
||||
}
|
||||
|
||||
s := getSettings()
|
||||
recordAbs, _ := resolvePathRelativeToExe(s.RecordDir)
|
||||
doneAbs, _ := resolvePathRelativeToExe(s.DoneDir)
|
||||
recordAbs, _ := resolvePathRelativeToApp(s.RecordDir)
|
||||
doneAbs, _ := resolvePathRelativeToApp(s.DoneDir)
|
||||
|
||||
candidates := []string{
|
||||
filepath.Join(doneAbs, id+".mp4"),
|
||||
@ -853,25 +853,41 @@ func extractFirstFrameJPEG(path string) ([]byte, error) {
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func resolvePathRelativeToExe(p string) (string, error) {
|
||||
func resolvePathRelativeToApp(p string) (string, error) {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// akzeptiere sowohl "records/done" als auch "records\done"
|
||||
p = filepath.Clean(filepath.FromSlash(p))
|
||||
|
||||
if filepath.IsAbs(p) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err == nil {
|
||||
exeDir := filepath.Dir(exe)
|
||||
low := strings.ToLower(exeDir)
|
||||
|
||||
// Heuristik: go run / tests -> 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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
1
frontend/.env
Normal file
1
frontend/.env
Normal file
@ -0,0 +1 @@
|
||||
DATABASE_URL="file:./prisma/models.db"
|
||||
@ -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<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
|
||||
const badge = (on: boolean, label: string) => (
|
||||
<span
|
||||
className={[
|
||||
className={clsx(
|
||||
'inline-flex items-center rounded-md px-2 py-0.5 text-xs',
|
||||
on
|
||||
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-200'
|
||||
: 'bg-gray-50 text-gray-600 dark:bg-white/5 dark:text-gray-300',
|
||||
].join(' ')}
|
||||
: 'bg-gray-50 text-gray-600 dark:bg-white/5 dark:text-gray-300'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
|
||||
/** 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 (
|
||||
<span
|
||||
className={clsx(
|
||||
hiddenUntilHover && !active
|
||||
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
||||
: 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant={active ? 'soft' : 'secondary'}
|
||||
size="xs"
|
||||
className={clsx(
|
||||
'px-2 py-1 leading-none',
|
||||
// damit es wie ein Icon-Button wirkt
|
||||
active ? '' : 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
|
||||
)}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="text-base leading-none">{icon}</span>
|
||||
</Button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default function ModelsTab() {
|
||||
const [models, setModels] = React.useState<StoredModel[]>([])
|
||||
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<ParsedModel>('/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<StoredModel>('/api/models/flags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, ...body }),
|
||||
})
|
||||
setModels((prev) => prev.map((m) => (m.id === updated.id ? updated : m)))
|
||||
}
|
||||
|
||||
const del = async (id: string) => {
|
||||
await apiJSON('/api/models/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
setModels((prev) => prev.filter((m) => m.id !== id))
|
||||
setErr(null)
|
||||
try {
|
||||
const updated = await apiJSON<StoredModel>('/api/models/flags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, ...body }),
|
||||
})
|
||||
setModels((prev) => prev.map((m) => (m.id === updated.id ? updated : m)))
|
||||
} catch (e: any) {
|
||||
setErr(e?.message ?? String(e))
|
||||
}
|
||||
}
|
||||
|
||||
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, { watching: !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, clearLiked: true })
|
||||
}
|
||||
}}
|
||||
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, { clearLiked: true })
|
||||
} 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 className="text-xs text-gray-500 dark:text-gray-400 truncate">{m.host ?? '—'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@ -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) => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{badge(m.watching, '👁 Beobachten')}
|
||||
{badge(m.favorite, '★ Favorit')}
|
||||
{badge(m.hot, '🔥 HOT')}
|
||||
{badge(m.keep, '📌 Behalten')}
|
||||
<span className="inline-flex items-center rounded-md px-2 py-0.5 text-xs bg-gray-50 text-gray-600 dark:bg-white/5 dark:text-gray-300">
|
||||
{m.liked === true ? '👍' : m.liked === false ? '👎' : '—'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Aktion',
|
||||
align: 'right',
|
||||
srOnlyHeader: true,
|
||||
cell: (m) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { watching: !m.watching }) }}>
|
||||
👁
|
||||
</Button>
|
||||
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { favorite: !m.favorite }) }}>
|
||||
★
|
||||
</Button>
|
||||
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { hot: !m.hot }) }}>
|
||||
🔥
|
||||
</Button>
|
||||
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { keep: !m.keep }) }}>
|
||||
📌
|
||||
</Button>
|
||||
|
||||
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { liked: true }) }}>
|
||||
👍
|
||||
</Button>
|
||||
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { liked: false }) }}>
|
||||
👎
|
||||
</Button>
|
||||
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { clearLiked: true }) }}>
|
||||
✕
|
||||
</Button>
|
||||
|
||||
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); del(m.id) }} title="Löschen">
|
||||
🗑
|
||||
</Button>
|
||||
{m.hot ? badge(true, '🔥 HOT') : null}
|
||||
{m.keep ? badge(true, '📌 Behalten') : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [])
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card
|
||||
header={<div className="text-sm font-medium text-gray-900 dark:text-white">Model hinzufügen</div>}
|
||||
grayBody
|
||||
>
|
||||
<Card header={<div className="text-sm font-medium text-gray-900 dark:text-white">Model hinzufügen</div>} grayBody>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="URL oder Modelname…"
|
||||
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"
|
||||
/>
|
||||
<Button
|
||||
className="px-3 py-2 text-sm"
|
||||
onClick={upsertFromParsed}
|
||||
disabled={!parsed || adding}
|
||||
title={!parsed ? 'Ungültig / nicht geparst' : 'In Models speichern'}
|
||||
title={!parsed ? 'Bitte gültige URL einfügen' : 'In Models speichern'}
|
||||
>
|
||||
Hinzufügen
|
||||
</Button>
|
||||
@ -280,18 +381,14 @@ export default function ModelsTab() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{err ? (
|
||||
<div className="text-xs text-red-600 dark:text-red-300">{err}</div>
|
||||
) : null}
|
||||
{err ? <div className="text-xs text-red-600 dark:text-red-300">{err}</div> : null}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
header={
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Models ({filtered.length})
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">Models ({filtered.length})</div>
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
@ -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')}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user