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 {
|
||||
finishCleanupJob(jobID, "", err)
|
||||
return
|
||||
@ -236,7 +251,19 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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()
|
||||
|
||||
resp.GeneratedOrphansChecked = gcStats.Checked
|
||||
resp.GeneratedOrphansRemoved = gcStats.Removed
|
||||
resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes
|
||||
@ -494,8 +521,14 @@ func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path strin
|
||||
}
|
||||
|
||||
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
|
||||
st.Queued = false
|
||||
st.Running = true
|
||||
st.Done = 0
|
||||
st.Total = 0
|
||||
st.CurrentFile = name
|
||||
st.Text = "Räume Record-Reste auf…"
|
||||
st.Error = ""
|
||||
st.FinishedAt = nil
|
||||
})
|
||||
|
||||
if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) {
|
||||
|
||||
@ -108,7 +108,7 @@ func runTray(onQuit func(), statsFn func() TrayStats) {
|
||||
updateStatus()
|
||||
|
||||
case <-mOpenFrontend.ClickedCh:
|
||||
_ = openBrowser("http://localhost:9999")
|
||||
_ = openBrowser("https://localhost:9999")
|
||||
|
||||
case <-mOpenRecordDir.ClickedCh:
|
||||
_ = openFolder(resolveConfiguredDir(getSettings().RecordDir))
|
||||
|
||||
@ -914,10 +914,16 @@ export default function App() {
|
||||
type DonePrefetch = {
|
||||
key: string
|
||||
items: RecordJob[]
|
||||
totalCount: 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 safeCount = Number.isFinite(count) && count > 0 ? count : 0
|
||||
@ -928,7 +934,7 @@ export default function App() {
|
||||
if (pageToFetch < 1) return
|
||||
if (donePrefetchInFlightRef.current) return
|
||||
|
||||
const key = makePrefetchKey(pageToFetch, doneSort)
|
||||
const key = makePrefetchKey(pageToFetch, doneSort, donePageSize, includeKeep)
|
||||
const cur = donePrefetchRef.current
|
||||
if (cur?.key === key && Date.now() - cur.ts < 15_000) {
|
||||
// frisch genug
|
||||
@ -938,7 +944,7 @@ export default function App() {
|
||||
donePrefetchInFlightRef.current = true
|
||||
try {
|
||||
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' : ''
|
||||
}`,
|
||||
{ cache: 'no-store' as any }
|
||||
@ -952,66 +958,23 @@ export default function App() {
|
||||
? (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 {
|
||||
donePrefetchInFlightRef.current = false
|
||||
}
|
||||
}, [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 now = Date.now()
|
||||
|
||||
@ -1043,20 +1006,33 @@ export default function App() {
|
||||
|
||||
const loadDonePage = useCallback(async (
|
||||
pageToLoad = donePage,
|
||||
sortToLoad = doneSort,
|
||||
opts?: { preserveOrder?: boolean }
|
||||
sortToLoad = doneSort
|
||||
) => {
|
||||
try {
|
||||
const preserveOrder = Boolean(opts?.preserveOrder)
|
||||
const key = makePrefetchKey(pageToLoad, sortToLoad)
|
||||
const key = makePrefetchKey(
|
||||
pageToLoad,
|
||||
sortToLoad,
|
||||
donePageSize,
|
||||
includeKeep
|
||||
)
|
||||
|
||||
const prefetched = donePrefetchRef.current
|
||||
|
||||
if (prefetched?.key === key && Array.isArray(prefetched.items)) {
|
||||
setDoneJobs((prev) =>
|
||||
preserveOrder ? mergeDoneJobsStable(prev, prefetched.items) : prefetched.items
|
||||
)
|
||||
setDoneJobs(prefetched.items)
|
||||
|
||||
const count = Number(prefetched.totalCount)
|
||||
if (Number.isFinite(count) && count >= 0 && count !== doneCount) {
|
||||
setDoneCount(count)
|
||||
}
|
||||
|
||||
donePrefetchRef.current = null
|
||||
|
||||
const totalPages = getDoneTotalPages(count, donePageSize)
|
||||
if (pageToLoad < totalPages) {
|
||||
void prefetchDonePage(pageToLoad + 1)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@ -1066,6 +1042,7 @@ export default function App() {
|
||||
}`,
|
||||
{ cache: 'no-store' as any }
|
||||
)
|
||||
|
||||
if (!res.ok) return
|
||||
|
||||
const data = await res.json().catch(() => null)
|
||||
@ -1074,9 +1051,7 @@ export default function App() {
|
||||
? (data.items as RecordJob[])
|
||||
: []
|
||||
|
||||
setDoneJobs((prev) =>
|
||||
preserveOrder ? mergeDoneJobsStable(prev, items) : items
|
||||
)
|
||||
setDoneJobs(items)
|
||||
|
||||
const countRaw = Number(data?.count ?? doneCount)
|
||||
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
||||
@ -1093,6 +1068,12 @@ export default function App() {
|
||||
// ignore
|
||||
}
|
||||
}, [donePage, doneSort, donePageSize, prefetchDonePage, includeKeep, doneCount])
|
||||
|
||||
const loadDonePageRef = useRef(loadDonePage)
|
||||
|
||||
useEffect(() => {
|
||||
loadDonePageRef.current = loadDonePage
|
||||
}, [loadDonePage])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@ -2773,7 +2754,8 @@ export default function App() {
|
||||
void loadTaskStateOnce()
|
||||
|
||||
if (selectedTabRef.current === 'finished') {
|
||||
void loadDonePage(donePageRef.current, doneSortRef.current)
|
||||
donePrefetchRef.current = null
|
||||
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||||
}
|
||||
}, document.hidden ? 60000 : 5000)
|
||||
}
|
||||
@ -2802,9 +2784,8 @@ export default function App() {
|
||||
void loadDoneCount()
|
||||
|
||||
if (selectedTabRef.current === 'finished') {
|
||||
void loadDonePage(donePageRef.current, doneSortRef.current, {
|
||||
preserveOrder: true,
|
||||
})
|
||||
donePrefetchRef.current = null
|
||||
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
@ -3009,7 +2990,8 @@ export default function App() {
|
||||
void loadAutostartState().catch(() => {})
|
||||
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
@ -3369,18 +3310,12 @@ export default function App() {
|
||||
),
|
||||
onSuccess: async () => {
|
||||
window.setTimeout(() => {
|
||||
fillDonePageFromPrefetch({ removedFile: file })
|
||||
|
||||
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
||||
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
||||
|
||||
const nextDoneCount = Math.max(0, doneCountRef.current - 1)
|
||||
const totalPagesAfterDelete = getDoneTotalPages(nextDoneCount, donePageSize)
|
||||
|
||||
if (donePage < totalPagesAfterDelete) {
|
||||
void prefetchDonePage(donePage + 1)
|
||||
}
|
||||
donePrefetchRef.current = null
|
||||
|
||||
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||||
void loadDoneCount()
|
||||
}, 320)
|
||||
},
|
||||
@ -3400,9 +3335,6 @@ export default function App() {
|
||||
[
|
||||
runFinishedFileAction,
|
||||
notify,
|
||||
fillDonePageFromPrefetch,
|
||||
donePage,
|
||||
prefetchDonePage,
|
||||
loadDoneCount,
|
||||
]
|
||||
)
|
||||
@ -3423,9 +3355,12 @@ export default function App() {
|
||||
),
|
||||
onSuccess: async () => {
|
||||
window.setTimeout(() => {
|
||||
fillDonePageFromPrefetch({ removedFile: file })
|
||||
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
||||
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
||||
|
||||
donePrefetchRef.current = null
|
||||
|
||||
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||||
void loadDoneCount()
|
||||
}, 320)
|
||||
},
|
||||
@ -3439,7 +3374,7 @@ export default function App() {
|
||||
return
|
||||
}
|
||||
},
|
||||
[runFinishedFileAction, notify, fillDonePageFromPrefetch, loadDoneCount]
|
||||
[runFinishedFileAction, notify, loadDoneCount]
|
||||
)
|
||||
|
||||
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
|
||||
} catch (e: any) {
|
||||
notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output))
|
||||
return
|
||||
}
|
||||
},
|
||||
[notify]
|
||||
[notify, loadDoneCount]
|
||||
)
|
||||
|
||||
const handleDeleteJob = useCallback(
|
||||
@ -4541,7 +4481,8 @@ export default function App() {
|
||||
}
|
||||
|
||||
bumpAssets()
|
||||
void loadDonePage(donePageRef.current, doneSortRef.current)
|
||||
donePrefetchRef.current = null
|
||||
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||||
void loadDoneCount()
|
||||
} catch (err) {
|
||||
const message =
|
||||
|
||||
@ -1850,62 +1850,50 @@ function RatingHeroHeader({
|
||||
toneLabel,
|
||||
segmentsCount,
|
||||
entriesCount,
|
||||
onClose,
|
||||
}: {
|
||||
stars: number
|
||||
toneLabel: string
|
||||
segmentsCount: number
|
||||
entriesCount: number
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const tone = ratingTone(stars)
|
||||
const progress = Math.max(0, Math.min(100, Math.round((stars / 5) * 100)))
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-xl border border-white/10 bg-gray-950 px-3 py-2 text-white shadow-lg">
|
||||
<div className="absolute -right-8 -top-8 size-20 rounded-full bg-amber-400/12 blur-2xl" />
|
||||
<div className="absolute -left-8 bottom-0 size-16 rounded-full bg-indigo-500/10 blur-2xl" />
|
||||
<div
|
||||
className={[
|
||||
'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="min-w-0">
|
||||
<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
|
||||
</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}
|
||||
</span>
|
||||
</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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<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 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
|
||||
className={[
|
||||
'h-full rounded-full transition-all duration-300',
|
||||
@ -2328,7 +2316,6 @@ function RatingMobileSheet({
|
||||
toneLabel={toneLabel}
|
||||
segmentsCount={segments.length}
|
||||
entriesCount={entries.length}
|
||||
onClose={closeWithAnimation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -223,7 +223,12 @@ function SettingsSection(props: {
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
|
||||
@ -498,17 +498,20 @@ export function ToastProvider({
|
||||
|
||||
const ctx = useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear])
|
||||
|
||||
const posCls =
|
||||
position === 'top-right'
|
||||
? 'items-start sm:items-start sm:justify-start'
|
||||
: position === 'top-left'
|
||||
? '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 desktopPosCls =
|
||||
position.startsWith('top')
|
||||
? 'sm:items-start'
|
||||
: 'sm:items-end'
|
||||
|
||||
const alignCls = position.endsWith('left') ? 'sm:items-start' : 'sm:items-end'
|
||||
const insetCls = position.startsWith('top') ? 'top-0 bottom-auto' : 'bottom-0 top-auto'
|
||||
const desktopAlignCls = position.endsWith('left')
|
||||
? '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 (
|
||||
<ToastContext.Provider value={ctx}>
|
||||
@ -534,8 +537,18 @@ export function ToastProvider({
|
||||
aria-live="assertive"
|
||||
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 className={['flex w-full flex-col gap-2.5 sm:gap-3', alignCls].join(' ')}>
|
||||
<div
|
||||
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) => {
|
||||
const { Icon, cls } = iconFor(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
|
||||
|
||||
const size = 156
|
||||
const padding = 20
|
||||
const visualViewport =
|
||||
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 =
|
||||
drawingBox ||
|
||||
@ -5332,29 +5357,70 @@ export default function TrainingTab(props: {
|
||||
const boxPixelW = hasUsableBox ? activeBox.w * rect.width : 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
|
||||
? Math.min(
|
||||
(size - padding * 2) / Math.max(1, boxPixelW),
|
||||
(size - padding * 2) / Math.max(1, boxPixelH)
|
||||
(size - fitPadding * 2) / Math.max(1, boxPixelW),
|
||||
(size - fitPadding * 2) / Math.max(1, boxPixelH)
|
||||
)
|
||||
: 2
|
||||
|
||||
const zoom = hasUsableBox
|
||||
? Math.max(0.55, Math.min(2.25, fitZoom))
|
||||
? Math.min(isTouchLike ? 2.25 : 2.5, fitZoom * 0.94)
|
||||
: 2
|
||||
|
||||
const viewportW = typeof window !== 'undefined' ? window.innerWidth : 390
|
||||
const viewportH = typeof window !== 'undefined' ? window.innerHeight : 800
|
||||
const gap = isTouchLike ? 10 : 12
|
||||
const edgeGap = isTouchLike ? 8 : 10
|
||||
|
||||
let left = touchMagnifier.clientX - size / 2
|
||||
let top = touchMagnifier.clientY - size - 28
|
||||
// Die Lupe wird an der Box ausgerichtet, nicht am Finger.
|
||||
// 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) {
|
||||
top = touchMagnifier.clientY + 28
|
||||
const anchorTop = hasUsableBox
|
||||
? 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))
|
||||
top = Math.max(8, Math.min(viewportH - size - 8, top))
|
||||
// Wenn die Lupe unten aus dem sichtbaren Bereich laufen würde:
|
||||
// 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 imageHeight = rect.height * zoom
|
||||
@ -5548,15 +5614,6 @@ export default function TrainingTab(props: {
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
{trainingRunning ? (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user