Add upload queue UI with ExifTool metadata preview on pick.
Users can select images within configured limits, see per-row load status, and inspect raw ExifTool output before upload work continues.
This commit is contained in:
@@ -1,19 +1,195 @@
|
||||
<template>
|
||||
<main class="flex min-h-screen flex-col p-8">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Upload</h1>
|
||||
<p class="mt-2 text-muted-foreground">Upload UI coming soon.</p>
|
||||
<main class="mx-auto flex min-h-screen max-w-3xl flex-col gap-6 p-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Upload</h1>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
Choose up to {{ maxQueueSize }} images, up to
|
||||
{{ maxImageSizeFormatted }}
|
||||
each.
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeMount } from 'vue';
|
||||
import { computed, onBeforeMount, ref } from 'vue';
|
||||
import MetadataInspector from '@/components/upload/MetadataInspector.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 prettyBytes from 'pretty-bytes';
|
||||
|
||||
const { maxQueueSize, maxImageBytes } = config.validation.upload;
|
||||
|
||||
const exiftoolStore = useExiftoolStore();
|
||||
const metadataStore = useMetadataStore();
|
||||
|
||||
const { initializeExiftool } = exiftoolStore;
|
||||
const { makeKeyFromFile, getPreview, loadPreviews, clearAllPreviews } = metadataStore;
|
||||
|
||||
onBeforeMount(() => {
|
||||
initializeExiftool();
|
||||
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);
|
||||
|
||||
onBeforeMount(async () => {
|
||||
try {
|
||||
await initializeExiftool();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
const maxImageSizeFormatted = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 }));
|
||||
|
||||
const queueRows = computed(() =>
|
||||
queuedFiles.value.map(file => ({
|
||||
file,
|
||||
key: makeKeyFromFile(file),
|
||||
preview: getPreview(file)
|
||||
}))
|
||||
);
|
||||
|
||||
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> => {
|
||||
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);
|
||||
|
||||
if (nonImageCount === picked.length) {
|
||||
selectionError.value = 'Only image files can be added.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (accepted.length === 0) {
|
||||
selectionError.value = `Each image must be at most ${maxImageSizeFormatted.value}.`;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
selectionError.value = buildPickedUploadWarning({ nonImageCount, oversizedImagesCount, queueOmittedImagesCount });
|
||||
|
||||
queuedFiles.value = accepted;
|
||||
selectedFile.value = accepted[0] ?? null;
|
||||
|
||||
loadingMetadata.value = true;
|
||||
|
||||
try {
|
||||
await loadPreviews(accepted);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loadingMetadata.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetUploadSelection = (): void => {
|
||||
selectionError.value = null;
|
||||
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;
|
||||
};
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user