add thumbnail preview for each queued image.
Create and revoke blob URLs on pick and session reset; reset upload session on view unmount.
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user