Polish upload UI for dual queue status and batch confirm flow.
Metadata error confirm, success styling, upload-confirmed hiding of configure, and remove stale PR plan doc.
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-success: var(--success);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
@@ -40,6 +41,7 @@
|
||||
--accent: oklch(0.94 0.02 260);
|
||||
--accent-foreground: oklch(0.28 0.03 260);
|
||||
--destructive: oklch(0.55 0.2 25);
|
||||
--success: oklch(0.45 0.14 155);
|
||||
--border: oklch(0.9 0.01 260);
|
||||
--input: oklch(0.9 0.01 260);
|
||||
--ring: oklch(0.42 0.08 260);
|
||||
@@ -59,6 +61,7 @@
|
||||
--accent: oklch(0.28 0.03 260);
|
||||
--accent-foreground: oklch(0.96 0.01 260);
|
||||
--destructive: oklch(0.62 0.2 25);
|
||||
--success: oklch(0.68 0.14 155);
|
||||
--border: oklch(0.32 0.02 260);
|
||||
--input: oklch(0.32 0.02 260);
|
||||
--ring: oklch(0.72 0.1 260);
|
||||
|
||||
@@ -8,9 +8,16 @@
|
||||
<div class="flex flex-1 flex-col gap-3 p-4">
|
||||
<p v-if="!fileName" class="text-sm text-muted-foreground">Select an image to view its metadata.</p>
|
||||
|
||||
<p v-else-if="entry.status === 'error'" class="text-sm text-destructive">
|
||||
{{ entry.message }}
|
||||
</p>
|
||||
<template v-else-if="entry.status === 'error'">
|
||||
<p class="text-sm text-destructive">{{ entry.message }}</p>
|
||||
<template v-if="entry.output">
|
||||
<p class="text-xs font-medium text-foreground">Original</p>
|
||||
<pre
|
||||
class="max-h-96 min-h-32 flex-1 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed whitespace-pre-wrap text-foreground"
|
||||
>{{ entry.output }}</pre
|
||||
>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="entry.status === 'ready'">
|
||||
<div
|
||||
|
||||
@@ -39,11 +39,53 @@
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="open && metadataErrorConfirmOpen"
|
||||
class="fixed inset-0 z-60 flex items-end justify-center bg-black/50 p-4 sm:items-center"
|
||||
>
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="upload-metadata-error-confirm-title"
|
||||
aria-describedby="upload-metadata-error-confirm-desc"
|
||||
class="flex w-full max-w-md flex-col rounded-lg border border-border bg-card shadow-lg"
|
||||
>
|
||||
<header class="border-b border-border px-4 py-3">
|
||||
<h2 id="upload-metadata-error-confirm-title" class="text-base font-medium text-card-foreground">
|
||||
Metadata errors
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<p id="upload-metadata-error-confirm-desc" class="px-4 py-4 text-sm text-muted-foreground">
|
||||
{{ metadataErrorCount }}
|
||||
{{ metadataErrorCount === 1 ? 'image has' : 'images have' }} metadata error. Do you want to upload
|
||||
them with original metadata?
|
||||
</p>
|
||||
|
||||
<footer class="flex flex-col-reverse gap-2 border-t border-border px-4 py-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-muted/60"
|
||||
@click="metadataErrorConfirmOpen = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90"
|
||||
@click="emitUpload(true)"
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, watch, type PropType } from 'vue';
|
||||
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||
import { validateUploadBatchSettings } from '@/utils/upload/validateUploadBatchSettings';
|
||||
|
||||
@@ -51,15 +93,24 @@ const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
const settings = defineModel<UploadBatchSettingsForm>('settings', { required: true });
|
||||
|
||||
const props = defineProps({
|
||||
metadataErrorCount: {
|
||||
type: Number as PropType<number>,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
upload: [];
|
||||
upload: [uploadDespiteMetadataFailure?: boolean];
|
||||
}>();
|
||||
|
||||
const uploadValidationError = ref<string | null>(null);
|
||||
const metadataErrorConfirmOpen = ref(false);
|
||||
|
||||
watch(open, isOpen => {
|
||||
if (!isOpen) {
|
||||
uploadValidationError.value = null;
|
||||
metadataErrorConfirmOpen.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -67,6 +118,13 @@ const close = (): void => {
|
||||
open.value = false;
|
||||
};
|
||||
|
||||
const emitUpload = (uploadDespiteMetadataFailure?: boolean): void => {
|
||||
uploadValidationError.value = null;
|
||||
metadataErrorConfirmOpen.value = false;
|
||||
|
||||
emit('upload', uploadDespiteMetadataFailure);
|
||||
};
|
||||
|
||||
const onUploadClick = (): void => {
|
||||
const error = validateUploadBatchSettings(settings.value);
|
||||
|
||||
@@ -76,8 +134,12 @@ const onUploadClick = (): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
uploadValidationError.value = null;
|
||||
if (props.metadataErrorCount > 0) {
|
||||
metadataErrorConfirmOpen.value = true;
|
||||
|
||||
emit('upload');
|
||||
return;
|
||||
}
|
||||
|
||||
emitUpload();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -11,8 +11,11 @@
|
||||
<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 class="shrink-0 max-w-32 truncate text-xs sm:max-w-40" :class="metadataStatusClass(row.metadata)">
|
||||
{{ row.metadata.message }}
|
||||
</span>
|
||||
<span class="shrink-0 max-w-32 truncate text-xs sm:max-w-40" :class="uploadStatusClass(row.upload)">
|
||||
{{ row.upload.message }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -21,15 +24,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type PropType } from 'vue';
|
||||
import { useMetadataStore } from '@/stores/metadata';
|
||||
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
||||
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
|
||||
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
|
||||
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[]>,
|
||||
@@ -48,6 +48,27 @@ const emit = defineEmits<{
|
||||
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';
|
||||
const metadataStatusClass = (entry: MetadataPreviewEntry): string => {
|
||||
if (entry.status === 'error') {
|
||||
return 'text-destructive';
|
||||
}
|
||||
|
||||
if (entry.status === 'ready') {
|
||||
return 'text-success';
|
||||
}
|
||||
|
||||
return 'text-muted-foreground';
|
||||
};
|
||||
|
||||
const uploadStatusClass = (entry: UploadQueueEntry): string => {
|
||||
if (entry.status === 'failed') {
|
||||
return 'text-destructive';
|
||||
}
|
||||
|
||||
if (entry.status === 'uploaded') {
|
||||
return 'text-success';
|
||||
}
|
||||
|
||||
return 'text-muted-foreground';
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -11,9 +11,14 @@
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<UploadDropzone ref="dropzoneRef" :disabled="loadingMetadata" @pick="onFilesPicked" />
|
||||
<UploadDropzone
|
||||
ref="dropzoneRef"
|
||||
:disabled="loadingMetadata || batchUploading"
|
||||
@pick="onFilesPicked"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="!uploadConfirmed"
|
||||
type="button"
|
||||
class="inline-flex shrink-0 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 sm:self-center"
|
||||
:disabled="!canOpenConfigure"
|
||||
@@ -37,6 +42,7 @@
|
||||
<UploadConfigureModal
|
||||
v-model:open="configureModalOpen"
|
||||
v-model:settings="batchSettings"
|
||||
:metadata-error-count="metadataErrorCount"
|
||||
@upload="onBatchUpload"
|
||||
/>
|
||||
</main>
|
||||
@@ -48,7 +54,9 @@ import type UploadDropzone from '@/components/upload/UploadDropzone.vue';
|
||||
import { config } from '@/config';
|
||||
import { useExiftoolStore } from '@/stores/exiftool';
|
||||
import { useMetadataStore } from '@/stores/metadata';
|
||||
import { useUploadStore } from '@/stores/upload';
|
||||
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
|
||||
import { analyzePickedUploadFiles } from '@/utils/upload/analyzePickedUploadFiles';
|
||||
import { buildPickedUploadWarning } from '@/utils/upload/buildPickedUploadWarning';
|
||||
import { formatUploadBatchSettingsSummary } from '@/utils/upload/formatUploadBatchSettingsSummary';
|
||||
@@ -58,14 +66,17 @@ const { maxQueueSize, maxImageBytes } = config.validation.upload;
|
||||
|
||||
const exiftoolStore = useExiftoolStore();
|
||||
const metadataStore = useMetadataStore();
|
||||
const uploadStore = useUploadStore();
|
||||
|
||||
const { initializeExiftool } = exiftoolStore;
|
||||
const { makeKeyFromFile, getPreview, loadPreviews, clearAllPreviews } = metadataStore;
|
||||
const { loadPreviews, clearAllPreviews } = metadataStore;
|
||||
const { clearAllUploads, uploadBatch } = uploadStore;
|
||||
|
||||
const queuedFiles = ref<File[]>([]);
|
||||
const selectedFile = ref<File | null>(null);
|
||||
const selectionError = ref<string | null>(null);
|
||||
const loadingMetadata = ref(false);
|
||||
const batchUploading = ref(false);
|
||||
const dropzoneRef = ref<InstanceType<typeof UploadDropzone> | null>(null);
|
||||
const batchSettings = ref<UploadBatchSettingsForm>({
|
||||
mode: 'zk',
|
||||
@@ -75,6 +86,7 @@ const batchSettings = ref<UploadBatchSettingsForm>({
|
||||
password: ''
|
||||
});
|
||||
const configureModalOpen = ref(false);
|
||||
const uploadConfirmed = ref(false);
|
||||
const confirmedBatchSummary = ref<string | null>(null);
|
||||
|
||||
onBeforeMount(async () => {
|
||||
@@ -88,25 +100,28 @@ onBeforeMount(async () => {
|
||||
const maxImageSizeFormatted = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 }));
|
||||
|
||||
const queueRows = computed(() =>
|
||||
queuedFiles.value.map(file => ({
|
||||
file,
|
||||
key: makeKeyFromFile(file),
|
||||
preview: getPreview(file)
|
||||
}))
|
||||
queuedFiles.value.map(file => {
|
||||
const key = makeKeyFromFile(file);
|
||||
|
||||
return {
|
||||
file,
|
||||
key,
|
||||
metadata: metadataStore.getPreview(file),
|
||||
upload: uploadStore.getUpload(file)
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const selectedPreview = computed(() => getPreview(selectedFile.value));
|
||||
const selectedPreview = computed(() => metadataStore.getPreview(selectedFile.value));
|
||||
|
||||
const canOpenConfigure = computed(() => {
|
||||
if (queuedFiles.value.length === 0 || loadingMetadata.value) {
|
||||
return false;
|
||||
}
|
||||
const metadataErrorCount = computed(
|
||||
() => queuedFiles.value.filter(file => metadataStore.getPreview(file).status === 'error').length
|
||||
);
|
||||
|
||||
return !queuedFiles.value.some(file => getPreview(file).status === 'error');
|
||||
});
|
||||
const canOpenConfigure = computed(() => queuedFiles.value.length > 0 && !loadingMetadata.value);
|
||||
|
||||
const onFilesPicked = async (picked: File[]): Promise<void> => {
|
||||
if (loadingMetadata.value) {
|
||||
if (loadingMetadata.value || batchUploading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -158,13 +173,28 @@ const resetUploadSelection = (): void => {
|
||||
clearAllPreviews();
|
||||
queuedFiles.value = [];
|
||||
selectedFile.value = null;
|
||||
clearAllUploads();
|
||||
dropzoneRef.value?.clearInput();
|
||||
configureModalOpen.value = false;
|
||||
uploadConfirmed.value = false;
|
||||
confirmedBatchSummary.value = null;
|
||||
};
|
||||
|
||||
const onBatchUpload = (): void => {
|
||||
confirmedBatchSummary.value = formatUploadBatchSettingsSummary(batchSettings.value);
|
||||
const onBatchUpload = async (uploadDespiteMetadataFailure?: boolean): Promise<void> => {
|
||||
const settings = batchSettings.value;
|
||||
|
||||
confirmedBatchSummary.value = formatUploadBatchSettingsSummary(settings);
|
||||
uploadConfirmed.value = true;
|
||||
configureModalOpen.value = false;
|
||||
|
||||
batchUploading.value = true;
|
||||
|
||||
try {
|
||||
await uploadBatch(queuedFiles.value, settings, uploadDespiteMetadataFailure);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
batchUploading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user