diff --git a/frontend/src/lib/composables/useOwnerCache.svelte.ts b/frontend/src/lib/composables/useOwnerCache.svelte.ts index f66fe4bb..4a25eb1e 100644 --- a/frontend/src/lib/composables/useOwnerCache.svelte.ts +++ b/frontend/src/lib/composables/useOwnerCache.svelte.ts @@ -31,14 +31,16 @@ export class OwnerCache { /** Resolve every not-yet-cached id in parallel; nullish ids are skipped. */ async resolve(ids: Iterable): Promise { - const unique = [...new Set([...ids].filter((id): id is string => !!id))]; - await Promise.all( - unique.map(async (id) => { - if (this.#names[id]) return; - const name = await this.#resolver(id); - this.#names = { ...this.#names, [id]: name }; - }) + const pending = [...new Set([...ids].filter((id): id is string => !!id))].filter( + (id) => !this.#names[id] ); + if (pending.length === 0) return; + const resolved = await Promise.all( + pending.map(async (id) => [id, await this.#resolver(id)] as const) + ); + // One reactive assignment for the whole batch instead of one per id, so a + // large resolve doesn't spread-copy the record N times (and re-run derives N times). + this.#names = { ...this.#names, ...Object.fromEntries(resolved) }; } } diff --git a/frontend/src/lib/stores/files.svelte.ts b/frontend/src/lib/stores/files.svelte.ts index afd5200c..44de3cea 100644 --- a/frontend/src/lib/stores/files.svelte.ts +++ b/frontend/src/lib/stores/files.svelte.ts @@ -96,10 +96,15 @@ class FilesStore { this.selection = new Set(); } + // Soft ceiling so the per-item toggle can't grow the set without bound. + // (Bulk "select all" lives in the views and intentionally isn't capped — + // silently dropping ids there would break batch delete/move.) + static readonly MAX_SELECTION = 10_000; + toggleSelected(id: string): void { const next = new Set(this.selection); if (next.has(id)) next.delete(id); - else next.add(id); + else if (next.size < FilesStore.MAX_SELECTION) next.add(id); this.selection = next; } } diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 153fa157..5f3f7aea 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -320,7 +320,7 @@ try { migration = await getMigration(); if (migration.status === 'running') { - if (!migrationTimer) migrationTimer = setInterval(loadMigration, 2000); + if (!migrationTimer) migrationTimer = setInterval(loadMigration, 5000); } else { stopMigrationPoll(); } diff --git a/frontend/src/routes/photos/+page.svelte b/frontend/src/routes/photos/+page.svelte index db3a6a87..140449e9 100644 --- a/frontend/src/routes/photos/+page.svelte +++ b/frontend/src/routes/photos/+page.svelte @@ -301,11 +301,16 @@ ['large', 800, 800] ]; let previewData = ''; - for (const [size, w, h] of SIZES) { - const blob = await bitmapToBlob(bitmap, w, h); - if (size === 'preview') previewData = await blobToDataUrl(blob); - await uploadThumbnail(file.id, size, blob).catch(() => {}); - } + // Render the blobs and push all three sizes in parallel; `previewData` + // is captured before its upload so the local preview shows even if that + // upload fails (allSettled swallows per-size failures, as before). + await Promise.allSettled( + SIZES.map(async ([size, w, h]) => { + const blob = await bitmapToBlob(bitmap, w, h); + if (size === 'preview') previewData = await blobToDataUrl(blob); + await uploadThumbnail(file.id, size, blob); + }) + ); if (previewData) videoThumbs = { ...videoThumbs, [file.id]: previewData }; } catch { // Keep the generic play badge on failure.