Slave/upload ui #8

Merged
nobswebdev merged 15 commits from slave/upload-ui into master 2026-10-25 22:43:57 +00:00
6 changed files with 156 additions and 61 deletions
Showing only changes of commit b4cc26a632 - Show all commits
-28
View File
@@ -1,28 +0,0 @@
# Upload flow PR plan
## Phase 1 — Configure UI (done)
- [x] Split upload page: dropzone, queue, metadata inspector, configure modal
- [x] Batch settings form (ZK / hotlink, expiry, max views, ZK password)
- [x] Preset → API helpers (`resolveUploadBatchExpiresAt`, `resolveUploadBatchMaxViews`, ZK prepare options builder)
- [x] Modal validation on Upload; batch settings summary after confirm
## Phase 2 — Upload execution + dual queue status (in progress)
- [ ] **Separate upload state** from metadata (`UploadQueueEntry` per file key); metadata column unchanged in `UploadQueueList`
- [ ] Show upload column once a batch upload has started (pending / uploading / uploaded / failed / skipped)
- [ ] `onBatchUpload`: stripped blob → encrypt → `useUploadStore` POST (ZK + hotlink)
- [ ] Share URLs via `buildZkViewerPath` / `buildHotlinkImagePath` (+ optional absolute URL from `VITE_CLEARNET_DOMAIN`)
- [ ] Disable dropzone / configure while batch upload runs; clear upload state on new pick
## Phase 3 — Hardening (follow-up or tail of PR)
- [ ] ALTCHA: fetch challenge + solve, pass `x-altcha` on upload (frontend not wired yet; backend may allow when disabled)
- [ ] Copy-to-clipboard for share links; optional delete token display
- [ ] Modal Esc / backdrop close
- [ ] Single validation gate (modal vs builder)
## Out of scope
- Viewer / delete pages
- E2E tests against docker-compose
+3
View File
@@ -20,6 +20,7 @@
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-success: var(--success);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
@@ -40,6 +41,7 @@
--accent: oklch(0.94 0.02 260); --accent: oklch(0.94 0.02 260);
--accent-foreground: oklch(0.28 0.03 260); --accent-foreground: oklch(0.28 0.03 260);
--destructive: oklch(0.55 0.2 25); --destructive: oklch(0.55 0.2 25);
--success: oklch(0.45 0.14 155);
--border: oklch(0.9 0.01 260); --border: oklch(0.9 0.01 260);
--input: oklch(0.9 0.01 260); --input: oklch(0.9 0.01 260);
--ring: oklch(0.42 0.08 260); --ring: oklch(0.42 0.08 260);
@@ -59,6 +61,7 @@
--accent: oklch(0.28 0.03 260); --accent: oklch(0.28 0.03 260);
--accent-foreground: oklch(0.96 0.01 260); --accent-foreground: oklch(0.96 0.01 260);
--destructive: oklch(0.62 0.2 25); --destructive: oklch(0.62 0.2 25);
--success: oklch(0.68 0.14 155);
--border: oklch(0.32 0.02 260); --border: oklch(0.32 0.02 260);
--input: oklch(0.32 0.02 260); --input: oklch(0.32 0.02 260);
--ring: oklch(0.72 0.1 260); --ring: oklch(0.72 0.1 260);
@@ -8,9 +8,16 @@
<div class="flex flex-1 flex-col gap-3 p-4"> <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-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"> <template v-else-if="entry.status === 'error'">
{{ entry.message }} <p class="text-sm text-destructive">{{ entry.message }}</p>
</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'"> <template v-else-if="entry.status === 'ready'">
<div <div
@@ -39,11 +39,53 @@
</footer> </footer>
</div> </div>
</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> </Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { ref, watch, type PropType } from 'vue';
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm'; import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import { validateUploadBatchSettings } from '@/utils/upload/validateUploadBatchSettings'; 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 settings = defineModel<UploadBatchSettingsForm>('settings', { required: true });
const props = defineProps({
metadataErrorCount: {
type: Number as PropType<number>,
required: true
}
});
const emit = defineEmits<{ const emit = defineEmits<{
upload: []; upload: [uploadDespiteMetadataFailure?: boolean];
}>(); }>();
const uploadValidationError = ref<string | null>(null); const uploadValidationError = ref<string | null>(null);
const metadataErrorConfirmOpen = ref(false);
watch(open, isOpen => { watch(open, isOpen => {
if (!isOpen) { if (!isOpen) {
uploadValidationError.value = null; uploadValidationError.value = null;
metadataErrorConfirmOpen.value = false;
} }
}); });
@@ -67,6 +118,13 @@ const close = (): void => {
open.value = false; open.value = false;
}; };
const emitUpload = (uploadDespiteMetadataFailure?: boolean): void => {
uploadValidationError.value = null;
metadataErrorConfirmOpen.value = false;
emit('upload', uploadDespiteMetadataFailure);
};
const onUploadClick = (): void => { const onUploadClick = (): void => {
const error = validateUploadBatchSettings(settings.value); const error = validateUploadBatchSettings(settings.value);
@@ -76,8 +134,12 @@ const onUploadClick = (): void => {
return; return;
} }
uploadValidationError.value = null; if (props.metadataErrorCount > 0) {
metadataErrorConfirmOpen.value = true;
emit('upload'); return;
}
emitUpload();
}; };
</script> </script>
@@ -11,8 +11,11 @@
<span class="shrink-0 text-xs text-muted-foreground"> <span class="shrink-0 text-xs text-muted-foreground">
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }} {{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
</span> </span>
<span class="shrink-0 text-xs" :class="statusTextClass(row.preview)"> <span class="shrink-0 max-w-32 truncate text-xs sm:max-w-40" :class="metadataStatusClass(row.metadata)">
{{ row.preview.message }} {{ 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> </span>
</button> </button>
</li> </li>
@@ -21,15 +24,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { type PropType } from 'vue'; import { type PropType } from 'vue';
import { useMetadataStore } from '@/stores/metadata';
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview'; 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 type { UploadQueueRow } from '@/types/upload/UploadQueueRow';
import prettyBytes from 'pretty-bytes'; import prettyBytes from 'pretty-bytes';
const metadataStore = useMetadataStore();
const { makeKeyFromFile } = metadataStore;
const props = defineProps({ const props = defineProps({
rows: { rows: {
type: Array as PropType<UploadQueueRow[]>, type: Array as PropType<UploadQueueRow[]>,
@@ -48,6 +48,27 @@ const emit = defineEmits<{
const isRowSelected = (row: UploadQueueRow): boolean => const isRowSelected = (row: UploadQueueRow): boolean =>
props.selectedFile !== null && makeKeyFromFile(props.selectedFile) === row.key; props.selectedFile !== null && makeKeyFromFile(props.selectedFile) === row.key;
const statusTextClass = (entry: MetadataPreviewEntry): string => const metadataStatusClass = (entry: MetadataPreviewEntry): string => {
entry.status === 'error' ? 'text-destructive' : 'text-muted-foreground'; 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> </script>
+46 -16
View File
@@ -11,9 +11,14 @@
<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"> <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 <button
v-if="!uploadConfirmed"
type="button" 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" 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" :disabled="!canOpenConfigure"
@@ -37,6 +42,7 @@
<UploadConfigureModal <UploadConfigureModal
v-model:open="configureModalOpen" v-model:open="configureModalOpen"
v-model:settings="batchSettings" v-model:settings="batchSettings"
:metadata-error-count="metadataErrorCount"
@upload="onBatchUpload" @upload="onBatchUpload"
/> />
</main> </main>
@@ -48,7 +54,9 @@ import type UploadDropzone from '@/components/upload/UploadDropzone.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';
import { useUploadStore } from '@/stores/upload';
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm'; import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
import { analyzePickedUploadFiles } from '@/utils/upload/analyzePickedUploadFiles'; import { analyzePickedUploadFiles } from '@/utils/upload/analyzePickedUploadFiles';
import { buildPickedUploadWarning } from '@/utils/upload/buildPickedUploadWarning'; import { buildPickedUploadWarning } from '@/utils/upload/buildPickedUploadWarning';
import { formatUploadBatchSettingsSummary } from '@/utils/upload/formatUploadBatchSettingsSummary'; import { formatUploadBatchSettingsSummary } from '@/utils/upload/formatUploadBatchSettingsSummary';
@@ -58,14 +66,17 @@ const { maxQueueSize, maxImageBytes } = config.validation.upload;
const exiftoolStore = useExiftoolStore(); const exiftoolStore = useExiftoolStore();
const metadataStore = useMetadataStore(); const metadataStore = useMetadataStore();
const uploadStore = useUploadStore();
const { initializeExiftool } = exiftoolStore; const { initializeExiftool } = exiftoolStore;
const { makeKeyFromFile, getPreview, loadPreviews, clearAllPreviews } = metadataStore; const { loadPreviews, clearAllPreviews } = metadataStore;
const { clearAllUploads, uploadBatch } = uploadStore;
const queuedFiles = ref<File[]>([]); const queuedFiles = ref<File[]>([]);
const selectedFile = ref<File | null>(null); 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 dropzoneRef = ref<InstanceType<typeof UploadDropzone> | null>(null); const dropzoneRef = ref<InstanceType<typeof UploadDropzone> | null>(null);
const batchSettings = ref<UploadBatchSettingsForm>({ const batchSettings = ref<UploadBatchSettingsForm>({
mode: 'zk', mode: 'zk',
@@ -75,6 +86,7 @@ const batchSettings = ref<UploadBatchSettingsForm>({
password: '' password: ''
}); });
const configureModalOpen = ref(false); const configureModalOpen = ref(false);
const uploadConfirmed = ref(false);
const confirmedBatchSummary = ref<string | null>(null); const confirmedBatchSummary = ref<string | null>(null);
onBeforeMount(async () => { onBeforeMount(async () => {
@@ -88,25 +100,28 @@ onBeforeMount(async () => {
const maxImageSizeFormatted = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 })); const maxImageSizeFormatted = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 }));
const queueRows = computed(() => const queueRows = computed(() =>
queuedFiles.value.map(file => ({ queuedFiles.value.map(file => {
const key = makeKeyFromFile(file);
return {
file, file,
key: makeKeyFromFile(file), key,
preview: getPreview(file) metadata: metadataStore.getPreview(file),
})) upload: uploadStore.getUpload(file)
};
})
); );
const selectedPreview = computed(() => getPreview(selectedFile.value)); const selectedPreview = computed(() => metadataStore.getPreview(selectedFile.value));
const canOpenConfigure = computed(() => { const metadataErrorCount = computed(
if (queuedFiles.value.length === 0 || loadingMetadata.value) { () => queuedFiles.value.filter(file => metadataStore.getPreview(file).status === 'error').length
return false; );
}
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> => { const onFilesPicked = async (picked: File[]): Promise<void> => {
if (loadingMetadata.value) { if (loadingMetadata.value || batchUploading.value) {
return; return;
} }
@@ -158,13 +173,28 @@ const resetUploadSelection = (): void => {
clearAllPreviews(); clearAllPreviews();
queuedFiles.value = []; queuedFiles.value = [];
selectedFile.value = null; selectedFile.value = null;
clearAllUploads();
dropzoneRef.value?.clearInput(); dropzoneRef.value?.clearInput();
configureModalOpen.value = false; configureModalOpen.value = false;
uploadConfirmed.value = false;
confirmedBatchSummary.value = null; confirmedBatchSummary.value = null;
}; };
const onBatchUpload = (): void => { const onBatchUpload = async (uploadDespiteMetadataFailure?: boolean): Promise<void> => {
confirmedBatchSummary.value = formatUploadBatchSettingsSummary(batchSettings.value); const settings = batchSettings.value;
confirmedBatchSummary.value = formatUploadBatchSettingsSummary(settings);
uploadConfirmed.value = true;
configureModalOpen.value = false; configureModalOpen.value = false;
batchUploading.value = true;
try {
await uploadBatch(queuedFiles.value, settings, uploadDespiteMetadataFailure);
} catch (error) {
console.error(error);
} finally {
batchUploading.value = false;
}
}; };
</script> </script>