fixed finished downloads
This commit is contained in:
parent
1625952638
commit
1a559fe76d
Binary file not shown.
@ -224,6 +224,21 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Neuer Cleanup-Abschnitt: alten 100%-Fortschritt vom Small-Files-Scan zurücksetzen.
|
||||||
|
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
|
||||||
|
st.Queued = false
|
||||||
|
st.Running = true
|
||||||
|
if st.StartedAt.IsZero() {
|
||||||
|
st.StartedAt = time.Now()
|
||||||
|
}
|
||||||
|
st.Done = 0
|
||||||
|
st.Total = 0
|
||||||
|
st.CurrentFile = ""
|
||||||
|
st.Text = "Räume Record-Reste auf…"
|
||||||
|
st.Error = ""
|
||||||
|
st.FinishedAt = nil
|
||||||
|
})
|
||||||
|
|
||||||
if err := cleanupRecordDirOrphanAVFiles(ctx, jobID, recordAbs, &resp); err != nil {
|
if err := cleanupRecordDirOrphanAVFiles(ctx, jobID, recordAbs, &resp); err != nil {
|
||||||
finishCleanupJob(jobID, "", err)
|
finishCleanupJob(jobID, "", err)
|
||||||
return
|
return
|
||||||
@ -236,7 +251,19 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
|
||||||
|
st.Queued = false
|
||||||
|
st.Running = true
|
||||||
|
st.Done = 0
|
||||||
|
st.Total = 0
|
||||||
|
st.CurrentFile = ""
|
||||||
|
st.Text = "Räume Generated-Assets auf…"
|
||||||
|
st.Error = ""
|
||||||
|
st.FinishedAt = nil
|
||||||
|
})
|
||||||
|
|
||||||
gcStats := triggerGeneratedGarbageCollectorSync()
|
gcStats := triggerGeneratedGarbageCollectorSync()
|
||||||
|
|
||||||
resp.GeneratedOrphansChecked = gcStats.Checked
|
resp.GeneratedOrphansChecked = gcStats.Checked
|
||||||
resp.GeneratedOrphansRemoved = gcStats.Removed
|
resp.GeneratedOrphansRemoved = gcStats.Removed
|
||||||
resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes
|
resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes
|
||||||
@ -494,8 +521,14 @@ func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
|
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
|
||||||
|
st.Queued = false
|
||||||
|
st.Running = true
|
||||||
|
st.Done = 0
|
||||||
|
st.Total = 0
|
||||||
st.CurrentFile = name
|
st.CurrentFile = name
|
||||||
st.Text = "Räume Record-Reste auf…"
|
st.Text = "Räume Record-Reste auf…"
|
||||||
|
st.Error = ""
|
||||||
|
st.FinishedAt = nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) {
|
if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
|||||||
@ -108,7 +108,7 @@ func runTray(onQuit func(), statsFn func() TrayStats) {
|
|||||||
updateStatus()
|
updateStatus()
|
||||||
|
|
||||||
case <-mOpenFrontend.ClickedCh:
|
case <-mOpenFrontend.ClickedCh:
|
||||||
_ = openBrowser("http://localhost:9999")
|
_ = openBrowser("https://localhost:9999")
|
||||||
|
|
||||||
case <-mOpenRecordDir.ClickedCh:
|
case <-mOpenRecordDir.ClickedCh:
|
||||||
_ = openFolder(resolveConfiguredDir(getSettings().RecordDir))
|
_ = openFolder(resolveConfiguredDir(getSettings().RecordDir))
|
||||||
|
|||||||
@ -914,10 +914,16 @@ export default function App() {
|
|||||||
type DonePrefetch = {
|
type DonePrefetch = {
|
||||||
key: string
|
key: string
|
||||||
items: RecordJob[]
|
items: RecordJob[]
|
||||||
|
totalCount: number
|
||||||
ts: number
|
ts: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}`
|
const makePrefetchKey = (
|
||||||
|
page: number,
|
||||||
|
sort: DoneSortMode,
|
||||||
|
pageSize: number,
|
||||||
|
keep: boolean
|
||||||
|
) => `${sort}::${pageSize}::${keep ? 1 : 0}::${page}`
|
||||||
|
|
||||||
const getDoneTotalPages = (count: number, pageSize = donePageSize) => {
|
const getDoneTotalPages = (count: number, pageSize = donePageSize) => {
|
||||||
const safeCount = Number.isFinite(count) && count > 0 ? count : 0
|
const safeCount = Number.isFinite(count) && count > 0 ? count : 0
|
||||||
@ -928,7 +934,7 @@ export default function App() {
|
|||||||
if (pageToFetch < 1) return
|
if (pageToFetch < 1) return
|
||||||
if (donePrefetchInFlightRef.current) return
|
if (donePrefetchInFlightRef.current) return
|
||||||
|
|
||||||
const key = makePrefetchKey(pageToFetch, doneSort)
|
const key = makePrefetchKey(pageToFetch, doneSort, donePageSize, includeKeep)
|
||||||
const cur = donePrefetchRef.current
|
const cur = donePrefetchRef.current
|
||||||
if (cur?.key === key && Date.now() - cur.ts < 15_000) {
|
if (cur?.key === key && Date.now() - cur.ts < 15_000) {
|
||||||
// frisch genug
|
// frisch genug
|
||||||
@ -938,7 +944,7 @@ export default function App() {
|
|||||||
donePrefetchInFlightRef.current = true
|
donePrefetchInFlightRef.current = true
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/record/done?page=${pageToFetch}&pageSize=${donePageSize}&sort=${encodeURIComponent(doneSort)}${
|
`/api/record/done?page=${pageToFetch}&pageSize=${donePageSize}&sort=${encodeURIComponent(doneSort)}&withCount=1${
|
||||||
includeKeep ? '&includeKeep=1' : ''
|
includeKeep ? '&includeKeep=1' : ''
|
||||||
}`,
|
}`,
|
||||||
{ cache: 'no-store' as any }
|
{ cache: 'no-store' as any }
|
||||||
@ -952,66 +958,23 @@ export default function App() {
|
|||||||
? (data as RecordJob[])
|
? (data as RecordJob[])
|
||||||
: []
|
: []
|
||||||
|
|
||||||
donePrefetchRef.current = { key, items, ts: Date.now() }
|
const totalCountRaw = Number(data?.count ?? data?.totalCount ?? 0)
|
||||||
|
const totalCount =
|
||||||
|
Number.isFinite(totalCountRaw) && totalCountRaw >= 0
|
||||||
|
? totalCountRaw
|
||||||
|
: 0
|
||||||
|
|
||||||
|
donePrefetchRef.current = {
|
||||||
|
key,
|
||||||
|
items,
|
||||||
|
totalCount,
|
||||||
|
ts: Date.now(),
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
donePrefetchInFlightRef.current = false
|
donePrefetchInFlightRef.current = false
|
||||||
}
|
}
|
||||||
}, [doneSort, includeKeep, donePageSize])
|
}, [doneSort, includeKeep, donePageSize])
|
||||||
|
|
||||||
function doneJobMergeKey(job: RecordJob): string {
|
|
||||||
const output = String(job?.output ?? '').trim()
|
|
||||||
const id = String((job as any)?.id ?? '').trim()
|
|
||||||
|
|
||||||
if (output) return output
|
|
||||||
if (id) return id
|
|
||||||
|
|
||||||
return JSON.stringify([
|
|
||||||
String((job as any)?.sourceUrl ?? ''),
|
|
||||||
String((job as any)?.startedAt ?? ''),
|
|
||||||
String((job as any)?.endedAt ?? ''),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeDoneJobsStable(
|
|
||||||
prev: RecordJob[],
|
|
||||||
incoming: RecordJob[]
|
|
||||||
): RecordJob[] {
|
|
||||||
if (!Array.isArray(prev) || prev.length === 0) return incoming
|
|
||||||
if (!Array.isArray(incoming) || incoming.length === 0) return prev
|
|
||||||
|
|
||||||
const incomingByKey = new Map<string, RecordJob>()
|
|
||||||
for (const item of incoming) {
|
|
||||||
incomingByKey.set(doneJobMergeKey(item), item)
|
|
||||||
}
|
|
||||||
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const merged: RecordJob[] = []
|
|
||||||
|
|
||||||
// 1) Bestehende sichtbare Reihenfolge beibehalten,
|
|
||||||
// aber Daten der vorhandenen Elemente aktualisieren.
|
|
||||||
for (const oldItem of prev) {
|
|
||||||
const key = doneJobMergeKey(oldItem)
|
|
||||||
const nextItem = incomingByKey.get(key)
|
|
||||||
|
|
||||||
if (nextItem) {
|
|
||||||
merged.push(nextItem)
|
|
||||||
seen.add(key)
|
|
||||||
} else {
|
|
||||||
merged.push(oldItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Wirklich neue Finished-Downloads hinten anhängen,
|
|
||||||
// ohne bestehende Karten umzuschichten.
|
|
||||||
for (const item of incoming) {
|
|
||||||
const key = doneJobMergeKey(item)
|
|
||||||
if (seen.has(key)) continue
|
|
||||||
merged.push(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadDoneCount = useCallback(async () => {
|
const loadDoneCount = useCallback(async () => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
||||||
@ -1043,20 +1006,33 @@ export default function App() {
|
|||||||
|
|
||||||
const loadDonePage = useCallback(async (
|
const loadDonePage = useCallback(async (
|
||||||
pageToLoad = donePage,
|
pageToLoad = donePage,
|
||||||
sortToLoad = doneSort,
|
sortToLoad = doneSort
|
||||||
opts?: { preserveOrder?: boolean }
|
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const preserveOrder = Boolean(opts?.preserveOrder)
|
const key = makePrefetchKey(
|
||||||
const key = makePrefetchKey(pageToLoad, sortToLoad)
|
pageToLoad,
|
||||||
|
sortToLoad,
|
||||||
|
donePageSize,
|
||||||
|
includeKeep
|
||||||
|
)
|
||||||
|
|
||||||
const prefetched = donePrefetchRef.current
|
const prefetched = donePrefetchRef.current
|
||||||
|
|
||||||
if (prefetched?.key === key && Array.isArray(prefetched.items)) {
|
if (prefetched?.key === key && Array.isArray(prefetched.items)) {
|
||||||
setDoneJobs((prev) =>
|
setDoneJobs(prefetched.items)
|
||||||
preserveOrder ? mergeDoneJobsStable(prev, prefetched.items) : prefetched.items
|
|
||||||
)
|
const count = Number(prefetched.totalCount)
|
||||||
|
if (Number.isFinite(count) && count >= 0 && count !== doneCount) {
|
||||||
|
setDoneCount(count)
|
||||||
|
}
|
||||||
|
|
||||||
donePrefetchRef.current = null
|
donePrefetchRef.current = null
|
||||||
|
|
||||||
|
const totalPages = getDoneTotalPages(count, donePageSize)
|
||||||
|
if (pageToLoad < totalPages) {
|
||||||
|
void prefetchDonePage(pageToLoad + 1)
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1066,6 +1042,7 @@ export default function App() {
|
|||||||
}`,
|
}`,
|
||||||
{ cache: 'no-store' as any }
|
{ cache: 'no-store' as any }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
|
|
||||||
const data = await res.json().catch(() => null)
|
const data = await res.json().catch(() => null)
|
||||||
@ -1074,9 +1051,7 @@ export default function App() {
|
|||||||
? (data.items as RecordJob[])
|
? (data.items as RecordJob[])
|
||||||
: []
|
: []
|
||||||
|
|
||||||
setDoneJobs((prev) =>
|
setDoneJobs(items)
|
||||||
preserveOrder ? mergeDoneJobsStable(prev, items) : items
|
|
||||||
)
|
|
||||||
|
|
||||||
const countRaw = Number(data?.count ?? doneCount)
|
const countRaw = Number(data?.count ?? doneCount)
|
||||||
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
||||||
@ -1094,6 +1069,12 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [donePage, doneSort, donePageSize, prefetchDonePage, includeKeep, doneCount])
|
}, [donePage, doneSort, donePageSize, prefetchDonePage, includeKeep, doneCount])
|
||||||
|
|
||||||
|
const loadDonePageRef = useRef(loadDonePage)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDonePageRef.current = loadDonePage
|
||||||
|
}, [loadDonePage])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0')
|
window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0')
|
||||||
@ -2773,7 +2754,8 @@ export default function App() {
|
|||||||
void loadTaskStateOnce()
|
void loadTaskStateOnce()
|
||||||
|
|
||||||
if (selectedTabRef.current === 'finished') {
|
if (selectedTabRef.current === 'finished') {
|
||||||
void loadDonePage(donePageRef.current, doneSortRef.current)
|
donePrefetchRef.current = null
|
||||||
|
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||||||
}
|
}
|
||||||
}, document.hidden ? 60000 : 5000)
|
}, document.hidden ? 60000 : 5000)
|
||||||
}
|
}
|
||||||
@ -2802,9 +2784,8 @@ export default function App() {
|
|||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
|
|
||||||
if (selectedTabRef.current === 'finished') {
|
if (selectedTabRef.current === 'finished') {
|
||||||
void loadDonePage(donePageRef.current, doneSortRef.current, {
|
donePrefetchRef.current = null
|
||||||
preserveOrder: true,
|
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3009,7 +2990,8 @@ export default function App() {
|
|||||||
void loadAutostartState().catch(() => {})
|
void loadAutostartState().catch(() => {})
|
||||||
|
|
||||||
if (selectedTabRef.current === 'finished') {
|
if (selectedTabRef.current === 'finished') {
|
||||||
void loadDonePage(donePageRef.current, doneSortRef.current)
|
donePrefetchRef.current = null
|
||||||
|
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3306,47 +3288,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fillDonePageFromPrefetch = useCallback((opts?: { removedFile?: string }) => {
|
|
||||||
const removedFile = String(opts?.removedFile ?? '').trim()
|
|
||||||
|
|
||||||
setDoneJobs((prev) => {
|
|
||||||
const filtered = removedFile
|
|
||||||
? prev.filter((j) => baseName(j.output || '') !== removedFile)
|
|
||||||
: [...prev]
|
|
||||||
|
|
||||||
const need = Math.max(0, donePageSize - filtered.length)
|
|
||||||
if (need <= 0) return filtered
|
|
||||||
|
|
||||||
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
|
|
||||||
const buf = donePrefetchRef.current
|
|
||||||
|
|
||||||
if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
const next: RecordJob[] = [...filtered]
|
|
||||||
const used = new Set(
|
|
||||||
next.map((x) => String(x.id || baseName(x.output || '')).trim())
|
|
||||||
)
|
|
||||||
|
|
||||||
while (next.length < donePageSize && buf.items.length > 0) {
|
|
||||||
const cand = buf.items.shift()!
|
|
||||||
const id = String(cand.id || baseName(cand.output || '')).trim()
|
|
||||||
if (!id || used.has(id)) continue
|
|
||||||
used.add(id)
|
|
||||||
next.push(cand)
|
|
||||||
}
|
|
||||||
|
|
||||||
donePrefetchRef.current = {
|
|
||||||
...buf,
|
|
||||||
items: buf.items,
|
|
||||||
ts: buf.ts,
|
|
||||||
}
|
|
||||||
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [donePage, donePageSize, doneSort])
|
|
||||||
|
|
||||||
const doneCountRef = useRef(doneCount)
|
const doneCountRef = useRef(doneCount)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -3369,18 +3310,12 @@ export default function App() {
|
|||||||
),
|
),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
fillDonePageFromPrefetch({ removedFile: file })
|
|
||||||
|
|
||||||
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
||||||
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
||||||
|
|
||||||
const nextDoneCount = Math.max(0, doneCountRef.current - 1)
|
donePrefetchRef.current = null
|
||||||
const totalPagesAfterDelete = getDoneTotalPages(nextDoneCount, donePageSize)
|
|
||||||
|
|
||||||
if (donePage < totalPagesAfterDelete) {
|
|
||||||
void prefetchDonePage(donePage + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
}, 320)
|
}, 320)
|
||||||
},
|
},
|
||||||
@ -3400,9 +3335,6 @@ export default function App() {
|
|||||||
[
|
[
|
||||||
runFinishedFileAction,
|
runFinishedFileAction,
|
||||||
notify,
|
notify,
|
||||||
fillDonePageFromPrefetch,
|
|
||||||
donePage,
|
|
||||||
prefetchDonePage,
|
|
||||||
loadDoneCount,
|
loadDoneCount,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@ -3423,9 +3355,12 @@ export default function App() {
|
|||||||
),
|
),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
fillDonePageFromPrefetch({ removedFile: file })
|
|
||||||
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
||||||
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
||||||
|
|
||||||
|
donePrefetchRef.current = null
|
||||||
|
|
||||||
|
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
}, 320)
|
}, 320)
|
||||||
},
|
},
|
||||||
@ -3439,7 +3374,7 @@ export default function App() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[runFinishedFileAction, notify, fillDonePageFromPrefetch, loadDoneCount]
|
[runFinishedFileAction, notify, loadDoneCount]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleToggleHot = useCallback(
|
const handleToggleHot = useCallback(
|
||||||
@ -3493,13 +3428,18 @@ export default function App() {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
donePrefetchRef.current = null
|
||||||
|
|
||||||
|
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||||||
|
void loadDoneCount()
|
||||||
|
|
||||||
return res
|
return res
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output))
|
notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[notify]
|
[notify, loadDoneCount]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDeleteJob = useCallback(
|
const handleDeleteJob = useCallback(
|
||||||
@ -4541,7 +4481,8 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bumpAssets()
|
bumpAssets()
|
||||||
void loadDonePage(donePageRef.current, doneSortRef.current)
|
donePrefetchRef.current = null
|
||||||
|
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||||||
void loadDoneCount()
|
void loadDoneCount()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
const message =
|
||||||
|
|||||||
@ -1850,62 +1850,50 @@ function RatingHeroHeader({
|
|||||||
toneLabel,
|
toneLabel,
|
||||||
segmentsCount,
|
segmentsCount,
|
||||||
entriesCount,
|
entriesCount,
|
||||||
onClose,
|
|
||||||
}: {
|
}: {
|
||||||
stars: number
|
stars: number
|
||||||
toneLabel: string
|
toneLabel: string
|
||||||
segmentsCount: number
|
segmentsCount: number
|
||||||
entriesCount: number
|
entriesCount: number
|
||||||
onClose?: () => void
|
|
||||||
}) {
|
}) {
|
||||||
const tone = ratingTone(stars)
|
const tone = ratingTone(stars)
|
||||||
const progress = Math.max(0, Math.min(100, Math.round((stars / 5) * 100)))
|
const progress = Math.max(0, Math.min(100, Math.round((stars / 5) * 100)))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-xl border border-white/10 bg-gray-950 px-3 py-2 text-white shadow-lg">
|
<div
|
||||||
<div className="absolute -right-8 -top-8 size-20 rounded-full bg-amber-400/12 blur-2xl" />
|
className={[
|
||||||
<div className="absolute -left-8 bottom-0 size-16 rounded-full bg-indigo-500/10 blur-2xl" />
|
'relative overflow-hidden rounded-xl border px-3 py-2 shadow-sm',
|
||||||
|
'border-gray-200 bg-white text-gray-950 ring-1 ring-black/5',
|
||||||
|
'dark:border-white/10 dark:bg-gray-950 dark:text-white dark:ring-white/10',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="absolute -right-8 -top-8 size-20 rounded-full bg-amber-300/20 blur-2xl dark:bg-amber-400/12" />
|
||||||
|
<div className="absolute -left-8 bottom-0 size-16 rounded-full bg-indigo-300/15 blur-2xl dark:bg-indigo-500/10" />
|
||||||
|
|
||||||
<div className="relative flex items-center justify-between gap-3">
|
<div className="relative flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<div className="truncate text-[13px] font-black leading-4 tracking-tight">
|
<div className="truncate text-[13px] font-black leading-4 tracking-tight text-gray-950 dark:text-white">
|
||||||
AI Rating
|
AI Rating
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="shrink-0 rounded-full bg-white/10 px-1.5 py-[2px] text-[9px] font-bold leading-none text-white/75 ring-1 ring-white/15">
|
<span className="shrink-0 rounded-full bg-gray-100 px-1.5 py-[2px] text-[9px] font-bold leading-none text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-white/75 dark:ring-white/15">
|
||||||
{toneLabel}
|
{toneLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-0.5 truncate text-[10px] leading-3 text-white/55">
|
<div className="mt-0.5 truncate text-[10px] leading-3 text-gray-500 dark:text-white/55">
|
||||||
{stars}/5 · {segmentsCount} Stellen · {entriesCount} Details
|
{stars}/5 · {segmentsCount} Stellen · {entriesCount} Details
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
<RatingStar stars={stars} className="h-7 w-7" />
|
<RatingStar stars={stars} className="h-7 w-7" />
|
||||||
|
|
||||||
{onClose ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-lg leading-none text-white/75 ring-1 ring-white/15 transition hover:bg-white/18 hover:text-white"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
onClose()
|
|
||||||
}}
|
|
||||||
aria-label="Schließen"
|
|
||||||
title="Schließen"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mt-2">
|
<div className="relative mt-2">
|
||||||
<div className="h-1 overflow-hidden rounded-full bg-white/12">
|
<div className="h-1 overflow-hidden rounded-full bg-gray-200 dark:bg-white/12">
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'h-full rounded-full transition-all duration-300',
|
'h-full rounded-full transition-all duration-300',
|
||||||
@ -2328,7 +2316,6 @@ function RatingMobileSheet({
|
|||||||
toneLabel={toneLabel}
|
toneLabel={toneLabel}
|
||||||
segmentsCount={segments.length}
|
segmentsCount={segments.length}
|
||||||
entriesCount={entries.length}
|
entriesCount={entries.length}
|
||||||
onClose={closeWithAnimation}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -223,7 +223,12 @@ function SettingsSection(props: {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleOpen}
|
onClick={toggleOpen}
|
||||||
className="flex w-full items-start justify-between gap-4 rounded-2xl bg-white/95 p-4 text-left transition hover:bg-gray-50 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:bg-gray-950/70 dark:hover:bg-white/5 dark:focus-visible:ring-indigo-400"
|
className={[
|
||||||
|
'flex w-full items-start justify-between gap-4 bg-white/95 p-4 text-left transition hover:bg-gray-50',
|
||||||
|
'focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500',
|
||||||
|
'dark:bg-gray-950/70 dark:hover:bg-white/5 dark:focus-visible:ring-indigo-400',
|
||||||
|
open ? 'rounded-t-2xl rounded-b-none' : 'rounded-2xl',
|
||||||
|
].join(' ')}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@ -498,17 +498,20 @@ export function ToastProvider({
|
|||||||
|
|
||||||
const ctx = useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear])
|
const ctx = useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear])
|
||||||
|
|
||||||
const posCls =
|
const desktopPosCls =
|
||||||
position === 'top-right'
|
position.startsWith('top')
|
||||||
? 'items-start sm:items-start sm:justify-start'
|
? 'sm:items-start'
|
||||||
: position === 'top-left'
|
: 'sm:items-end'
|
||||||
? 'items-start sm:items-start sm:justify-start'
|
|
||||||
: position === 'bottom-left'
|
|
||||||
? 'items-end sm:items-end sm:justify-end'
|
|
||||||
: 'items-end sm:items-end sm:justify-end'
|
|
||||||
|
|
||||||
const alignCls = position.endsWith('left') ? 'sm:items-start' : 'sm:items-end'
|
const desktopAlignCls = position.endsWith('left')
|
||||||
const insetCls = position.startsWith('top') ? 'top-0 bottom-auto' : 'bottom-0 top-auto'
|
? 'sm:items-start'
|
||||||
|
: 'sm:items-end'
|
||||||
|
|
||||||
|
// Mobile immer oben mittig.
|
||||||
|
// Desktop bleibt wie über `position` gesteuert.
|
||||||
|
const insetCls = position.startsWith('top')
|
||||||
|
? 'top-0 bottom-auto'
|
||||||
|
: 'top-0 bottom-auto sm:bottom-0 sm:top-auto'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={ctx}>
|
<ToastContext.Provider value={ctx}>
|
||||||
@ -534,8 +537,18 @@ export function ToastProvider({
|
|||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
className={['pointer-events-none fixed z-[80] inset-x-0', insetCls].join(' ')}
|
className={['pointer-events-none fixed z-[80] inset-x-0', insetCls].join(' ')}
|
||||||
>
|
>
|
||||||
<div className={['flex w-full px-3 py-4 sm:px-6 sm:py-6', posCls].join(' ')}>
|
<div
|
||||||
<div className={['flex w-full flex-col gap-2.5 sm:gap-3', alignCls].join(' ')}>
|
className={[
|
||||||
|
'flex w-full justify-center px-3 py-4 sm:px-6 sm:py-6',
|
||||||
|
desktopPosCls,
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'flex w-full max-w-[22rem] flex-col items-center gap-2.5 sm:max-w-none sm:gap-3',
|
||||||
|
desktopAlignCls,
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
{toasts.map((t) => {
|
{toasts.map((t) => {
|
||||||
const { Icon, cls } = iconFor(t.type)
|
const { Icon, cls } = iconFor(t.type)
|
||||||
const accents = accentFor(t.type)
|
const accents = accentFor(t.type)
|
||||||
|
|||||||
@ -5305,8 +5305,33 @@ export default function TrainingTab(props: {
|
|||||||
|
|
||||||
if (!rect || rect.width <= 0 || rect.height <= 0) return null
|
if (!rect || rect.width <= 0 || rect.height <= 0) return null
|
||||||
|
|
||||||
const size = 156
|
const visualViewport =
|
||||||
const padding = 20
|
typeof window !== 'undefined' ? window.visualViewport : undefined
|
||||||
|
|
||||||
|
const viewportLeft = visualViewport?.offsetLeft ?? 0
|
||||||
|
const viewportTop = visualViewport?.offsetTop ?? 0
|
||||||
|
|
||||||
|
const viewportW =
|
||||||
|
visualViewport?.width ??
|
||||||
|
(typeof window !== 'undefined' ? window.innerWidth : 390)
|
||||||
|
|
||||||
|
const viewportH =
|
||||||
|
visualViewport?.height ??
|
||||||
|
(typeof window !== 'undefined' ? window.innerHeight : 800)
|
||||||
|
|
||||||
|
const isCoarsePointer =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof window.matchMedia === 'function' &&
|
||||||
|
window.matchMedia('(pointer: coarse)').matches
|
||||||
|
|
||||||
|
const isTouchLike = isCoarsePointer || viewportW < 640
|
||||||
|
|
||||||
|
const baseSize = isTouchLike ? 136 : 156
|
||||||
|
const largeBoxSize = isTouchLike
|
||||||
|
? Math.min(176, Math.max(152, viewportW - 32))
|
||||||
|
: 190
|
||||||
|
|
||||||
|
const padding = isTouchLike ? 16 : 20
|
||||||
|
|
||||||
const activeBox =
|
const activeBox =
|
||||||
drawingBox ||
|
drawingBox ||
|
||||||
@ -5332,29 +5357,70 @@ export default function TrainingTab(props: {
|
|||||||
const boxPixelW = hasUsableBox ? activeBox.w * rect.width : 0
|
const boxPixelW = hasUsableBox ? activeBox.w * rect.width : 0
|
||||||
const boxPixelH = hasUsableBox ? activeBox.h * rect.height : 0
|
const boxPixelH = hasUsableBox ? activeBox.h * rect.height : 0
|
||||||
|
|
||||||
|
const boxNeedsLargeMagnifier =
|
||||||
|
hasUsableBox &&
|
||||||
|
(
|
||||||
|
boxPixelW > baseSize - padding * 2 ||
|
||||||
|
boxPixelH > baseSize - padding * 2
|
||||||
|
)
|
||||||
|
|
||||||
|
const size = boxNeedsLargeMagnifier ? largeBoxSize : baseSize
|
||||||
|
|
||||||
|
const fitPadding = isTouchLike ? 14 : 18
|
||||||
|
|
||||||
const fitZoom = hasUsableBox
|
const fitZoom = hasUsableBox
|
||||||
? Math.min(
|
? Math.min(
|
||||||
(size - padding * 2) / Math.max(1, boxPixelW),
|
(size - fitPadding * 2) / Math.max(1, boxPixelW),
|
||||||
(size - padding * 2) / Math.max(1, boxPixelH)
|
(size - fitPadding * 2) / Math.max(1, boxPixelH)
|
||||||
)
|
)
|
||||||
: 2
|
: 2
|
||||||
|
|
||||||
const zoom = hasUsableBox
|
const zoom = hasUsableBox
|
||||||
? Math.max(0.55, Math.min(2.25, fitZoom))
|
? Math.min(isTouchLike ? 2.25 : 2.5, fitZoom * 0.94)
|
||||||
: 2
|
: 2
|
||||||
|
|
||||||
const viewportW = typeof window !== 'undefined' ? window.innerWidth : 390
|
const gap = isTouchLike ? 10 : 12
|
||||||
const viewportH = typeof window !== 'undefined' ? window.innerHeight : 800
|
const edgeGap = isTouchLike ? 8 : 10
|
||||||
|
|
||||||
let left = touchMagnifier.clientX - size / 2
|
// Die Lupe wird an der Box ausgerichtet, nicht am Finger.
|
||||||
let top = touchMagnifier.clientY - size - 28
|
// Wenn gerade noch keine echte Box existiert, fällt sie auf den aktuellen Bildpunkt zurück.
|
||||||
|
const anchorX = hasUsableBox
|
||||||
|
? rect.left + boxCenterX * rect.width
|
||||||
|
: rect.left + touchMagnifier.imageX * rect.width
|
||||||
|
|
||||||
if (top < 8) {
|
const anchorTop = hasUsableBox
|
||||||
top = touchMagnifier.clientY + 28
|
? rect.top + activeBox.y * rect.height
|
||||||
|
: rect.top + touchMagnifier.imageY * rect.height
|
||||||
|
|
||||||
|
const anchorBottom = hasUsableBox
|
||||||
|
? rect.top + (activeBox.y + activeBox.h) * rect.height
|
||||||
|
: rect.top + touchMagnifier.imageY * rect.height
|
||||||
|
|
||||||
|
let left = anchorX - size / 2
|
||||||
|
|
||||||
|
// Normalfall: Lupe über der Box.
|
||||||
|
let top = anchorTop - size - gap
|
||||||
|
|
||||||
|
// Wenn oben kein Platz ist: Lupe unter die Box setzen.
|
||||||
|
if (top < viewportTop + edgeGap) {
|
||||||
|
top = anchorBottom + gap
|
||||||
}
|
}
|
||||||
|
|
||||||
left = Math.max(8, Math.min(viewportW - size - 8, left))
|
// Wenn die Lupe unten aus dem sichtbaren Bereich laufen würde:
|
||||||
top = Math.max(8, Math.min(viewportH - size - 8, top))
|
// an das untere Ende des sichtbaren Viewports klemmen.
|
||||||
|
if (top + size > viewportTop + viewportH - edgeGap) {
|
||||||
|
top = viewportTop + viewportH - size - edgeGap
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falls der Viewport extrem klein ist, trotzdem sichtbar halten.
|
||||||
|
if (top < viewportTop + edgeGap) {
|
||||||
|
top = viewportTop + edgeGap
|
||||||
|
}
|
||||||
|
|
||||||
|
left = Math.max(
|
||||||
|
viewportLeft + edgeGap,
|
||||||
|
Math.min(viewportLeft + viewportW - size - edgeGap, left)
|
||||||
|
)
|
||||||
|
|
||||||
const imageWidth = rect.width * zoom
|
const imageWidth = rect.width * zoom
|
||||||
const imageHeight = rect.height * zoom
|
const imageHeight = rect.height * zoom
|
||||||
@ -5548,15 +5614,6 @@ export default function TrainingTab(props: {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
</section>
|
||||||
|
|
||||||
{trainingRunning ? (
|
{trainingRunning ? (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user