Extract upload dropzone and queue UI with shared pick validation utils.

This commit is contained in:
2026-10-20 22:20:16 +02:00
parent 3ab66c250c
commit d82d451448
12 changed files with 547 additions and 94 deletions
+20
View File
@@ -0,0 +1,20 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
MetadataInspector: typeof import('./components/upload/MetadataInspector.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
UploadDropzone: typeof import('./components/upload/UploadDropzone.vue')['default']
UploadQueueList: typeof import('./components/upload/UploadQueueList.vue')['default']
}
}
@@ -0,0 +1,57 @@
<template>
<div>
<label
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90"
:class="disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
>
Choose images
<input
ref="fileInputRef"
type="file"
accept="image/*"
multiple
class="sr-only"
:disabled="disabled"
@change="onFileInputChange"
/>
</label>
<p v-if="selectionError" class="mt-2 text-sm text-destructive">{{ selectionError }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, type PropType } from 'vue';
defineProps({
disabled: {
type: Boolean,
required: true
},
selectionError: {
type: String as PropType<string | null>,
default: null
}
});
const emit = defineEmits<{
pick: [files: File[]];
}>();
const fileInputRef = ref<HTMLInputElement | null>(null);
const onFileInputChange = (event: Event): void => {
const input = event.target as HTMLInputElement;
const picked = input.files ? [...input.files] : [];
emit('pick', picked);
};
const clearInput = (): void => {
if (fileInputRef.value) {
fileInputRef.value.value = '';
}
};
defineExpose({ clearInput });
</script>
@@ -0,0 +1,53 @@
<template>
<ul v-if="rows.length > 0" class="divide-y divide-border rounded-lg border border-border">
<li v-for="row in rows" :key="row.key">
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60"
:class="isRowSelected(row) ? 'bg-muted/80' : ''"
@click="emit('select', row.file)"
>
<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">
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
</span>
<span class="shrink-0 text-xs" :class="statusTextClass(row.preview)">
{{ row.preview.message }}
</span>
</button>
</li>
</ul>
</template>
<script setup lang="ts">
import { type PropType } from 'vue';
import { useMetadataStore } from '@/stores/metadata';
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
import type { UploadQueueRow } from '@/types/upload/UploadQueueRow';
import prettyBytes from 'pretty-bytes';
const metadataStore = useMetadataStore();
const { makeKeyFromFile } = metadataStore;
const props = defineProps({
rows: {
type: Array as PropType<UploadQueueRow[]>,
required: true
},
selectedFile: {
type: Object as PropType<File | null>,
default: null
}
});
const emit = defineEmits<{
select: [file: File];
}>();
const isRowSelected = (row: UploadQueueRow): boolean =>
props.selectedFile !== null && makeKeyFromFile(props.selectedFile) === row.key;
const statusTextClass = (entry: MetadataPreviewEntry): string =>
entry.status === 'error' ? 'text-destructive' : 'text-muted-foreground';
</script>
@@ -0,0 +1,7 @@
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
export type UploadQueueRow = {
file: File;
key: string;
preview: MetadataPreviewEntry;
};
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { analyzePickedUploadFiles } from '@/utils/upload/analyzePickedUploadFiles';
const makeFile = (name: string, type: string, size: number): File =>
new File([new Uint8Array(size)], name, { type });
describe('analyzePickedUploadFiles', () => {
const maxImageBytes = 100;
const maxQueueSize = 2;
it('accepts images within size and queue limits', () => {
const picked = [makeFile('a.png', 'image/png', 50), makeFile('b.jpg', 'image/jpeg', 80)];
const result = analyzePickedUploadFiles(picked, maxImageBytes, maxQueueSize);
expect(result.accepted).toHaveLength(2);
expect(result.nonImageCount).toBe(0);
expect(result.oversizedImagesCount).toBe(0);
expect(result.queueOmittedImagesCount).toBe(0);
});
it('skips non-images and oversized files and caps the queue', () => {
const picked = [
makeFile('doc.pdf', 'application/pdf', 10),
makeFile('big.png', 'image/png', 200),
makeFile('1.png', 'image/png', 10),
makeFile('2.png', 'image/png', 10),
makeFile('3.png', 'image/png', 10)
];
const result = analyzePickedUploadFiles(picked, maxImageBytes, maxQueueSize);
expect(result.accepted.map(f => f.name)).toEqual(['1.png', '2.png']);
expect(result.nonImageCount).toBe(1);
expect(result.oversizedImagesCount).toBe(1);
expect(result.queueOmittedImagesCount).toBe(1);
});
});
@@ -0,0 +1,21 @@
import type { PickedUploadAnalysis } from '@/types/upload/PickedUploadAnalysis';
export const analyzePickedUploadFiles = (
picked: File[],
maxImageBytes: number,
maxQueueSize: number
): PickedUploadAnalysis => {
const images = picked.filter(file => file.type.startsWith('image/'));
const nonImageCount = picked.length - images.length;
const imagesWithinSizeLimit = images.filter(file => file.size <= maxImageBytes);
const oversizedImagesCount = images.length - imagesWithinSizeLimit.length;
const accepted = imagesWithinSizeLimit.slice(0, maxQueueSize);
const queueOmittedImagesCount = imagesWithinSizeLimit.length - accepted.length;
return {
accepted,
nonImageCount,
oversizedImagesCount,
queueOmittedImagesCount
};
};
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { buildPickedUploadWarning } from '@/utils/upload/buildPickedUploadWarning';
describe('buildPickedUploadWarning', () => {
it('returns null when nothing was skipped', () => {
expect(
buildPickedUploadWarning(
{ nonImageCount: 0, oversizedImagesCount: 0, queueOmittedImagesCount: 0 },
5
)
).toBeNull();
});
it('joins skip reasons into one message', () => {
const message = buildPickedUploadWarning(
{ nonImageCount: 1, oversizedImagesCount: 2, queueOmittedImagesCount: 3 },
4
);
expect(message).toContain('1 non-image');
expect(message).toContain('2 image(s) exceeded');
expect(message).toContain('first 4 valid images');
});
});
@@ -0,0 +1,22 @@
import type { PickedUploadAnalysis } from '@/types/upload/PickedUploadAnalysis';
export const buildPickedUploadWarning = (
summary: Pick<PickedUploadAnalysis, 'nonImageCount' | 'oversizedImagesCount' | 'queueOmittedImagesCount'>,
maxQueueSize: number
): string | null => {
const parts: string[] = [];
if (summary.nonImageCount > 0) {
parts.push(`${summary.nonImageCount} non-image file(s) were skipped.`);
}
if (summary.oversizedImagesCount > 0) {
parts.push(`${summary.oversizedImagesCount} image(s) exceeded the size limit and were skipped.`);
}
if (summary.queueOmittedImagesCount > 0) {
parts.push(`Only the first ${maxQueueSize} valid images were added.`);
}
return parts.length > 0 ? parts.join(' ') : null;
};
+22 -93
View File
@@ -9,44 +9,14 @@
</p>
</div>
<div>
<label
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90"
:class="loadingMetadata ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
>
Choose images
<input
ref="fileInputRef"
type="file"
accept="image/*"
multiple
class="sr-only"
:disabled="loadingMetadata"
@change="onFileInputChange"
/>
</label>
<UploadDropzone
ref="dropzoneRef"
:disabled="loadingMetadata"
:selection-error="selectionError"
@pick="onFilesPicked"
/>
<p v-if="selectionError" class="mt-2 text-sm text-destructive">{{ selectionError }}</p>
</div>
<ul v-if="queueRows.length > 0" class="divide-y divide-border rounded-lg border border-border">
<li v-for="row in queueRows" :key="row.key">
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60"
:class="isFileSelected(row.file) ? 'bg-muted/80' : ''"
@click="selectedFile = row.file"
>
<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">
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
</span>
<span class="shrink-0 text-xs" :class="statusTextClass(row.preview)">
{{ row.preview.message }}
</span>
</button>
</li>
</ul>
<UploadQueueList :rows="queueRows" :selected-file="selectedFile" @select="selectedFile = $event" />
<MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" />
</main>
@@ -54,12 +24,12 @@
<script setup lang="ts">
import { computed, onBeforeMount, ref } from 'vue';
import MetadataInspector from '@/components/upload/MetadataInspector.vue';
import type UploadDropzone from '@/components/upload/UploadDropzone.vue';
import { config } from '@/config';
import { useExiftoolStore } from '@/stores/exiftool';
import { useMetadataStore } from '@/stores/metadata';
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
import type { PickedUploadAnalysis } from '@/types/upload/PickedUploadAnalysis';
import { analyzePickedUploadFiles } from '@/utils/upload/analyzePickedUploadFiles';
import { buildPickedUploadWarning } from '@/utils/upload/buildPickedUploadWarning';
import prettyBytes from 'pretty-bytes';
const { maxQueueSize, maxImageBytes } = config.validation.upload;
@@ -74,7 +44,7 @@ const queuedFiles = ref<File[]>([]);
const selectedFile = ref<File | null>(null);
const selectionError = ref<string | null>(null);
const loadingMetadata = ref(false);
const fileInputRef = ref<HTMLInputElement | null>(null);
const dropzoneRef = ref<InstanceType<typeof UploadDropzone> | null>(null);
onBeforeMount(async () => {
try {
@@ -96,27 +66,22 @@ const queueRows = computed(() =>
const selectedPreview = computed(() => getPreview(selectedFile.value));
const isFileSelected = (file: File): boolean =>
selectedFile.value !== null && makeKeyFromFile(selectedFile.value) === makeKeyFromFile(file);
const statusTextClass = (entry: MetadataPreviewEntry): string =>
entry.status === 'error' ? 'text-destructive' : 'text-muted-foreground';
const onFileInputChange = async (event: Event): Promise<void> => {
const onFilesPicked = async (picked: File[]): Promise<void> => {
if (loadingMetadata.value) {
return;
}
const input = event.target as HTMLInputElement;
const picked = input.files ? [...input.files] : [];
resetUploadSelection();
if (picked.length === 0) {
return;
}
const { accepted, nonImageCount, oversizedImagesCount, queueOmittedImagesCount } = analyzePickedUploadFiles(picked);
const { accepted, nonImageCount, oversizedImagesCount, queueOmittedImagesCount } = analyzePickedUploadFiles(
picked,
maxImageBytes,
maxQueueSize
);
if (nonImageCount === picked.length) {
selectionError.value = 'Only image files can be added.';
@@ -130,7 +95,10 @@ const onFileInputChange = async (event: Event): Promise<void> => {
return;
}
selectionError.value = buildPickedUploadWarning({ nonImageCount, oversizedImagesCount, queueOmittedImagesCount });
selectionError.value = buildPickedUploadWarning(
{ nonImageCount, oversizedImagesCount, queueOmittedImagesCount },
maxQueueSize
);
queuedFiles.value = accepted;
selectedFile.value = accepted[0] ?? null;
@@ -151,45 +119,6 @@ const resetUploadSelection = (): void => {
clearAllPreviews();
queuedFiles.value = [];
selectedFile.value = null;
if (fileInputRef.value) {
fileInputRef.value.value = '';
}
};
const analyzePickedUploadFiles = (picked: File[]): PickedUploadAnalysis => {
const images = picked.filter(file => file.type.startsWith('image/'));
const nonImageCount = picked.length - images.length;
const imagesWithinSizeLimit = images.filter(file => file.size <= maxImageBytes);
const oversizedImagesCount = images.length - imagesWithinSizeLimit.length;
const accepted = imagesWithinSizeLimit.slice(0, maxQueueSize);
const queueOmittedImagesCount = imagesWithinSizeLimit.length - accepted.length;
return {
accepted,
nonImageCount,
oversizedImagesCount,
queueOmittedImagesCount
};
};
const buildPickedUploadWarning = (
summary: Pick<PickedUploadAnalysis, 'nonImageCount' | 'oversizedImagesCount' | 'queueOmittedImagesCount'>
): string | null => {
const parts: string[] = [];
if (summary.nonImageCount > 0) {
parts.push(`${summary.nonImageCount} non-image file(s) were skipped.`);
}
if (summary.oversizedImagesCount > 0) {
parts.push(`${summary.oversizedImagesCount} image(s) exceeded the size limit and were skipped.`);
}
if (summary.queueOmittedImagesCount > 0) {
parts.push(`Only the first ${maxQueueSize} valid images were added.`);
}
return parts.length > 0 ? parts.join(' ') : null;
dropzoneRef.value?.clearInput();
};
</script>