Slave/ui polishing #13

Merged
nobswebdev merged 14 commits from slave/ui-polishing into master 2026-10-30 19:49:55 +00:00
7 changed files with 90 additions and 2 deletions
Showing only changes of commit 938229eeea - Show all commits
@@ -7,6 +7,18 @@
:class="isRowSelected(row) ? 'bg-muted/80' : ''" :class="isRowSelected(row) ? 'bg-muted/80' : ''"
@click="emit('select', row.file)" @click="emit('select', row.file)"
> >
<img
v-if="row.thumbnailUrl"
:src="row.thumbnailUrl"
class="h-10 w-10 shrink-0 rounded border border-border object-cover"
loading="lazy"
decoding="async"
/>
<span
v-else
class="h-10 w-10 shrink-0 rounded border border-border bg-muted/60"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ row.file.name }}</span> <span class="min-w-0 flex-1 truncate text-sm font-medium">{{ row.file.name }}</span>
<span class="shrink-0 text-xs text-muted-foreground"> <span class="shrink-0 text-xs text-muted-foreground">
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }} {{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
@@ -4,6 +4,7 @@ import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
export type UploadQueueRow = { export type UploadQueueRow = {
file: File; file: File;
key: string; key: string;
thumbnailUrl: string | null;
metadata: MetadataPreviewEntry; metadata: MetadataPreviewEntry;
upload: UploadQueueEntry; upload: UploadQueueEntry;
}; };
@@ -0,0 +1,29 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { createQueueThumbnailUrls } from '@/utils/upload/createQueueThumbnailUrls';
const makeFile = (name: string, size: number, lastModified: number): File =>
new File([new Uint8Array(size)], name, { type: 'image/png', lastModified });
describe('createQueueThumbnailUrls', () => {
beforeEach(() => {
vi.stubGlobal(
'URL',
Object.assign(globalThis.URL, {
createObjectURL: vi.fn(() => 'blob:mock-1')
})
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('creates one object URL per file', () => {
const files = [makeFile('a.png', 10, 1), makeFile('b.png', 20, 2)];
const urlsByKey = createQueueThumbnailUrls(files);
expect(URL.createObjectURL).toHaveBeenCalledTimes(2);
expect(Object.keys(urlsByKey)).toHaveLength(2);
});
});
@@ -0,0 +1,11 @@
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
export const createQueueThumbnailUrls = (files: File[]): Record<string, string> => {
const urlsByKey: Record<string, string> = {};
for (const file of files) {
urlsByKey[makeKeyFromFile(file)] = URL.createObjectURL(file);
}
return urlsByKey;
};
@@ -0,0 +1,20 @@
import { describe, expect, it, vi } from 'vitest';
import { revokeQueueThumbnailUrls } from '@/utils/upload/revokeQueueThumbnailUrls';
describe('revokeQueueThumbnailUrls', () => {
it('revokes every URL in the map', () => {
vi.stubGlobal(
'URL',
Object.assign(globalThis.URL, {
revokeObjectURL: vi.fn()
})
);
revokeQueueThumbnailUrls({ a: 'blob:1', b: 'blob:2' });
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:1');
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:2');
vi.unstubAllGlobals();
});
});
@@ -0,0 +1,5 @@
export const revokeQueueThumbnailUrls = (urlsByKey: Record<string, string>): void => {
for (const url of Object.values(urlsByKey)) {
URL.revokeObjectURL(url);
}
};
+12 -2
View File
@@ -28,7 +28,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeMount, ref } from 'vue'; import { computed, onBeforeMount, onBeforeUnmount, ref } from 'vue';
import type UploadIntakeControls from '@/components/upload/UploadIntakeControls.vue'; import type UploadIntakeControls from '@/components/upload/UploadIntakeControls.vue';
import { config } from '@/config'; import { config } from '@/config';
import { useExiftoolStore } from '@/stores/exiftool'; import { useExiftoolStore } from '@/stores/exiftool';
@@ -39,6 +39,8 @@ import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
import { analyzePickedUploadFiles } from '@/utils/upload/analyzePickedUploadFiles'; import { analyzePickedUploadFiles } from '@/utils/upload/analyzePickedUploadFiles';
import { buildPickedUploadWarning } from '@/utils/upload/buildPickedUploadWarning'; import { buildPickedUploadWarning } from '@/utils/upload/buildPickedUploadWarning';
import { formatUploadBatchSettingsSummary } from '@/utils/upload/formatUploadBatchSettingsSummary'; import { formatUploadBatchSettingsSummary } from '@/utils/upload/formatUploadBatchSettingsSummary';
import { createQueueThumbnailUrls } from '@/utils/upload/createQueueThumbnailUrls';
import { revokeQueueThumbnailUrls } from '@/utils/upload/revokeQueueThumbnailUrls';
import prettyBytes from 'pretty-bytes'; import prettyBytes from 'pretty-bytes';
const { maxQueueSize, maxImageBytes } = config.validation.upload; const { maxQueueSize, maxImageBytes } = config.validation.upload;
@@ -67,6 +69,7 @@ const batchSettings = ref<UploadBatchSettingsForm>({
const uploadConfirmed = ref(false); const uploadConfirmed = ref(false);
const showShareLinks = ref(false); const showShareLinks = ref(false);
const confirmedBatchSummary = ref<string | null>(null); const confirmedBatchSummary = ref<string | null>(null);
const thumbnailUrlsByKey = ref<Record<string, string>>({});
onBeforeMount(async () => { onBeforeMount(async () => {
try { try {
@@ -76,6 +79,10 @@ onBeforeMount(async () => {
} }
}); });
onBeforeUnmount(() => {
resetUploadSession();
});
const maxImageSizeFormatted = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 })); const maxImageSizeFormatted = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 }));
const queueRows = computed(() => const queueRows = computed(() =>
@@ -85,6 +92,7 @@ const queueRows = computed(() =>
return { return {
file, file,
key, key,
thumbnailUrl: thumbnailUrlsByKey.value[key] ?? null,
metadata: metadataStore.getPreview(file), metadata: metadataStore.getPreview(file),
upload: uploadStore.getUpload(file) upload: uploadStore.getUpload(file)
}; };
@@ -136,6 +144,7 @@ const onFilesPicked = async (picked: File[]): Promise<void> => {
); );
queuedFiles.value = accepted; queuedFiles.value = accepted;
thumbnailUrlsByKey.value = createQueueThumbnailUrls(accepted);
selectedFile.value = accepted[0] ?? null; selectedFile.value = accepted[0] ?? null;
loadingMetadata.value = true; loadingMetadata.value = true;
@@ -148,9 +157,10 @@ const onFilesPicked = async (picked: File[]): Promise<void> => {
loadingMetadata.value = false; loadingMetadata.value = false;
} }
}; };
const resetUploadSession = (): void => { const resetUploadSession = (): void => {
selectionError.value = null; selectionError.value = null;
revokeQueueThumbnailUrls(thumbnailUrlsByKey.value);
thumbnailUrlsByKey.value = {};
clearAllPreviews(); clearAllPreviews();
queuedFiles.value = []; queuedFiles.value = [];
selectedFile.value = null; selectedFile.value = null;