replace upload dropzone with global intake controls.

Window drag-drop and paste share the same pick path as the file chooser; configure modal lives in the intake component.
This commit is contained in:
2026-10-29 21:40:07 +01:00
parent ebe8b9aa2f
commit 9cc73e0dc5
4 changed files with 195 additions and 103 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ declare module 'vue' {
RouterView: typeof import('vue-router')['RouterView']
ShareLinkField: typeof import('./components/upload/ShareLinkField.vue')['default']
UploadConfigureModal: typeof import('./components/upload/UploadConfigureModal.vue')['default']
UploadDropzone: typeof import('./components/upload/UploadDropzone.vue')['default']
UploadIntakeControls: typeof import('./components/upload/UploadIntakeControls.vue')['default']
UploadQueueList: typeof import('./components/upload/UploadQueueList.vue')['default']
UploadSettingsPanel: typeof import('./components/upload/UploadSettingsPanel.vue')['default']
UploadShareResults: typeof import('./components/upload/UploadShareResults.vue')['default']
@@ -1,57 +0,0 @@
<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="hidden"
: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,179 @@
<template>
<div class="flex w-full flex-col gap-3">
<p class="mb-3 text-base text-muted-foreground">
Drop anywhere, paste or choose files. Max {{ maxQueueSize }} images, up to {{ maxImageSizeLabel }} each.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<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="hidden"
:disabled="disabled"
@change="onFileInputChange"
/>
</label>
<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"
:disabled="!canOpenConfigure"
@click="configureModalOpen = true"
>
Configure & upload
</button>
</div>
<UploadConfigureModal
v-model:open="configureModalOpen"
v-model:settings="settings"
:metadata-error-count="metadataErrorCount"
@upload="onUploadFromModal"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { config } from '@/config';
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import prettyBytes from 'pretty-bytes';
const { maxQueueSize, maxImageBytes } = config.validation.upload;
const props = defineProps({
disabled: {
type: Boolean,
required: true
},
canOpenConfigure: {
type: Boolean,
required: true
},
uploadConfirmed: {
type: Boolean,
required: true
},
metadataErrorCount: {
type: Number,
required: true
}
});
const settings = defineModel<UploadBatchSettingsForm>('settings', { required: true });
const emit = defineEmits<{
pick: [files: File[]];
upload: [uploadDespiteMetadataFailure?: boolean];
}>();
const fileInputRef = ref<HTMLInputElement | null>(null);
const configureModalOpen = ref(false);
onMounted(() => {
window.addEventListener('dragover', onWindowDragOver);
window.addEventListener('drop', onWindowDrop);
window.addEventListener('paste', onWindowPaste);
});
onBeforeUnmount(() => {
window.removeEventListener('dragover', onWindowDragOver);
window.removeEventListener('drop', onWindowDrop);
window.removeEventListener('paste', onWindowPaste);
});
const maxImageSizeLabel = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 }));
const emitPicked = (picked: File[]): void => {
if (picked.length === 0) {
return;
}
emit('pick', picked);
};
const onFileInputChange = (event: Event): void => {
const input = event.target as HTMLInputElement;
const picked = input.files ? [...input.files] : [];
emitPicked(picked);
};
const onUploadFromModal = (uploadDespiteMetadataFailure?: boolean): void => {
configureModalOpen.value = false;
emit('upload', uploadDespiteMetadataFailure);
};
const isFileDrag = (event: DragEvent): boolean => {
return Boolean(event.dataTransfer?.types.includes('Files'));
};
const onWindowDragOver = (event: DragEvent): void => {
if (props.disabled || !isFileDrag(event)) {
return;
}
event.preventDefault();
};
const onWindowDrop = (event: DragEvent): void => {
if (props.disabled) {
return;
}
event.preventDefault();
const picked = event.dataTransfer?.files ? [...event.dataTransfer.files] : [];
emitPicked(picked);
};
const isPasswordInputTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) {
return false;
}
return target.closest('input[type="password"]') !== null;
};
const onWindowPaste = (event: ClipboardEvent): void => {
if (props.disabled) {
return;
}
const picked = event.clipboardData?.files ? [...event.clipboardData.files] : [];
if (picked.length === 0) {
return;
}
if (isPasswordInputTarget(event.target)) {
return;
}
event.preventDefault();
emitPicked(picked);
};
const clearInput = (): void => {
if (fileInputRef.value) {
fileInputRef.value.value = '';
}
};
const closeConfigureModal = (): void => {
configureModalOpen.value = false;
};
defineExpose({ clearInput, closeConfigureModal });
</script>
+15 -45
View File
@@ -1,32 +1,16 @@
<template>
<main class="mx-auto flex w-full max-w-3xl flex-1 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 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 || 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"
@click="configureModalOpen = true"
>
Configure & upload
</button>
</div>
<UploadIntakeControls
ref="intakeControlsRef"
v-model:settings="batchSettings"
:disabled="loadingMetadata || batchUploading"
:can-open-configure="canOpenConfigure"
:upload-confirmed="uploadConfirmed"
:metadata-error-count="metadataErrorCount"
@pick="onFilesPicked"
@upload="onBatchUpload"
/>
<p v-if="selectionError" class="text-sm text-destructive">{{ selectionError }}</p>
</div>
@@ -37,26 +21,15 @@
<UploadQueueList :rows="queueRows" :selected-file="selectedFile" @select="selectedFile = $event" />
<UploadShareResults
v-if="showShareLinks"
:file-name="selectedFile?.name ?? null"
:upload="selectedUpload"
/>
<UploadShareResults v-if="showShareLinks" :file-name="selectedFile?.name ?? null" :upload="selectedUpload" />
<MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" />
<UploadConfigureModal
v-model:open="configureModalOpen"
v-model:settings="batchSettings"
:metadata-error-count="metadataErrorCount"
@upload="onBatchUpload"
/>
</main>
</template>
<script setup lang="ts">
import { computed, onBeforeMount, ref } from 'vue';
import type UploadDropzone from '@/components/upload/UploadDropzone.vue';
import type UploadIntakeControls from '@/components/upload/UploadIntakeControls.vue';
import { config } from '@/config';
import { useExiftoolStore } from '@/stores/exiftool';
import { useMetadataStore } from '@/stores/metadata';
@@ -83,7 +56,7 @@ 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 intakeControlsRef = ref<InstanceType<typeof UploadIntakeControls> | null>(null);
const batchSettings = ref<UploadBatchSettingsForm>({
mode: 'zk',
expiryPreset: 'never',
@@ -91,7 +64,6 @@ const batchSettings = ref<UploadBatchSettingsForm>({
passwordEnabled: false,
password: ''
});
const configureModalOpen = ref(false);
const uploadConfirmed = ref(false);
const showShareLinks = ref(false);
const confirmedBatchSummary = ref<string | null>(null);
@@ -183,8 +155,8 @@ const resetUploadSession = (): void => {
queuedFiles.value = [];
selectedFile.value = null;
clearAllUploads();
dropzoneRef.value?.clearInput();
configureModalOpen.value = false;
intakeControlsRef.value?.clearInput();
intakeControlsRef.value?.closeConfigureModal();
uploadConfirmed.value = false;
showShareLinks.value = false;
confirmedBatchSummary.value = null;
@@ -195,8 +167,6 @@ const onBatchUpload = async (uploadDespiteMetadataFailure?: boolean): Promise<vo
confirmedBatchSummary.value = formatUploadBatchSettingsSummary(settings);
uploadConfirmed.value = true;
configureModalOpen.value = false;
batchUploading.value = true;
try {