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 (
|
require (
|
||||||
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect
|
github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.3 // 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
|
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/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/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 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s=
|
||||||
github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
|
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 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ=
|
||||||
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
|
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=
|
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.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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.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-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.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.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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.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.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.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.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/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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
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.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
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=
|
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 != "" {
|
if p := strings.TrimSpace(s.FFmpegPath); p != "" {
|
||||||
// Relativ zur EXE auflösen, falls nötig
|
// Relativ zur EXE auflösen, falls nötig
|
||||||
if !filepath.IsAbs(p) {
|
if !filepath.IsAbs(p) {
|
||||||
if abs, err := resolvePathRelativeToExe(p); err == nil {
|
if abs, err := resolvePathRelativeToApp(p); err == nil {
|
||||||
p = abs
|
p = abs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -543,7 +543,7 @@ func recordPreview(w http.ResponseWriter, r *http.Request) {
|
|||||||
outPath = filepath.Clean(outPath)
|
outPath = filepath.Clean(outPath)
|
||||||
|
|
||||||
if !filepath.IsAbs(outPath) {
|
if !filepath.IsAbs(outPath) {
|
||||||
if abs, err := resolvePathRelativeToExe(outPath); err == nil {
|
if abs, err := resolvePathRelativeToApp(outPath); err == nil {
|
||||||
outPath = abs
|
outPath = abs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -586,8 +586,8 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
recordAbs, _ := resolvePathRelativeToExe(s.RecordDir)
|
recordAbs, _ := resolvePathRelativeToApp(s.RecordDir)
|
||||||
doneAbs, _ := resolvePathRelativeToExe(s.DoneDir)
|
doneAbs, _ := resolvePathRelativeToApp(s.DoneDir)
|
||||||
|
|
||||||
candidates := []string{
|
candidates := []string{
|
||||||
filepath.Join(doneAbs, id+".mp4"),
|
filepath.Join(doneAbs, id+".mp4"),
|
||||||
@ -853,25 +853,41 @@ func extractFirstFrameJPEG(path string) ([]byte, error) {
|
|||||||
return out.Bytes(), nil
|
return out.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvePathRelativeToExe(p string) (string, error) {
|
func resolvePathRelativeToApp(p string) (string, error) {
|
||||||
p = strings.TrimSpace(p)
|
p = strings.TrimSpace(p)
|
||||||
if p == "" {
|
if p == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// akzeptiere sowohl "records/done" als auch "records\done"
|
|
||||||
p = filepath.Clean(filepath.FromSlash(p))
|
p = filepath.Clean(filepath.FromSlash(p))
|
||||||
|
|
||||||
if filepath.IsAbs(p) {
|
if filepath.IsAbs(p) {
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
exe, err := os.Executable()
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
base := filepath.Dir(exe)
|
return filepath.Join(wd, p), nil
|
||||||
return filepath.Join(base, p), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// routes.go (package main)
|
// routes.go (package main)
|
||||||
@ -887,7 +903,9 @@ func registerRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/record/video", recordVideo)
|
mux.HandleFunc("/api/record/video", recordVideo)
|
||||||
mux.HandleFunc("/api/record/done", recordDoneList)
|
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)
|
store := NewModelStore(modelsPath)
|
||||||
if err := store.Load(); err != nil {
|
if err := store.Load(); err != nil {
|
||||||
fmt.Println("⚠️ models load:", err)
|
fmt.Println("⚠️ models load:", err)
|
||||||
@ -1094,8 +1112,8 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
recordAbs, _ := resolvePathRelativeToExe(s.RecordDir)
|
recordAbs, _ := resolvePathRelativeToApp(s.RecordDir)
|
||||||
doneAbs, _ := resolvePathRelativeToExe(s.DoneDir)
|
doneAbs, _ := resolvePathRelativeToApp(s.DoneDir)
|
||||||
|
|
||||||
candidates := []string{
|
candidates := []string{
|
||||||
filepath.Join(doneAbs, file), // bevorzugt doneDir
|
filepath.Join(doneAbs, file), // bevorzugt doneDir
|
||||||
@ -1148,7 +1166,7 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !filepath.IsAbs(outPath) {
|
if !filepath.IsAbs(outPath) {
|
||||||
abs, err := resolvePathRelativeToExe(outPath)
|
abs, err := resolvePathRelativeToApp(outPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "pfad auflösung fehlgeschlagen", http.StatusInternalServerError)
|
http.Error(w, "pfad auflösung fehlgeschlagen", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@ -1180,7 +1198,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
doneAbs, err := resolvePathRelativeToExe(s.DoneDir)
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@ -1304,7 +1322,7 @@ func moveToDoneDir(outputPath string) (string, error) {
|
|||||||
s := getSettings()
|
s := getSettings()
|
||||||
|
|
||||||
// ✅ doneDir relativ zur exe auflösen (funktion hast du schon)
|
// ✅ doneDir relativ zur exe auflösen (funktion hast du schon)
|
||||||
doneDirAbs, err := resolvePathRelativeToExe(s.DoneDir)
|
doneDirAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,22 @@
|
|||||||
|
// models_store.go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StoredModel struct {
|
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
|
Input string `json:"input"` // Original-URL/Eingabe
|
||||||
IsURL bool `json:"isUrl"` // vom Parser
|
IsURL bool `json:"isUrl"` // vom Parser
|
||||||
Host string `json:"host,omitempty"`
|
Host string `json:"host,omitempty"`
|
||||||
@ -30,94 +33,6 @@ type StoredModel struct {
|
|||||||
UpdatedAt string `json:"updatedAt"`
|
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 {
|
type ParsedModelDTO struct {
|
||||||
Input string `json:"input"`
|
Input string `json:"input"`
|
||||||
IsURL bool `json:"isUrl"`
|
IsURL bool `json:"isUrl"`
|
||||||
@ -126,7 +41,338 @@ type ParsedModelDTO struct {
|
|||||||
ModelKey string `json:"modelKey"`
|
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) {
|
func (s *ModelStore) UpsertFromParsed(p ParsedModelDTO) (StoredModel, error) {
|
||||||
|
if err := s.ensureInit(); err != nil {
|
||||||
|
return StoredModel{}, err
|
||||||
|
}
|
||||||
|
|
||||||
if p.ModelKey == "" {
|
if p.ModelKey == "" {
|
||||||
return StoredModel{}, errors.New("modelKey fehlt")
|
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() == "" {
|
if err != nil || u.Scheme == "" || u.Hostname() == "" {
|
||||||
return StoredModel{}, errors.New("Ungültige URL.")
|
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)
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
id := p.ModelKey
|
_, err = s.db.Exec(`
|
||||||
existing, ok := s.items[id]
|
INSERT INTO models (
|
||||||
if !ok {
|
id,input,is_url,host,path,model_key,
|
||||||
existing = StoredModel{
|
watching,favorite,hot,keep,liked,
|
||||||
ID: id,
|
created_at,updated_at
|
||||||
CreatedAt: now,
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
}
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
}
|
input=excluded.input,
|
||||||
|
is_url=excluded.is_url,
|
||||||
// Felder aktualisieren
|
host=excluded.host,
|
||||||
existing.Input = p.Input
|
path=excluded.path,
|
||||||
existing.IsURL = p.IsURL
|
model_key=excluded.model_key,
|
||||||
existing.Host = p.Host
|
updated_at=excluded.updated_at;
|
||||||
existing.Path = p.Path
|
`,
|
||||||
existing.ModelKey = p.ModelKey
|
id,
|
||||||
existing.UpdatedAt = now
|
u.String(),
|
||||||
|
int64(1),
|
||||||
s.items[id] = existing
|
host,
|
||||||
if err := s.saveLocked(); err != nil {
|
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 StoredModel{}, err
|
||||||
}
|
}
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModelFlagsPatch struct {
|
return s.getByID(id)
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) {
|
func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) {
|
||||||
|
if err := s.ensureInit(); err != nil {
|
||||||
|
return StoredModel{}, err
|
||||||
|
}
|
||||||
if patch.ID == "" {
|
if patch.ID == "" {
|
||||||
return StoredModel{}, errors.New("id fehlt")
|
return StoredModel{}, errors.New("id fehlt")
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
m, ok := s.items[patch.ID]
|
// aktuelle Flags lesen
|
||||||
if !ok {
|
var (
|
||||||
return StoredModel{}, errors.New("model nicht gefunden")
|
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 {
|
if patch.Watching != nil {
|
||||||
m.Watching = *patch.Watching
|
watching = boolToInt(*patch.Watching)
|
||||||
}
|
}
|
||||||
if patch.Favorite != nil {
|
if patch.Favorite != nil {
|
||||||
m.Favorite = *patch.Favorite
|
favorite = boolToInt(*patch.Favorite)
|
||||||
}
|
}
|
||||||
if patch.Hot != nil {
|
if patch.Hot != nil {
|
||||||
m.Hot = *patch.Hot
|
hot = boolToInt(*patch.Hot)
|
||||||
}
|
}
|
||||||
if patch.Keep != nil {
|
if patch.Keep != nil {
|
||||||
m.Keep = *patch.Keep
|
keep = boolToInt(*patch.Keep)
|
||||||
}
|
}
|
||||||
|
|
||||||
if patch.ClearLiked {
|
if patch.ClearLiked {
|
||||||
m.Liked = nil
|
liked = sql.NullInt64{Valid: false}
|
||||||
} else if patch.Liked != nil {
|
} else if patch.Liked != nil {
|
||||||
m.Liked = patch.Liked
|
liked = sql.NullInt64{Valid: true, Int64: boolToInt(*patch.Liked)}
|
||||||
}
|
}
|
||||||
|
|
||||||
m.UpdatedAt = now
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
s.items[m.ID] = m
|
|
||||||
|
|
||||||
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 StoredModel{}, err
|
||||||
}
|
}
|
||||||
return m, nil
|
|
||||||
|
return s.getByID(patch.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ModelStore) Delete(id string) error {
|
func (s *ModelStore) Delete(id string) error {
|
||||||
|
if err := s.ensureInit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return errors.New("id fehlt")
|
return errors.New("id fehlt")
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import Table, { type Column } from './Table'
|
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) => (
|
const badge = (on: boolean, label: string) => (
|
||||||
<span
|
<span
|
||||||
className={[
|
className={clsx(
|
||||||
'inline-flex items-center rounded-md px-2 py-0.5 text-xs',
|
'inline-flex items-center rounded-md px-2 py-0.5 text-xs',
|
||||||
on
|
on
|
||||||
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-200'
|
? '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',
|
: 'bg-gray-50 text-gray-600 dark:bg-white/5 dark:text-gray-300'
|
||||||
].join(' ')}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</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() {
|
export default function ModelsTab() {
|
||||||
const [models, setModels] = React.useState<StoredModel[]>([])
|
const [models, setModels] = React.useState<StoredModel[]>([])
|
||||||
const [loading, setLoading] = React.useState(false)
|
const [loading, setLoading] = React.useState(false)
|
||||||
@ -79,22 +135,36 @@ export default function ModelsTab() {
|
|||||||
refresh()
|
refresh()
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
// Parse (debounced) via existing /api/models/parse
|
// Parse (debounced) nur bei gültiger URL
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const v = input.trim()
|
const raw = input.trim()
|
||||||
if (!v) {
|
if (!raw) {
|
||||||
setParsed(null)
|
setParsed(null)
|
||||||
setParseError(null)
|
setParseError(null)
|
||||||
return
|
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 {
|
try {
|
||||||
const p = await apiJSON<ParsedModel>('/api/models/parse', {
|
const p = await apiJSON<ParsedModel>('/api/models/parse', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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)
|
setParsed(p)
|
||||||
setParseError(null)
|
setParseError(null)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -103,24 +173,25 @@ export default function ModelsTab() {
|
|||||||
}
|
}
|
||||||
}, 300)
|
}, 300)
|
||||||
|
|
||||||
return () => clearTimeout(t)
|
return () => window.clearTimeout(t)
|
||||||
}, [input])
|
}, [input])
|
||||||
|
|
||||||
const filtered = React.useMemo(() => {
|
const filtered = React.useMemo(() => {
|
||||||
const needle = q.trim().toLowerCase()
|
const needle = q.trim().toLowerCase()
|
||||||
if (!needle) return models
|
if (!needle) return models
|
||||||
return models.filter((m) => {
|
return models.filter((m) => {
|
||||||
const hay = [
|
const hay = [m.modelKey, m.host ?? '', m.input ?? ''].join(' ').toLowerCase()
|
||||||
m.modelKey,
|
|
||||||
m.host ?? '',
|
|
||||||
m.input ?? '',
|
|
||||||
].join(' ').toLowerCase()
|
|
||||||
return hay.includes(needle)
|
return hay.includes(needle)
|
||||||
})
|
})
|
||||||
}, [models, q])
|
}, [models, q])
|
||||||
|
|
||||||
const upsertFromParsed = async () => {
|
const upsertFromParsed = async () => {
|
||||||
if (!parsed) return
|
if (!parsed) return
|
||||||
|
if (!parsed.isUrl) {
|
||||||
|
setParseError('Bitte nur URLs einfügen (keine Modelnamen).')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setAdding(true)
|
setAdding(true)
|
||||||
setErr(null)
|
setErr(null)
|
||||||
try {
|
try {
|
||||||
@ -143,34 +214,107 @@ export default function ModelsTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const patch = async (id: string, body: any) => {
|
const patch = async (id: string, body: any) => {
|
||||||
const updated = await apiJSON<StoredModel>('/api/models/flags', {
|
setErr(null)
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const updated = await apiJSON<StoredModel>('/api/models/flags', {
|
||||||
body: JSON.stringify({ id, ...body }),
|
method: 'POST',
|
||||||
})
|
headers: { 'Content-Type': 'application/json' },
|
||||||
setModels((prev) => prev.map((m) => (m.id === updated.id ? updated : m)))
|
body: JSON.stringify({ id, ...body }),
|
||||||
}
|
})
|
||||||
|
setModels((prev) => prev.map((m) => (m.id === updated.id ? updated : m)))
|
||||||
const del = async (id: string) => {
|
} catch (e: any) {
|
||||||
await apiJSON('/api/models/delete', {
|
setErr(e?.message ?? String(e))
|
||||||
method: 'POST',
|
}
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ id }),
|
|
||||||
})
|
|
||||||
setModels((prev) => prev.filter((m) => m.id !== id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = React.useMemo<Column<StoredModel>[]>(() => {
|
const columns = React.useMemo<Column<StoredModel>[]>(() => {
|
||||||
return [
|
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',
|
key: 'model',
|
||||||
header: 'Model',
|
header: 'Model',
|
||||||
cell: (m) => (
|
cell: (m) => (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-medium truncate">{m.modelKey}</div>
|
<div className="font-medium truncate">{m.modelKey}</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{m.host ?? '—'}</div>
|
||||||
{m.host ?? '—'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -182,7 +326,7 @@ export default function ModelsTab() {
|
|||||||
href={m.input}
|
href={m.input}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
title={m.input}
|
title={m.input}
|
||||||
>
|
>
|
||||||
@ -191,78 +335,35 @@ export default function ModelsTab() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'flags',
|
key: 'tags',
|
||||||
header: 'Status',
|
header: 'Tags',
|
||||||
cell: (m) => (
|
cell: (m) => (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{badge(m.watching, '👁 Beobachten')}
|
{m.hot ? badge(true, '🔥 HOT') : null}
|
||||||
{badge(m.favorite, '★ Favorit')}
|
{m.keep ? badge(true, '📌 Behalten') : null}
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card
|
<Card header={<div className="text-sm font-medium text-gray-900 dark:text-white">Model hinzufügen</div>} grayBody>
|
||||||
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="grid gap-2">
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<input
|
<input
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
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"
|
className="flex-1 rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="px-3 py-2 text-sm"
|
className="px-3 py-2 text-sm"
|
||||||
onClick={upsertFromParsed}
|
onClick={upsertFromParsed}
|
||||||
disabled={!parsed || adding}
|
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
|
Hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
@ -280,18 +381,14 @@ export default function ModelsTab() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{err ? (
|
{err ? <div className="text-xs text-red-600 dark:text-red-300">{err}</div> : null}
|
||||||
<div className="text-xs text-red-600 dark:text-red-300">{err}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
header={
|
header={
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
<div className="text-sm font-medium text-gray-900 dark:text-white">Models ({filtered.length})</div>
|
||||||
Models ({filtered.length})
|
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
@ -307,10 +404,10 @@ export default function ModelsTab() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
getRowKey={(m) => m.id}
|
getRowKey={(m) => m.id}
|
||||||
striped
|
striped
|
||||||
|
compact
|
||||||
fullWidth
|
fullWidth
|
||||||
onRowClick={(m) => {
|
stickyHeader
|
||||||
if (m.input) window.open(m.input, '_blank', 'noreferrer')
|
onRowClick={(m) => m.input && window.open(m.input, '_blank', 'noreferrer')}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user