Slave/ui polishing #13
Vendored
+1
-1
@@ -23,7 +23,7 @@ declare module 'vue' {
|
|||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
ShareLinkField: typeof import('./components/upload/ShareLinkField.vue')['default']
|
ShareLinkField: typeof import('./components/upload/ShareLinkField.vue')['default']
|
||||||
UploadConfigureModal: typeof import('./components/upload/UploadConfigureModal.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']
|
UploadQueueList: typeof import('./components/upload/UploadQueueList.vue')['default']
|
||||||
UploadSettingsPanel: typeof import('./components/upload/UploadSettingsPanel.vue')['default']
|
UploadSettingsPanel: typeof import('./components/upload/UploadSettingsPanel.vue')['default']
|
||||||
UploadShareResults: typeof import('./components/upload/UploadShareResults.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>
|
||||||
@@ -1,33 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-8">
|
<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-2">
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<UploadIntakeControls
|
||||||
<UploadDropzone
|
ref="intakeControlsRef"
|
||||||
ref="dropzoneRef"
|
v-model:settings="batchSettings"
|
||||||
:disabled="loadingMetadata || batchUploading"
|
:disabled="loadingMetadata || batchUploading"
|
||||||
|
:can-open-configure="canOpenConfigure"
|
||||||
|
:upload-confirmed="uploadConfirmed"
|
||||||
|
:metadata-error-count="metadataErrorCount"
|
||||||
@pick="onFilesPicked"
|
@pick="onFilesPicked"
|
||||||
|
@upload="onBatchUpload"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<p v-if="selectionError" class="text-sm text-destructive">{{ selectionError }}</p>
|
<p v-if="selectionError" class="text-sm text-destructive">{{ selectionError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -37,26 +21,15 @@
|
|||||||
|
|
||||||
<UploadQueueList :rows="queueRows" :selected-file="selectedFile" @select="selectedFile = $event" />
|
<UploadQueueList :rows="queueRows" :selected-file="selectedFile" @select="selectedFile = $event" />
|
||||||
|
|
||||||
<UploadShareResults
|
<UploadShareResults v-if="showShareLinks" :file-name="selectedFile?.name ?? null" :upload="selectedUpload" />
|
||||||
v-if="showShareLinks"
|
|
||||||
:file-name="selectedFile?.name ?? null"
|
|
||||||
:upload="selectedUpload"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" />
|
<MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" />
|
||||||
|
|
||||||
<UploadConfigureModal
|
|
||||||
v-model:open="configureModalOpen"
|
|
||||||
v-model:settings="batchSettings"
|
|
||||||
:metadata-error-count="metadataErrorCount"
|
|
||||||
@upload="onBatchUpload"
|
|
||||||
/>
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeMount, ref } from 'vue';
|
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 { config } from '@/config';
|
||||||
import { useExiftoolStore } from '@/stores/exiftool';
|
import { useExiftoolStore } from '@/stores/exiftool';
|
||||||
import { useMetadataStore } from '@/stores/metadata';
|
import { useMetadataStore } from '@/stores/metadata';
|
||||||
@@ -83,7 +56,7 @@ const selectedFile = ref<File | null>(null);
|
|||||||
const selectionError = ref<string | null>(null);
|
const selectionError = ref<string | null>(null);
|
||||||
const loadingMetadata = ref(false);
|
const loadingMetadata = ref(false);
|
||||||
const batchUploading = 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>({
|
const batchSettings = ref<UploadBatchSettingsForm>({
|
||||||
mode: 'zk',
|
mode: 'zk',
|
||||||
expiryPreset: 'never',
|
expiryPreset: 'never',
|
||||||
@@ -91,7 +64,6 @@ const batchSettings = ref<UploadBatchSettingsForm>({
|
|||||||
passwordEnabled: false,
|
passwordEnabled: false,
|
||||||
password: ''
|
password: ''
|
||||||
});
|
});
|
||||||
const configureModalOpen = ref(false);
|
|
||||||
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);
|
||||||
@@ -183,8 +155,8 @@ const resetUploadSession = (): void => {
|
|||||||
queuedFiles.value = [];
|
queuedFiles.value = [];
|
||||||
selectedFile.value = null;
|
selectedFile.value = null;
|
||||||
clearAllUploads();
|
clearAllUploads();
|
||||||
dropzoneRef.value?.clearInput();
|
intakeControlsRef.value?.clearInput();
|
||||||
configureModalOpen.value = false;
|
intakeControlsRef.value?.closeConfigureModal();
|
||||||
uploadConfirmed.value = false;
|
uploadConfirmed.value = false;
|
||||||
showShareLinks.value = false;
|
showShareLinks.value = false;
|
||||||
confirmedBatchSummary.value = null;
|
confirmedBatchSummary.value = null;
|
||||||
@@ -195,8 +167,6 @@ const onBatchUpload = async (uploadDespiteMetadataFailure?: boolean): Promise<vo
|
|||||||
|
|
||||||
confirmedBatchSummary.value = formatUploadBatchSettingsSummary(settings);
|
confirmedBatchSummary.value = formatUploadBatchSettingsSummary(settings);
|
||||||
uploadConfirmed.value = true;
|
uploadConfirmed.value = true;
|
||||||
configureModalOpen.value = false;
|
|
||||||
|
|
||||||
batchUploading.value = true;
|
batchUploading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user