From 805665745bacf6e37142e8d822f11b16a23e362e Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Thu, 22 Oct 2026 03:14:02 +0200
Subject: [PATCH] Add batch upload configure modal and settings mapping
utilities.
Users configure share mode, expiry, and max views before upload; batch summary confirms choices above the queue.
---
docs/UPLOAD_PR_PLAN.md | 28 ++++
frontend/package-lock.json | 7 +
frontend/package.json | 1 +
frontend/src/components.d.ts | 2 +
.../components/upload/MetadataInspector.vue | 2 +-
.../upload/UploadConfigureModal.vue | 83 +++++++++++
.../components/upload/UploadSettingsPanel.vue | 132 ++++++++++++++++++
.../types/upload/UploadBatchSettingsForm.ts | 11 ++
.../src/types/upload/UploadExpiryOffset.ts | 6 +
.../src/types/upload/UploadExpiryPreset.ts | 1 +
.../src/types/upload/UploadMaxViewsPreset.ts | 1 +
.../upload/ZkUploadPrepareOptionsResult.ts | 5 +
...oadPrepareOptionsFromBatchSettings.spec.ts | 74 ++++++++++
...ZkUploadPrepareOptionsFromBatchSettings.ts | 39 ++++++
.../formatUploadBatchSettingsSummary.spec.ts | 42 ++++++
.../formatUploadBatchSettingsSummary.ts | 29 ++++
.../resolveUploadBatchExpiresAt.spec.ts | 24 ++++
.../upload/resolveUploadBatchExpiresAt.ts | 20 +++
.../upload/resolveUploadBatchMaxViews.spec.ts | 13 ++
.../upload/resolveUploadBatchMaxViews.ts | 9 ++
.../validateUploadBatchSettings.spec.ts | 44 ++++++
.../upload/validateUploadBatchSettings.ts | 13 ++
frontend/src/views/UploadView.vue | 58 +++++++-
23 files changed, 637 insertions(+), 7 deletions(-)
create mode 100644 docs/UPLOAD_PR_PLAN.md
create mode 100644 frontend/src/components/upload/UploadConfigureModal.vue
create mode 100644 frontend/src/components/upload/UploadSettingsPanel.vue
create mode 100644 frontend/src/types/upload/UploadBatchSettingsForm.ts
create mode 100644 frontend/src/types/upload/UploadExpiryOffset.ts
create mode 100644 frontend/src/types/upload/UploadExpiryPreset.ts
create mode 100644 frontend/src/types/upload/UploadMaxViewsPreset.ts
create mode 100644 frontend/src/types/upload/ZkUploadPrepareOptionsResult.ts
create mode 100644 frontend/src/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings.spec.ts
create mode 100644 frontend/src/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings.ts
create mode 100644 frontend/src/utils/upload/formatUploadBatchSettingsSummary.spec.ts
create mode 100644 frontend/src/utils/upload/formatUploadBatchSettingsSummary.ts
create mode 100644 frontend/src/utils/upload/resolveUploadBatchExpiresAt.spec.ts
create mode 100644 frontend/src/utils/upload/resolveUploadBatchExpiresAt.ts
create mode 100644 frontend/src/utils/upload/resolveUploadBatchMaxViews.spec.ts
create mode 100644 frontend/src/utils/upload/resolveUploadBatchMaxViews.ts
create mode 100644 frontend/src/utils/upload/validateUploadBatchSettings.spec.ts
create mode 100644 frontend/src/utils/upload/validateUploadBatchSettings.ts
diff --git a/docs/UPLOAD_PR_PLAN.md b/docs/UPLOAD_PR_PLAN.md
new file mode 100644
index 0000000..ef8680b
--- /dev/null
+++ b/docs/UPLOAD_PR_PLAN.md
@@ -0,0 +1,28 @@
+# 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
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 436616e..496fac1 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -12,6 +12,7 @@
"@tailwindcss/vite": "^4.3.3",
"@uswriting/exiftool": "1.0.9",
"axios": "^1.20.0",
+ "dayjs": "^1.11.23",
"hash-wasm": "^4.12.0",
"pinia": "^4.0.3",
"pretty-bytes": "^7.2.0",
@@ -2199,6 +2200,12 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
+ "node_modules/dayjs": {
+ "version": "1.11.23",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
+ "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
+ "license": "MIT"
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index f1bedbc..e5f88cb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,6 +14,7 @@
"@tailwindcss/vite": "^4.3.3",
"@uswriting/exiftool": "1.0.9",
"axios": "^1.20.0",
+ "dayjs": "^1.11.23",
"hash-wasm": "^4.12.0",
"pinia": "^4.0.3",
"pretty-bytes": "^7.2.0",
diff --git a/frontend/src/components.d.ts b/frontend/src/components.d.ts
index f41ef2e..1d73c9e 100644
--- a/frontend/src/components.d.ts
+++ b/frontend/src/components.d.ts
@@ -14,7 +14,9 @@ declare module 'vue' {
MetadataInspector: typeof import('./components/upload/MetadataInspector.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
+ UploadConfigureModal: typeof import('./components/upload/UploadConfigureModal.vue')['default']
UploadDropzone: typeof import('./components/upload/UploadDropzone.vue')['default']
UploadQueueList: typeof import('./components/upload/UploadQueueList.vue')['default']
+ UploadSettingsPanel: typeof import('./components/upload/UploadSettingsPanel.vue')['default']
}
}
diff --git a/frontend/src/components/upload/MetadataInspector.vue b/frontend/src/components/upload/MetadataInspector.vue
index 6c78537..a1ff4a9 100644
--- a/frontend/src/components/upload/MetadataInspector.vue
+++ b/frontend/src/components/upload/MetadataInspector.vue
@@ -1,7 +1,7 @@
- Metadata
+ Metadata Inspector
{{ fileName }}
diff --git a/frontend/src/components/upload/UploadConfigureModal.vue b/frontend/src/components/upload/UploadConfigureModal.vue
new file mode 100644
index 0000000..64abf68
--- /dev/null
+++ b/frontend/src/components/upload/UploadConfigureModal.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+ {{ uploadValidationError }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/upload/UploadSettingsPanel.vue b/frontend/src/components/upload/UploadSettingsPanel.vue
new file mode 100644
index 0000000..b89669a
--- /dev/null
+++ b/frontend/src/components/upload/UploadSettingsPanel.vue
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/types/upload/UploadBatchSettingsForm.ts b/frontend/src/types/upload/UploadBatchSettingsForm.ts
new file mode 100644
index 0000000..d2bcbda
--- /dev/null
+++ b/frontend/src/types/upload/UploadBatchSettingsForm.ts
@@ -0,0 +1,11 @@
+import type { UploadExpiryPreset } from '@/types/upload/UploadExpiryPreset';
+import type { UploadMaxViewsPreset } from '@/types/upload/UploadMaxViewsPreset';
+import type { UploadMode } from '@/types/upload/UploadMode';
+
+export type UploadBatchSettingsForm = {
+ mode: UploadMode;
+ expiryPreset: UploadExpiryPreset;
+ maxViewsPreset: UploadMaxViewsPreset;
+ passwordEnabled: boolean;
+ password: string;
+};
diff --git a/frontend/src/types/upload/UploadExpiryOffset.ts b/frontend/src/types/upload/UploadExpiryOffset.ts
new file mode 100644
index 0000000..e48a18e
--- /dev/null
+++ b/frontend/src/types/upload/UploadExpiryOffset.ts
@@ -0,0 +1,6 @@
+import type { ManipulateType } from 'dayjs';
+
+export type UploadExpiryOffset = {
+ amount: number;
+ unit: ManipulateType;
+};
diff --git a/frontend/src/types/upload/UploadExpiryPreset.ts b/frontend/src/types/upload/UploadExpiryPreset.ts
new file mode 100644
index 0000000..8c3ea24
--- /dev/null
+++ b/frontend/src/types/upload/UploadExpiryPreset.ts
@@ -0,0 +1 @@
+export type UploadExpiryPreset = 'never' | '1h' | '24h' | '7d' | '30d';
diff --git a/frontend/src/types/upload/UploadMaxViewsPreset.ts b/frontend/src/types/upload/UploadMaxViewsPreset.ts
new file mode 100644
index 0000000..48ca709
--- /dev/null
+++ b/frontend/src/types/upload/UploadMaxViewsPreset.ts
@@ -0,0 +1 @@
+export type UploadMaxViewsPreset = 'unlimited' | '1' | '10' | '100' | '1000' | '10000';
diff --git a/frontend/src/types/upload/ZkUploadPrepareOptionsResult.ts b/frontend/src/types/upload/ZkUploadPrepareOptionsResult.ts
new file mode 100644
index 0000000..e6d88f4
--- /dev/null
+++ b/frontend/src/types/upload/ZkUploadPrepareOptionsResult.ts
@@ -0,0 +1,5 @@
+import type { ZkUploadPrepareOptions } from '@/types/upload/ZkUploadPrepareOptions';
+
+export type ZkUploadPrepareOptionsResult =
+ | { ok: true; options: ZkUploadPrepareOptions }
+ | { ok: false; error: string };
diff --git a/frontend/src/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings.spec.ts b/frontend/src/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings.spec.ts
new file mode 100644
index 0000000..fa11bcf
--- /dev/null
+++ b/frontend/src/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings.spec.ts
@@ -0,0 +1,74 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
+import { buildZkUploadPrepareOptionsFromBatchSettings } from '@/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings';
+
+const baseSettings: UploadBatchSettingsForm = {
+ mode: 'zk',
+ expiryPreset: 'never',
+ maxViewsPreset: 'unlimited',
+ passwordEnabled: false,
+ password: ''
+};
+
+describe('buildZkUploadPrepareOptionsFromBatchSettings', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-06-01T00:00:00.000Z'));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('builds non-password options', () => {
+ const settings = {
+ ...baseSettings,
+ expiryPreset: '24h' as const,
+ maxViewsPreset: '10' as const
+ };
+
+ const result = buildZkUploadPrepareOptionsFromBatchSettings(settings);
+
+ expect(result).toEqual({
+ ok: true,
+ options: {
+ expiresAt: '2026-06-02T00:00:00.000Z',
+ maxViews: 10,
+ passwordProtected: false
+ }
+ });
+ });
+
+ it('builds password-protected options', () => {
+ const settings = {
+ ...baseSettings,
+ passwordEnabled: true,
+ password: 'batch-secret'
+ };
+
+ const result = buildZkUploadPrepareOptionsFromBatchSettings(settings);
+
+ expect(result).toEqual({
+ ok: true,
+ options: {
+ expiresAt: null,
+ maxViews: null,
+ passwordProtected: true,
+ password: 'batch-secret'
+ }
+ });
+ });
+
+ it('returns validation errors', () => {
+ const settings = {
+ ...baseSettings,
+ passwordEnabled: true,
+ password: ''
+ };
+
+ expect(buildZkUploadPrepareOptionsFromBatchSettings(settings)).toEqual({
+ ok: false,
+ error: 'Enter a password or turn off password protection.'
+ });
+ });
+});
diff --git a/frontend/src/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings.ts b/frontend/src/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings.ts
new file mode 100644
index 0000000..8bf7320
--- /dev/null
+++ b/frontend/src/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings.ts
@@ -0,0 +1,39 @@
+import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
+import type { ZkUploadPrepareOptionsResult } from '@/types/upload/ZkUploadPrepareOptionsResult';
+import { resolveUploadBatchExpiresAt } from '@/utils/upload/resolveUploadBatchExpiresAt';
+import { resolveUploadBatchMaxViews } from '@/utils/upload/resolveUploadBatchMaxViews';
+import { validateUploadBatchSettings } from '@/utils/upload/validateUploadBatchSettings';
+
+export const buildZkUploadPrepareOptionsFromBatchSettings = (
+ settings: UploadBatchSettingsForm
+): ZkUploadPrepareOptionsResult => {
+ const validationError = validateUploadBatchSettings(settings);
+
+ if (validationError) {
+ return { ok: false, error: validationError };
+ }
+
+ const uploadOptions = {
+ expiresAt: resolveUploadBatchExpiresAt(settings.expiryPreset),
+ maxViews: resolveUploadBatchMaxViews(settings.maxViewsPreset)
+ };
+
+ if (settings.passwordEnabled) {
+ return {
+ ok: true,
+ options: {
+ ...uploadOptions,
+ passwordProtected: true,
+ password: settings.password
+ }
+ };
+ }
+
+ return {
+ ok: true,
+ options: {
+ ...uploadOptions,
+ passwordProtected: false
+ }
+ };
+};
diff --git a/frontend/src/utils/upload/formatUploadBatchSettingsSummary.spec.ts b/frontend/src/utils/upload/formatUploadBatchSettingsSummary.spec.ts
new file mode 100644
index 0000000..b42c7d1
--- /dev/null
+++ b/frontend/src/utils/upload/formatUploadBatchSettingsSummary.spec.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest';
+import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
+import { formatUploadBatchSettingsSummary } from '@/utils/upload/formatUploadBatchSettingsSummary';
+
+const baseSettings: UploadBatchSettingsForm = {
+ mode: 'zk',
+ expiryPreset: 'never',
+ maxViewsPreset: 'unlimited',
+ passwordEnabled: false,
+ password: ''
+};
+
+describe('formatUploadBatchSettingsSummary', () => {
+ it('formats default zk settings', () => {
+ expect(formatUploadBatchSettingsSummary(baseSettings)).toBe(
+ 'Zero knowledge · Never expires · Unlimited views'
+ );
+ });
+
+ it('includes password when enabled', () => {
+ const settings = {
+ ...baseSettings,
+ passwordEnabled: true,
+ password: 'secret'
+ };
+
+ expect(formatUploadBatchSettingsSummary(settings)).toBe(
+ 'Zero knowledge · Never expires · Unlimited views · Password protected'
+ );
+ });
+
+ it('formats hotlink mode', () => {
+ const settings = {
+ ...baseSettings,
+ mode: 'hotlink' as const,
+ expiryPreset: '24h' as const,
+ maxViewsPreset: '10' as const
+ };
+
+ expect(formatUploadBatchSettingsSummary(settings)).toBe('Hotlink · 24 hours · 10 views');
+ });
+});
diff --git a/frontend/src/utils/upload/formatUploadBatchSettingsSummary.ts b/frontend/src/utils/upload/formatUploadBatchSettingsSummary.ts
new file mode 100644
index 0000000..98cf226
--- /dev/null
+++ b/frontend/src/utils/upload/formatUploadBatchSettingsSummary.ts
@@ -0,0 +1,29 @@
+import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
+
+const EXPIRY_LABELS: Record = {
+ never: 'Never expires',
+ '1h': '1 hour',
+ '24h': '24 hours',
+ '7d': '7 days',
+ '30d': '30 days'
+};
+
+const MAX_VIEWS_LABELS: Record = {
+ unlimited: 'Unlimited views',
+ '1': 'Burn after read',
+ '10': '10 views',
+ '100': '100 views',
+ '1000': '1,000 views',
+ '10000': '10,000 views'
+};
+
+export const formatUploadBatchSettingsSummary = (settings: UploadBatchSettingsForm): string => {
+ const modeLabel = settings.mode === 'zk' ? 'Zero knowledge' : 'Hotlink';
+ const parts = [modeLabel, EXPIRY_LABELS[settings.expiryPreset], MAX_VIEWS_LABELS[settings.maxViewsPreset]];
+
+ if (settings.mode === 'zk' && settings.passwordEnabled) {
+ parts.push('Password protected');
+ }
+
+ return parts.join(' · ');
+};
diff --git a/frontend/src/utils/upload/resolveUploadBatchExpiresAt.spec.ts b/frontend/src/utils/upload/resolveUploadBatchExpiresAt.spec.ts
new file mode 100644
index 0000000..5d6027d
--- /dev/null
+++ b/frontend/src/utils/upload/resolveUploadBatchExpiresAt.spec.ts
@@ -0,0 +1,24 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { resolveUploadBatchExpiresAt } from '@/utils/upload/resolveUploadBatchExpiresAt';
+
+describe('resolveUploadBatchExpiresAt', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('returns null for never', () => {
+ expect(resolveUploadBatchExpiresAt('never')).toBeNull();
+ });
+
+ it('adds preset offsets from the reference time', () => {
+ expect(resolveUploadBatchExpiresAt('1h')).toBe('2026-01-01T13:00:00.000Z');
+ expect(resolveUploadBatchExpiresAt('24h')).toBe('2026-01-02T12:00:00.000Z');
+ expect(resolveUploadBatchExpiresAt('7d')).toBe('2026-01-08T12:00:00.000Z');
+ expect(resolveUploadBatchExpiresAt('30d')).toBe('2026-01-31T12:00:00.000Z');
+ });
+});
diff --git a/frontend/src/utils/upload/resolveUploadBatchExpiresAt.ts b/frontend/src/utils/upload/resolveUploadBatchExpiresAt.ts
new file mode 100644
index 0000000..0d3d2a0
--- /dev/null
+++ b/frontend/src/utils/upload/resolveUploadBatchExpiresAt.ts
@@ -0,0 +1,20 @@
+import dayjs from 'dayjs';
+import type { UploadExpiryPreset } from '@/types/upload/UploadExpiryPreset';
+import type { UploadExpiryOffset } from '@/types/upload/UploadExpiryOffset';
+
+const EXPIRY_OFFSET: Record, UploadExpiryOffset> = {
+ '1h': { amount: 1, unit: 'hour' },
+ '24h': { amount: 24, unit: 'hour' },
+ '7d': { amount: 7, unit: 'day' },
+ '30d': { amount: 30, unit: 'day' }
+};
+
+export const resolveUploadBatchExpiresAt = (preset: UploadExpiryPreset): string | null => {
+ if (preset === 'never') {
+ return null;
+ }
+
+ const { amount, unit } = EXPIRY_OFFSET[preset];
+
+ return dayjs().add(amount, unit).toISOString();
+};
diff --git a/frontend/src/utils/upload/resolveUploadBatchMaxViews.spec.ts b/frontend/src/utils/upload/resolveUploadBatchMaxViews.spec.ts
new file mode 100644
index 0000000..da69066
--- /dev/null
+++ b/frontend/src/utils/upload/resolveUploadBatchMaxViews.spec.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from 'vitest';
+import { resolveUploadBatchMaxViews } from '@/utils/upload/resolveUploadBatchMaxViews';
+
+describe('resolveUploadBatchMaxViews', () => {
+ it('maps presets to API values', () => {
+ expect(resolveUploadBatchMaxViews('unlimited')).toBeNull();
+ expect(resolveUploadBatchMaxViews('1')).toBe(1);
+ expect(resolveUploadBatchMaxViews('10')).toBe(10);
+ expect(resolveUploadBatchMaxViews('100')).toBe(100);
+ expect(resolveUploadBatchMaxViews('1000')).toBe(1000);
+ expect(resolveUploadBatchMaxViews('10000')).toBe(10000);
+ });
+});
diff --git a/frontend/src/utils/upload/resolveUploadBatchMaxViews.ts b/frontend/src/utils/upload/resolveUploadBatchMaxViews.ts
new file mode 100644
index 0000000..4925395
--- /dev/null
+++ b/frontend/src/utils/upload/resolveUploadBatchMaxViews.ts
@@ -0,0 +1,9 @@
+import type { UploadMaxViewsPreset } from '@/types/upload/UploadMaxViewsPreset';
+
+export const resolveUploadBatchMaxViews = (preset: UploadMaxViewsPreset): number | null => {
+ if (preset === 'unlimited') {
+ return null;
+ }
+
+ return parseInt(preset, 10);
+};
diff --git a/frontend/src/utils/upload/validateUploadBatchSettings.spec.ts b/frontend/src/utils/upload/validateUploadBatchSettings.spec.ts
new file mode 100644
index 0000000..e976590
--- /dev/null
+++ b/frontend/src/utils/upload/validateUploadBatchSettings.spec.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
+import { validateUploadBatchSettings } from '@/utils/upload/validateUploadBatchSettings';
+
+const baseSettings: UploadBatchSettingsForm = {
+ mode: 'zk',
+ expiryPreset: 'never',
+ maxViewsPreset: 'unlimited',
+ passwordEnabled: false,
+ password: ''
+};
+
+describe('validateUploadBatchSettings', () => {
+ it('allows hotlink mode without password', () => {
+ const settings = {
+ ...baseSettings,
+ mode: 'hotlink' as const,
+ passwordEnabled: true,
+ password: ''
+ };
+
+ expect(validateUploadBatchSettings(settings)).toBeNull();
+ });
+
+ it('requires password when zk protection is enabled', () => {
+ const settings = {
+ ...baseSettings,
+ passwordEnabled: true,
+ password: ''
+ };
+
+ expect(validateUploadBatchSettings(settings)).toBe('Enter a password or turn off password protection.');
+ });
+
+ it('allows zk upload when password is set', () => {
+ const settings = {
+ ...baseSettings,
+ passwordEnabled: true,
+ password: 'secret'
+ };
+
+ expect(validateUploadBatchSettings(settings)).toBeNull();
+ });
+});
diff --git a/frontend/src/utils/upload/validateUploadBatchSettings.ts b/frontend/src/utils/upload/validateUploadBatchSettings.ts
new file mode 100644
index 0000000..3663b00
--- /dev/null
+++ b/frontend/src/utils/upload/validateUploadBatchSettings.ts
@@ -0,0 +1,13 @@
+import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
+
+export const validateUploadBatchSettings = (settings: UploadBatchSettingsForm): string | null => {
+ if (settings.mode !== 'zk' || !settings.passwordEnabled) {
+ return null;
+ }
+
+ if (settings.password.length === 0) {
+ return 'Enter a password or turn off password protection.';
+ }
+
+ return null;
+};
diff --git a/frontend/src/views/UploadView.vue b/frontend/src/views/UploadView.vue
index e3cb6be..3b54f0f 100644
--- a/frontend/src/views/UploadView.vue
+++ b/frontend/src/views/UploadView.vue
@@ -9,16 +9,36 @@
-
+
+
+
+
+
+
+
+
{{ selectionError }}
+
+
+
+ {{ confirmedBatchSummary }}
+
+
+
@@ -28,8 +48,10 @@ import type UploadDropzone from '@/components/upload/UploadDropzone.vue';
import { config } from '@/config';
import { useExiftoolStore } from '@/stores/exiftool';
import { useMetadataStore } from '@/stores/metadata';
+import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import { analyzePickedUploadFiles } from '@/utils/upload/analyzePickedUploadFiles';
import { buildPickedUploadWarning } from '@/utils/upload/buildPickedUploadWarning';
+import { formatUploadBatchSettingsSummary } from '@/utils/upload/formatUploadBatchSettingsSummary';
import prettyBytes from 'pretty-bytes';
const { maxQueueSize, maxImageBytes } = config.validation.upload;
@@ -45,6 +67,15 @@ const selectedFile = ref(null);
const selectionError = ref(null);
const loadingMetadata = ref(false);
const dropzoneRef = ref | null>(null);
+const batchSettings = ref({
+ mode: 'zk',
+ expiryPreset: 'never',
+ maxViewsPreset: 'unlimited',
+ passwordEnabled: false,
+ password: ''
+});
+const configureModalOpen = ref(false);
+const confirmedBatchSummary = ref(null);
onBeforeMount(async () => {
try {
@@ -66,6 +97,14 @@ const queueRows = computed(() =>
const selectedPreview = computed(() => getPreview(selectedFile.value));
+const canOpenConfigure = computed(() => {
+ if (queuedFiles.value.length === 0 || loadingMetadata.value) {
+ return false;
+ }
+
+ return !queuedFiles.value.some(file => getPreview(file).status === 'error');
+});
+
const onFilesPicked = async (picked: File[]): Promise => {
if (loadingMetadata.value) {
return;
@@ -120,5 +159,12 @@ const resetUploadSelection = (): void => {
queuedFiles.value = [];
selectedFile.value = null;
dropzoneRef.value?.clearInput();
+ configureModalOpen.value = false;
+ confirmedBatchSummary.value = null;
+};
+
+const onBatchUpload = (): void => {
+ confirmedBatchSummary.value = formatUploadBatchSettingsSummary(batchSettings.value);
+ configureModalOpen.value = false;
};