bugfixes and ui optimizations
This commit is contained in:
parent
091f143384
commit
fd3442500c
1
backend/build.bat
Normal file
1
backend/build.bat
Normal file
@ -0,0 +1 @@
|
||||
go build -ldflags="-H=windowsgui" -o nsfwapp.exe
|
||||
@ -2521,6 +2521,120 @@ func releaseFileForMutation(file string) {
|
||||
}
|
||||
}
|
||||
|
||||
func generatedAssetIDFromTrashFileName(name string) string {
|
||||
name = strings.TrimSpace(filepath.Base(name))
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Keine Meta-Dateien als Video behandeln.
|
||||
if strings.EqualFold(name, "last.json") || strings.HasSuffix(strings.ToLower(name), ".json") {
|
||||
return ""
|
||||
}
|
||||
|
||||
originalFile := ""
|
||||
|
||||
// Neue Trash-Namen sehen so aus:
|
||||
// <token>__<original-file.mp4>
|
||||
//
|
||||
// Token kann theoretisch "_" enthalten, deshalb alle "__"-Positionen testen.
|
||||
searchFrom := 0
|
||||
for {
|
||||
idx := strings.Index(name[searchFrom:], "__")
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
|
||||
pos := searchFrom + idx
|
||||
candidate := strings.TrimSpace(name[pos+2:])
|
||||
|
||||
if isSafeBasename(candidate) && isAllowedVideoExt(candidate) {
|
||||
originalFile = candidate
|
||||
break
|
||||
}
|
||||
|
||||
searchFrom = pos + 2
|
||||
if searchFrom >= len(name) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy-Fallback: Trash-Datei heißt direkt wie das Video.
|
||||
if originalFile == "" && isSafeBasename(name) && isAllowedVideoExt(name) {
|
||||
originalFile = name
|
||||
}
|
||||
|
||||
if originalFile == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
id := canonicalAssetIDFromName(originalFile)
|
||||
if strings.TrimSpace(id) != "" {
|
||||
return strings.TrimSpace(id)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(stripHotPrefix(
|
||||
strings.TrimSuffix(filepath.Base(originalFile), filepath.Ext(originalFile)),
|
||||
))
|
||||
}
|
||||
|
||||
func buildTrashGeneratedAssetIDMap(trashDir string, entries []os.DirEntry) map[string]string {
|
||||
out := make(map[string]string, len(entries))
|
||||
|
||||
// 1) Zuerst Meta-Dateien lesen. Das ist am zuverlässigsten.
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".json") || strings.EqualFold(name, "last.json") {
|
||||
continue
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(filepath.Join(trashDir, name))
|
||||
if err != nil || len(b) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var meta trashMeta
|
||||
if err := json.Unmarshal(b, &meta); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !isSafeBasename(meta.TrashName) || !isSafeBasename(meta.File) || !isAllowedVideoExt(meta.File) {
|
||||
continue
|
||||
}
|
||||
|
||||
id := canonicalAssetIDFromName(meta.File)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
id = stripHotPrefix(strings.TrimSuffix(filepath.Base(meta.File), filepath.Ext(meta.File)))
|
||||
}
|
||||
|
||||
if strings.TrimSpace(id) != "" {
|
||||
out[meta.TrashName] = strings.TrimSpace(id)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Fallback über Dateinamen, falls Meta fehlt.
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := e.Name()
|
||||
if _, exists := out[name]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
if id := generatedAssetIDFromTrashFileName(name); id != "" {
|
||||
out[name] = id
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func cleanupTrashKeepOnlyLatest(trashDir, keepToken, keepTrashName string) {
|
||||
trashDir = filepath.Clean(strings.TrimSpace(trashDir))
|
||||
keepToken = strings.TrimSpace(keepToken)
|
||||
@ -2538,20 +2652,25 @@ func cleanupTrashKeepOnlyLatest(trashDir, keepToken, keepTrashName string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Wichtig:
|
||||
// Generated-Assets erst dann löschen, wenn der dazugehörige Trash-Eintrag
|
||||
// endgültig aus .trash entfernt wird.
|
||||
generatedIDByTrashName := buildTrashGeneratedAssetIDMap(trashDir, entries)
|
||||
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
|
||||
// Das zuletzt gelöschte Video behalten
|
||||
// Das zuletzt gelöschte Video behalten.
|
||||
if name == keepTrashName {
|
||||
continue
|
||||
}
|
||||
|
||||
// Token-Meta für Undo behalten
|
||||
// Token-Meta für Undo behalten.
|
||||
if name == keepMetaName {
|
||||
continue
|
||||
}
|
||||
|
||||
// last.json behalten, damit Debug/Komfort weiterhin funktioniert
|
||||
// last.json behalten, damit Debug/Komfort weiterhin funktioniert.
|
||||
if name == "last.json" {
|
||||
continue
|
||||
}
|
||||
@ -2565,6 +2684,12 @@ func cleanupTrashKeepOnlyLatest(trashDir, keepToken, keepTrashName string) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Nur wenn diese Trash-Datei jetzt endgültig gelöscht wird,
|
||||
// werden die generated Assets entfernt.
|
||||
if id := generatedIDByTrashName[name]; strings.TrimSpace(id) != "" {
|
||||
removeGeneratedForID(id)
|
||||
}
|
||||
|
||||
if err := removeWithRetry(full); err != nil {
|
||||
appLogln("⚠️ trash cleanup file failed:", full, err)
|
||||
}
|
||||
|
||||
@ -56,15 +56,12 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
||||
api.HandleFunc("/api/settings/cleanup", settingsCleanupHandler)
|
||||
|
||||
api.HandleFunc("/api/pending-autostart", handlePendingAutoStart(auth))
|
||||
|
||||
api.HandleFunc("/api/record", startRecordingFromRequest)
|
||||
api.HandleFunc("/api/record/status", recordStatus)
|
||||
api.HandleFunc("/api/record/stop", recordStop)
|
||||
api.HandleFunc("/api/record/stop-all", recordStopAll)
|
||||
api.HandleFunc("/api/record/postwork/remove", recordRemoveQueuedPostwork)
|
||||
api.HandleFunc("/api/preview", recordPreview)
|
||||
api.HandleFunc("/api/preview/live", recordPreviewLive)
|
||||
api.HandleFunc("/api/preview-scrubber/", recordPreviewScrubberFrame)
|
||||
api.HandleFunc("/api/preview-sprite/", recordPreviewSprite)
|
||||
api.HandleFunc("/api/record/list", recordList)
|
||||
api.HandleFunc("/api/record/done/meta", recordDoneMeta)
|
||||
api.HandleFunc("/api/record/video", recordVideo)
|
||||
@ -83,6 +80,11 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
||||
api.HandleFunc("/api/record/concurrency", recordConcurrentDownloadsStatus)
|
||||
api.HandleFunc("/api/record/release-file", handleReleaseFileTasks)
|
||||
|
||||
api.HandleFunc("/api/preview", recordPreview)
|
||||
api.HandleFunc("/api/preview/live", recordPreviewLive)
|
||||
api.HandleFunc("/api/preview-scrubber/", recordPreviewScrubberFrame)
|
||||
api.HandleFunc("/api/preview-sprite/", recordPreviewSprite)
|
||||
|
||||
// Training / ML Annotation
|
||||
api.HandleFunc("/api/training/labels", trainingLabelsHandler)
|
||||
api.HandleFunc("/api/training/next", trainingNextHandler)
|
||||
|
||||
@ -1309,34 +1309,59 @@ func trainingFrameSecondsForVideo(duration float64, count int) []float64 {
|
||||
return []float64{0}
|
||||
}
|
||||
|
||||
out := make([]float64, 0, count)
|
||||
|
||||
minSec := 0.5
|
||||
maxSec := duration - 0.5
|
||||
if maxSec < 0 {
|
||||
maxSec = duration
|
||||
|
||||
if maxSec <= minSec {
|
||||
return []float64{0}
|
||||
}
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
ratio := float64(i+1) / float64(count+1)
|
||||
sec := duration * ratio
|
||||
out := make([]float64, 0, count)
|
||||
|
||||
if sec < 0.5 {
|
||||
sec = 0.5
|
||||
}
|
||||
if sec > maxSec {
|
||||
sec = maxSec
|
||||
}
|
||||
// Lokaler RNG, damit jeder Import neue Frames bekommt.
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
// Auf 0.1s runden, damit SampleIDs lesbarer/stabiler sind.
|
||||
// Mindestabstand zwischen Frames, damit nicht mehrfach fast dieselbe Stelle kommt.
|
||||
minDistance := 0.4
|
||||
|
||||
// Bei sehr kurzen Videos Abstand automatisch verkleinern.
|
||||
availableRange := maxSec - minSec
|
||||
if availableRange/float64(count) < minDistance {
|
||||
minDistance = math.Max(0.1, availableRange/float64(count+1))
|
||||
}
|
||||
|
||||
const maxAttempts = 4000
|
||||
|
||||
for attempts := 0; len(out) < count && attempts < maxAttempts; attempts++ {
|
||||
sec := minSec + rng.Float64()*(maxSec-minSec)
|
||||
|
||||
// Auf 0.1s runden, damit IDs/Logs lesbar bleiben.
|
||||
sec = math.Round(sec*10) / 10
|
||||
|
||||
if len(out) > 0 && math.Abs(sec-out[len(out)-1]) < 0.2 {
|
||||
tooClose := false
|
||||
for _, existing := range out {
|
||||
if math.Abs(sec-existing) < minDistance {
|
||||
tooClose = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if tooClose {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, sec)
|
||||
}
|
||||
|
||||
// Fallback, falls ein extrem kurzes Video nicht genug unterschiedliche Random-Punkte hergibt.
|
||||
for len(out) < count {
|
||||
sec := minSec + rng.Float64()*(maxSec-minSec)
|
||||
sec = math.Round(sec*10) / 10
|
||||
out = append(out, sec)
|
||||
}
|
||||
|
||||
sort.Float64s(out)
|
||||
|
||||
if len(out) == 0 {
|
||||
out = append(out, 0)
|
||||
}
|
||||
@ -1406,7 +1431,7 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
seconds := trainingFrameSecondsForVideo(duration, req.Count)
|
||||
sourceFile := filepath.Base(outPath)
|
||||
previewURL := trainingPreviewURLForVideoPath(outPath)
|
||||
previewURL := ""
|
||||
|
||||
requestID := strings.TrimSpace(req.AnalysisRequestID)
|
||||
if requestID == "" {
|
||||
@ -1455,6 +1480,12 @@ func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ab hier existiert das erste echte extrahierte Bild.
|
||||
// Dieses bleibt als Overlay-Background für Analyse/Speichern/weitere Frames.
|
||||
if previewURL == "" {
|
||||
previewURL = "/api/training/frame?id=" + url.QueryEscape(id)
|
||||
}
|
||||
|
||||
trainingPublishAnalysisStepWithPreview(
|
||||
requestID,
|
||||
startedAtMs,
|
||||
@ -3426,7 +3457,7 @@ func trainingCreateNextSampleWithProgressRange(
|
||||
}
|
||||
|
||||
sourceFile := filepath.Base(videoPath)
|
||||
previewURL := trainingPreviewURLForVideoPath(videoPath)
|
||||
previewURL := ""
|
||||
|
||||
publishStep = func(localStep int, sourceFile string, message string) {
|
||||
trainingPublishAnalysisStepWithPreview(
|
||||
@ -3477,6 +3508,8 @@ func trainingCreateNextSampleWithProgressRange(
|
||||
}
|
||||
}
|
||||
|
||||
previewURL = "/api/training/frame?id=" + url.QueryEscape(id)
|
||||
|
||||
publishStep(3, sourceFile, "Bild wird analysiert…")
|
||||
|
||||
prediction := trainingPredictFrame(framePath)
|
||||
|
||||
@ -53,6 +53,29 @@ const TOAST_LEAVE_MS = 220
|
||||
const TOAST_SWIPE_DISMISS_PX = 96
|
||||
const TOAST_SWIPE_LOCK_PX = 10
|
||||
|
||||
const TOAST_MESSAGE_MAX_CHARS = 260
|
||||
|
||||
const toastMessageClampStyle: CSSProperties = {
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}
|
||||
|
||||
function compactToastMessage(input?: string, max = TOAST_MESSAGE_MAX_CHARS) {
|
||||
const text = String(input || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
if (!text) return ''
|
||||
|
||||
if (text.length <= max) {
|
||||
return text
|
||||
}
|
||||
|
||||
return `${text.slice(0, max).trimEnd()}…`
|
||||
}
|
||||
|
||||
function iconFor(type: ToastType) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
@ -517,7 +540,9 @@ export function ToastProvider({
|
||||
const { Icon, cls } = iconFor(t.type)
|
||||
const accents = accentFor(t.type)
|
||||
const title = (t.title || '').trim() || titleDefault(t.type)
|
||||
const msg = (t.message || '').trim()
|
||||
const fullMsg = (t.message || '').trim()
|
||||
const msg = compactToastMessage(fullMsg)
|
||||
const msgWasShortened = fullMsg.length > msg.length
|
||||
const img = (t.imageUrl || '').trim()
|
||||
const imgAlt = (t.imageAlt || title).trim()
|
||||
const isClickable = typeof t.onClick === 'function'
|
||||
@ -622,7 +647,11 @@ export function ToastProvider({
|
||||
</p>
|
||||
|
||||
{msg ? (
|
||||
<p className="mt-0.5 text-sm leading-5 text-gray-600 dark:text-gray-300 break-words">
|
||||
<p
|
||||
className="mt-0.5 text-sm leading-5 text-gray-600 dark:text-gray-300 break-words"
|
||||
style={toastMessageClampStyle}
|
||||
title={msgWasShortened ? fullMsg : undefined}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
) : null}
|
||||
@ -674,7 +703,11 @@ export function ToastProvider({
|
||||
</p>
|
||||
|
||||
{msg ? (
|
||||
<p className="mt-0.5 text-sm leading-5 text-gray-600 dark:text-gray-300 break-words">
|
||||
<p
|
||||
className="mt-0.5 text-sm leading-5 text-gray-600 dark:text-gray-300 break-words"
|
||||
style={toastMessageClampStyle}
|
||||
title={msgWasShortened ? fullMsg : undefined}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@ -763,10 +763,29 @@ function TrainingStageOverlay(props: {
|
||||
text?: string
|
||||
progress?: number
|
||||
backgroundUrl?: string
|
||||
visible?: boolean
|
||||
}) {
|
||||
const progress = clampPercent(props.progress ?? 0)
|
||||
const isTraining = props.mode === 'training'
|
||||
const hasBackground = !isTraining && Boolean(props.backgroundUrl)
|
||||
const visible = props.visible ?? true
|
||||
|
||||
const [backgroundVisible, setBackgroundVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.backgroundUrl || isTraining) {
|
||||
setBackgroundVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
setBackgroundVisible(false)
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
setBackgroundVisible(true)
|
||||
})
|
||||
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [props.backgroundUrl, isTraining])
|
||||
|
||||
const title = isTraining ? 'Training läuft…' : 'Analyse läuft…'
|
||||
const fallbackText = isTraining
|
||||
@ -774,15 +793,26 @@ function TrainingStageOverlay(props: {
|
||||
: 'Bild wird erstellt und analysiert. Bitte warten.'
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-[500] flex items-center justify-center overflow-hidden rounded-md bg-black text-center text-white">
|
||||
<div
|
||||
className={[
|
||||
'absolute inset-0 z-[500] flex items-center justify-center overflow-hidden rounded-md bg-black text-center text-white',
|
||||
'transition-opacity duration-300 ease-out will-change-opacity motion-reduce:transition-none',
|
||||
visible ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="absolute inset-1 overflow-hidden rounded-md sm:inset-2">
|
||||
{hasBackground ? (
|
||||
<img
|
||||
key={props.backgroundUrl}
|
||||
src={props.backgroundUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className="absolute inset-0 z-0 h-full w-full scale-105 object-cover opacity-80 blur-[1px]"
|
||||
className={[
|
||||
'absolute inset-0 z-0 h-full w-full scale-105 object-cover blur-[1px]',
|
||||
'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none',
|
||||
backgroundVisible ? 'opacity-80' : 'opacity-0',
|
||||
].join(' ')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -2016,6 +2046,9 @@ export default function TrainingTab(props: {
|
||||
const [loadingPreviewLoaded, setLoadingPreviewLoaded] = useState(false)
|
||||
const [loadingPreviewFailed, setLoadingPreviewFailed] = useState(false)
|
||||
|
||||
const [stageOverlayMounted, setStageOverlayMounted] = useState(false)
|
||||
const [stageOverlayVisible, setStageOverlayVisible] = useState(false)
|
||||
|
||||
const imageBoxRef = useRef<HTMLDivElement | null>(null)
|
||||
const frameImageRef = useRef<HTMLImageElement | null>(null)
|
||||
|
||||
@ -2762,9 +2795,7 @@ export default function TrainingTab(props: {
|
||||
const output = String(raw?.output || '').trim()
|
||||
if (!output) return false
|
||||
|
||||
setLoadingPreviewCandidate(
|
||||
`/api/training/video-preview?output=${encodeURIComponent(output)}`
|
||||
)
|
||||
setLoadingPreviewCandidate('')
|
||||
|
||||
const detail: PendingTrainingVideoImport = {
|
||||
jobId: String(raw?.jobId || '').trim(),
|
||||
@ -3418,7 +3449,14 @@ export default function TrainingTab(props: {
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
console.error('Feedback speichern fehlgeschlagen:', e)
|
||||
|
||||
const raw = e instanceof Error ? e.message : String(e)
|
||||
const short = raw.length > 220
|
||||
? `${raw.slice(0, 220).trimEnd()}…`
|
||||
: raw
|
||||
|
||||
setError(`Feedback konnte nicht gespeichert werden. ${short}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@ -4693,12 +4731,19 @@ export default function TrainingTab(props: {
|
||||
smDvh: 60,
|
||||
lgDvh: 78,
|
||||
lgPx: 820,
|
||||
|
||||
// Platz für:
|
||||
// - Speichern/Überspringen
|
||||
// - Bild vergrößern/Normal anzeigen
|
||||
// - Abstände
|
||||
bottomReservePx: 158,
|
||||
}
|
||||
: {
|
||||
baseDvh: 44,
|
||||
smDvh: 52,
|
||||
lgDvh: 64,
|
||||
lgPx: 680,
|
||||
bottomReservePx: 138,
|
||||
}
|
||||
|
||||
const imageStageStyle = {
|
||||
@ -4706,7 +4751,7 @@ export default function TrainingTab(props: {
|
||||
|
||||
'--image-stage-max-h': `${imageStageLimits.baseDvh}dvh`,
|
||||
'--image-stage-max-h-sm': `${imageStageLimits.smDvh}dvh`,
|
||||
'--image-stage-max-h-lg': `min(${imageStageLimits.lgDvh}dvh, ${imageStageLimits.lgPx}px)`,
|
||||
'--image-stage-max-h-lg': `min(calc(100dvh - ${imageStageLimits.bottomReservePx}px - env(safe-area-inset-bottom)), ${imageStageLimits.lgDvh}dvh, ${imageStageLimits.lgPx}px)`,
|
||||
|
||||
'--image-stage-w': `${imageStageLimits.baseDvh * imageAspectRatio}dvh`,
|
||||
'--image-stage-w-sm': `${imageStageLimits.smDvh * imageAspectRatio}dvh`,
|
||||
@ -4739,6 +4784,31 @@ export default function TrainingTab(props: {
|
||||
? analysisProgress
|
||||
: 100
|
||||
|
||||
const stageOverlayFadeMs = 300
|
||||
|
||||
useEffect(() => {
|
||||
if (stageBusy) {
|
||||
setStageOverlayMounted(true)
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
setStageOverlayVisible(true)
|
||||
})
|
||||
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}
|
||||
|
||||
setStageOverlayVisible(false)
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setStageOverlayMounted(false)
|
||||
}, stageOverlayFadeMs)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [stageBusy])
|
||||
|
||||
const renderStageOverlay = stageBusy || stageOverlayMounted
|
||||
const stageOverlayIsVisible = stageBusy && stageOverlayVisible
|
||||
|
||||
return (
|
||||
<div className="min-h-full lg:h-full lg:min-h-0 lg:overflow-hidden">
|
||||
{loadingPreviewUrl ? (
|
||||
@ -4933,6 +5003,8 @@ export default function TrainingTab(props: {
|
||||
className={[
|
||||
'block h-auto min-h-0 max-h-full max-w-full rounded-md object-contain',
|
||||
'select-none',
|
||||
'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none',
|
||||
frameImageLoaded ? 'opacity-100' : 'opacity-0',
|
||||
imageTouchClass,
|
||||
'[-webkit-user-drag:none] [-webkit-touch-callout:none]',
|
||||
].join(' ')}
|
||||
@ -5358,11 +5430,12 @@ export default function TrainingTab(props: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stageBusy ? (
|
||||
{renderStageOverlay ? (
|
||||
<TrainingStageOverlay
|
||||
mode={stageOverlayMode}
|
||||
text={stageOverlayText}
|
||||
progress={stageOverlayProgress}
|
||||
visible={stageOverlayIsVisible}
|
||||
backgroundUrl={
|
||||
stageOverlayMode === 'analysis'
|
||||
? loadingPreviewBackgroundUrl
|
||||
@ -5374,9 +5447,9 @@ export default function TrainingTab(props: {
|
||||
|
||||
<div
|
||||
className={[
|
||||
'z-10 grid grid-cols-2 gap-2',
|
||||
'z-10 grid shrink-0 grid-cols-2 gap-2',
|
||||
imageExpanded
|
||||
? 'mt-3 shrink-0'
|
||||
? 'mt-3'
|
||||
: 'sticky bottom-0 mt-3 sm:static sm:mt-4',
|
||||
].join(' ')}
|
||||
>
|
||||
@ -5461,7 +5534,12 @@ export default function TrainingTab(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 hidden text-center text-xs text-gray-500 dark:text-gray-400 lg:block">
|
||||
<div
|
||||
className={[
|
||||
'mt-2 hidden text-center text-xs text-gray-500 dark:text-gray-400',
|
||||
imageExpanded ? 'lg:hidden' : 'lg:block',
|
||||
].join(' ')}
|
||||
>
|
||||
Prüfe das Bild. Wenn die Erkennung stimmt: „Passt so“. Wenn nicht: korrigieren und speichern.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user