From d82d45144834256c07c917db68065e007072795a Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Tue, 20 Oct 2026 22:20:16 +0200
Subject: [PATCH 01/15] Extract upload dropzone and queue UI with shared pick
validation utils.
---
frontend/package-lock.json | 274 ++++++++++++++++++
frontend/package.json | 1 +
frontend/src/components.d.ts | 20 ++
.../src/components/upload/UploadDropzone.vue | 57 ++++
.../src/components/upload/UploadQueueList.vue | 53 ++++
frontend/src/types/upload/UploadQueueRow.ts | 7 +
.../upload/analyzePickedUploadFiles.spec.ts | 38 +++
.../utils/upload/analyzePickedUploadFiles.ts | 21 ++
.../upload/buildPickedUploadWarning.spec.ts | 24 ++
.../utils/upload/buildPickedUploadWarning.ts | 22 ++
frontend/src/views/UploadView.vue | 115 ++------
frontend/vite.config.ts | 9 +-
12 files changed, 547 insertions(+), 94 deletions(-)
create mode 100644 frontend/src/components.d.ts
create mode 100644 frontend/src/components/upload/UploadDropzone.vue
create mode 100644 frontend/src/components/upload/UploadQueueList.vue
create mode 100644 frontend/src/types/upload/UploadQueueRow.ts
create mode 100644 frontend/src/utils/upload/analyzePickedUploadFiles.spec.ts
create mode 100644 frontend/src/utils/upload/analyzePickedUploadFiles.ts
create mode 100644 frontend/src/utils/upload/buildPickedUploadWarning.spec.ts
create mode 100644 frontend/src/utils/upload/buildPickedUploadWarning.ts
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index ca7d2ae..436616e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -24,6 +24,7 @@
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
+ "unplugin-vue-components": "^32.1.0",
"vite": "^8.0.4",
"vitest": "^3.2.7",
"vue-tsc": "^3.2.6"
@@ -2037,6 +2038,19 @@
}
}
},
+ "node_modules/acorn": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
+ "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -2144,6 +2158,22 @@
"node": ">= 16"
}
},
+ "node_modules/chokidar": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
+ "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^5.0.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -2156,6 +2186,13 @@
"node": ">= 0.8"
}
},
+ "node_modules/confbox": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz",
+ "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2356,6 +2393,13 @@
"node": ">=12.0.0"
}
},
+ "node_modules/exsolve": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz",
+ "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2817,6 +2861,24 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/local-pkg": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
+ "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mlly": "^1.7.4",
+ "pkg-types": "^2.3.0",
+ "quansync": "^0.2.11"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -2863,6 +2925,38 @@
"node": ">= 0.6"
}
},
+ "node_modules/mlly": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
+ "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "pathe": "^2.0.3",
+ "pkg-types": "^1.3.1",
+ "ufo": "^1.6.3"
+ }
+ },
+ "node_modules/mlly/node_modules/confbox": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
+ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mlly/node_modules/pkg-types": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
+ "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.1.8",
+ "mlly": "^1.7.4",
+ "pathe": "^2.0.1"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2900,6 +2994,20 @@
"integrity": "sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg==",
"license": "MIT"
},
+ "node_modules/obug": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+ "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -2974,6 +3082,18 @@
}
}
},
+ "node_modules/pkg-types": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.4.0.tgz",
+ "integrity": "sha512-07Fys7iVx0X6TcviMOgogM1Lk33ZyqOezMnU79S+kffWqwolG/5W9V8EgQSeYCIfFTYVgssRbnH630Oa2+soBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.3.1",
+ "exsolve": "^1.1.3",
+ "pathe": "^2.0.3"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
@@ -3023,6 +3143,37 @@
"node": ">=10"
}
},
+ "node_modules/quansync": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
+ "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/antfu"
+ },
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/sxzz"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/readdirp": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
+ "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/rolldown": {
"version": "1.2.11",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz",
@@ -3238,6 +3389,13 @@
"node": ">=14.17"
}
},
+ "node_modules/ufo": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
+ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -3245,6 +3403,115 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/unplugin": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.4.0.tgz",
+ "integrity": "sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "picomatch": "^4.0.7",
+ "webpack-virtual-modules": "^0.6.2"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@farmfe/core": "*",
+ "@rsbuild/core": "*",
+ "@rspack/core": "*",
+ "bun-types-no-globals": "*",
+ "esbuild": "*",
+ "rolldown": "*",
+ "rollup": "*",
+ "unloader": "*",
+ "vite": "*",
+ "webpack": "*"
+ },
+ "peerDependenciesMeta": {
+ "@farmfe/core": {
+ "optional": true
+ },
+ "@rsbuild/core": {
+ "optional": true
+ },
+ "@rspack/core": {
+ "optional": true
+ },
+ "bun-types-no-globals": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "rolldown": {
+ "optional": true
+ },
+ "rollup": {
+ "optional": true
+ },
+ "unloader": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ },
+ "webpack": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/unplugin-utils": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz",
+ "integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sxzz"
+ }
+ },
+ "node_modules/unplugin-vue-components": {
+ "version": "32.1.0",
+ "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz",
+ "integrity": "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^5.0.0",
+ "local-pkg": "^1.2.0",
+ "magic-string": "^0.30.21",
+ "mlly": "^1.8.2",
+ "obug": "^2.1.1",
+ "picomatch": "^4.0.4",
+ "tinyglobby": "^0.2.16",
+ "unplugin": "^3.0.0",
+ "unplugin-utils": "^0.3.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@nuxt/kit": "^3.2.2 || ^4.0.0",
+ "vue": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@nuxt/kit": {
+ "optional": true
+ }
+ }
+ },
"node_modules/vite": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz",
@@ -3671,6 +3938,13 @@
"typescript": ">=5.0.0"
}
},
+ "node_modules/webpack-virtual-modules": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
+ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 172b509..f1bedbc 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -26,6 +26,7 @@
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
+ "unplugin-vue-components": "^32.1.0",
"vite": "^8.0.4",
"vitest": "^3.2.7",
"vue-tsc": "^3.2.6"
diff --git a/frontend/src/components.d.ts b/frontend/src/components.d.ts
new file mode 100644
index 0000000..f41ef2e
--- /dev/null
+++ b/frontend/src/components.d.ts
@@ -0,0 +1,20 @@
+/* eslint-disable */
+// @ts-nocheck
+// biome-ignore lint: disable
+// oxlint-disable
+// ------
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+
+export {}
+
+/* prettier-ignore */
+declare module 'vue' {
+ export interface GlobalComponents {
+ MetadataInspector: typeof import('./components/upload/MetadataInspector.vue')['default']
+ RouterLink: typeof import('vue-router')['RouterLink']
+ RouterView: typeof import('vue-router')['RouterView']
+ UploadDropzone: typeof import('./components/upload/UploadDropzone.vue')['default']
+ UploadQueueList: typeof import('./components/upload/UploadQueueList.vue')['default']
+ }
+}
diff --git a/frontend/src/components/upload/UploadDropzone.vue b/frontend/src/components/upload/UploadDropzone.vue
new file mode 100644
index 0000000..ed66727
--- /dev/null
+++ b/frontend/src/components/upload/UploadDropzone.vue
@@ -0,0 +1,57 @@
+
+
+
+ Choose images
+
+
+
+
{{ selectionError }}
+
+
+
+
diff --git a/frontend/src/components/upload/UploadQueueList.vue b/frontend/src/components/upload/UploadQueueList.vue
new file mode 100644
index 0000000..dfaa588
--- /dev/null
+++ b/frontend/src/components/upload/UploadQueueList.vue
@@ -0,0 +1,53 @@
+
+
+
+
+ {{ row.file.name }}
+
+ {{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
+
+
+ {{ row.preview.message }}
+
+
+
+
+
+
+
diff --git a/frontend/src/types/upload/UploadQueueRow.ts b/frontend/src/types/upload/UploadQueueRow.ts
new file mode 100644
index 0000000..290b7df
--- /dev/null
+++ b/frontend/src/types/upload/UploadQueueRow.ts
@@ -0,0 +1,7 @@
+import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
+
+export type UploadQueueRow = {
+ file: File;
+ key: string;
+ preview: MetadataPreviewEntry;
+};
diff --git a/frontend/src/utils/upload/analyzePickedUploadFiles.spec.ts b/frontend/src/utils/upload/analyzePickedUploadFiles.spec.ts
new file mode 100644
index 0000000..0d06523
--- /dev/null
+++ b/frontend/src/utils/upload/analyzePickedUploadFiles.spec.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+import { analyzePickedUploadFiles } from '@/utils/upload/analyzePickedUploadFiles';
+
+const makeFile = (name: string, type: string, size: number): File =>
+ new File([new Uint8Array(size)], name, { type });
+
+describe('analyzePickedUploadFiles', () => {
+ const maxImageBytes = 100;
+ const maxQueueSize = 2;
+
+ it('accepts images within size and queue limits', () => {
+ const picked = [makeFile('a.png', 'image/png', 50), makeFile('b.jpg', 'image/jpeg', 80)];
+
+ const result = analyzePickedUploadFiles(picked, maxImageBytes, maxQueueSize);
+
+ expect(result.accepted).toHaveLength(2);
+ expect(result.nonImageCount).toBe(0);
+ expect(result.oversizedImagesCount).toBe(0);
+ expect(result.queueOmittedImagesCount).toBe(0);
+ });
+
+ it('skips non-images and oversized files and caps the queue', () => {
+ const picked = [
+ makeFile('doc.pdf', 'application/pdf', 10),
+ makeFile('big.png', 'image/png', 200),
+ makeFile('1.png', 'image/png', 10),
+ makeFile('2.png', 'image/png', 10),
+ makeFile('3.png', 'image/png', 10)
+ ];
+
+ const result = analyzePickedUploadFiles(picked, maxImageBytes, maxQueueSize);
+
+ expect(result.accepted.map(f => f.name)).toEqual(['1.png', '2.png']);
+ expect(result.nonImageCount).toBe(1);
+ expect(result.oversizedImagesCount).toBe(1);
+ expect(result.queueOmittedImagesCount).toBe(1);
+ });
+});
diff --git a/frontend/src/utils/upload/analyzePickedUploadFiles.ts b/frontend/src/utils/upload/analyzePickedUploadFiles.ts
new file mode 100644
index 0000000..e4c3c65
--- /dev/null
+++ b/frontend/src/utils/upload/analyzePickedUploadFiles.ts
@@ -0,0 +1,21 @@
+import type { PickedUploadAnalysis } from '@/types/upload/PickedUploadAnalysis';
+
+export const analyzePickedUploadFiles = (
+ picked: File[],
+ maxImageBytes: number,
+ maxQueueSize: number
+): PickedUploadAnalysis => {
+ const images = picked.filter(file => file.type.startsWith('image/'));
+ const nonImageCount = picked.length - images.length;
+ const imagesWithinSizeLimit = images.filter(file => file.size <= maxImageBytes);
+ const oversizedImagesCount = images.length - imagesWithinSizeLimit.length;
+ const accepted = imagesWithinSizeLimit.slice(0, maxQueueSize);
+ const queueOmittedImagesCount = imagesWithinSizeLimit.length - accepted.length;
+
+ return {
+ accepted,
+ nonImageCount,
+ oversizedImagesCount,
+ queueOmittedImagesCount
+ };
+};
diff --git a/frontend/src/utils/upload/buildPickedUploadWarning.spec.ts b/frontend/src/utils/upload/buildPickedUploadWarning.spec.ts
new file mode 100644
index 0000000..571608a
--- /dev/null
+++ b/frontend/src/utils/upload/buildPickedUploadWarning.spec.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest';
+import { buildPickedUploadWarning } from '@/utils/upload/buildPickedUploadWarning';
+
+describe('buildPickedUploadWarning', () => {
+ it('returns null when nothing was skipped', () => {
+ expect(
+ buildPickedUploadWarning(
+ { nonImageCount: 0, oversizedImagesCount: 0, queueOmittedImagesCount: 0 },
+ 5
+ )
+ ).toBeNull();
+ });
+
+ it('joins skip reasons into one message', () => {
+ const message = buildPickedUploadWarning(
+ { nonImageCount: 1, oversizedImagesCount: 2, queueOmittedImagesCount: 3 },
+ 4
+ );
+
+ expect(message).toContain('1 non-image');
+ expect(message).toContain('2 image(s) exceeded');
+ expect(message).toContain('first 4 valid images');
+ });
+});
diff --git a/frontend/src/utils/upload/buildPickedUploadWarning.ts b/frontend/src/utils/upload/buildPickedUploadWarning.ts
new file mode 100644
index 0000000..96b7f45
--- /dev/null
+++ b/frontend/src/utils/upload/buildPickedUploadWarning.ts
@@ -0,0 +1,22 @@
+import type { PickedUploadAnalysis } from '@/types/upload/PickedUploadAnalysis';
+
+export const buildPickedUploadWarning = (
+ summary: Pick,
+ maxQueueSize: number
+): string | null => {
+ const parts: string[] = [];
+
+ if (summary.nonImageCount > 0) {
+ parts.push(`${summary.nonImageCount} non-image file(s) were skipped.`);
+ }
+
+ if (summary.oversizedImagesCount > 0) {
+ parts.push(`${summary.oversizedImagesCount} image(s) exceeded the size limit and were skipped.`);
+ }
+
+ if (summary.queueOmittedImagesCount > 0) {
+ parts.push(`Only the first ${maxQueueSize} valid images were added.`);
+ }
+
+ return parts.length > 0 ? parts.join(' ') : null;
+};
diff --git a/frontend/src/views/UploadView.vue b/frontend/src/views/UploadView.vue
index 849998b..e3cb6be 100644
--- a/frontend/src/views/UploadView.vue
+++ b/frontend/src/views/UploadView.vue
@@ -9,44 +9,14 @@
-
-
- Choose images
-
-
+
-
{{ selectionError }}
-
-
-
-
-
- {{ row.file.name }}
-
- {{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
-
-
- {{ row.preview.message }}
-
-
-
-
+
@@ -54,12 +24,12 @@
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 332e329..a5fd8b4 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
+import Components from 'unplugin-vue-components/vite';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -12,5 +13,11 @@ export default defineConfig({
'@': path.resolve(__dirname, 'src')
}
},
- plugins: [vue(), tailwindcss()]
+ plugins: [
+ vue(),
+ Components({
+ dts: 'src/components.d.ts'
+ }),
+ tailwindcss()
+ ]
});
--
2.54.0
From bd456451a4713c5c4f96f9ccdfa482c40b3b8d9d Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Wed, 21 Oct 2026 15:03:53 +0200
Subject: [PATCH 02/15] Remove NEVER_MODE_INACTIVITY_DAYS env variable as idea
of cleanup of inactive images changed
---
.env.example | 2 --
backend/src/config/validate.spec.ts | 1 -
backend/src/config/validate.ts | 6 ------
3 files changed, 9 deletions(-)
diff --git a/.env.example b/.env.example
index 8d0072e..29a27b2 100644
--- a/.env.example
+++ b/.env.example
@@ -23,8 +23,6 @@ ALTCHA_HMAC_KEY=change-me-use-long-random-secret
ALTCHA_MAX_NUMBER=50000
ALTCHA_CHALLENGE_TTL_MS=600000
-NEVER_MODE_INACTIVITY_DAYS=365
-
THROTTLE_TTL_MS=60000
THROTTLE_LIMIT=1000
THROTTLE_UPLOAD_TTL_MS=60000
diff --git a/backend/src/config/validate.spec.ts b/backend/src/config/validate.spec.ts
index 17c168c..c266bb4 100644
--- a/backend/src/config/validate.spec.ts
+++ b/backend/src/config/validate.spec.ts
@@ -17,7 +17,6 @@ const validConfig = (overrides: Record = {}): Record
Date: Wed, 21 Oct 2026 17:46:33 +0200
Subject: [PATCH 03/15] Pin dev Docker images to Node 22.23.4 for October 2026
security fixes.
---
backend/Dockerfile.dev | 2 +-
frontend/Dockerfile.dev | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev
index d56d43c..1cdc39c 100644
--- a/backend/Dockerfile.dev
+++ b/backend/Dockerfile.dev
@@ -1,4 +1,4 @@
-FROM node:22-alpine
+FROM node:22.23.4-alpine
WORKDIR /backend
diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev
index 92b8c9e..dc6577d 100644
--- a/frontend/Dockerfile.dev
+++ b/frontend/Dockerfile.dev
@@ -1,4 +1,4 @@
-FROM node:22-alpine
+FROM node:22.23.4-alpine
WORKDIR /frontend
--
2.54.0
From 805665745bacf6e37142e8d822f11b16a23e362e Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Thu, 22 Oct 2026 03:14:02 +0200
Subject: [PATCH 04/15] 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 }}
+
+
+
+
+
+ Cancel
+
+
+ Upload
+
+
+
+
+
+
+
+
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 @@
+
+
+
+ Share mode
+
+
+
+
+
+
+
+
+ Expiry
+
+
+ {{ option.label }}
+
+
+
+
+
+ Max views
+
+
+ {{ option.label }}
+
+
+
+
+
+
+
+
+
+
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 @@
-
+
+
+
+
+
+ Configure & upload
+
+
+
+
{{ 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;
};
--
2.54.0
From fe643d9831226226423d4a9179abc6a98ec86757 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Fri, 23 Oct 2026 18:34:09 +0200
Subject: [PATCH 05/15] Add client ALTCHA proof-of-work and Vite env flags for
uploads.
Expose enabled/max-number settings and wire altcha-lib for per-upload challenges.
---
.env.example | 4 +-
frontend/package-lock.json | 70 +++++++++++++++++++++++++++++++++++
frontend/package.json | 1 +
frontend/src/config/index.ts | 6 +++
frontend/src/stores/altcha.ts | 70 +++++++++++++++++++++++++++++++++++
frontend/src/vite-env.d.ts | 2 +
6 files changed, 152 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/stores/altcha.ts
diff --git a/.env.example b/.env.example
index 29a27b2..b342048 100644
--- a/.env.example
+++ b/.env.example
@@ -20,7 +20,7 @@ CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
ALTCHA_ENABLED=true
ALTCHA_HMAC_KEY=change-me-use-long-random-secret
-ALTCHA_MAX_NUMBER=50000
+ALTCHA_MAX_NUMBER=250000
ALTCHA_CHALLENGE_TTL_MS=600000
THROTTLE_TTL_MS=60000
@@ -35,6 +35,8 @@ VALIDATION_UPLOAD_JSON_BODY_PADDING_BYTES=2097152 # 2MiB
VITE_API_BASE_URL=http://localhost:3000/api
VITE_CLEARNET_DOMAIN=localhost:5173
VITE_ONION_HOSTNAME=
+VITE_ALTCHA_ENABLED=true
+VITE_ALTCHA_MAX_NUMBER=250000
VITE_UPLOAD_MAX_QUEUE_SIZE=10
VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX=1000000
VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES=10485760 # 10MiB
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 496fac1..84a87e3 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,6 +11,7 @@
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"@uswriting/exiftool": "1.0.9",
+ "altcha-lib": "^2.7.0",
"axios": "^1.20.0",
"dayjs": "^1.11.23",
"hash-wasm": "^4.12.0",
@@ -2071,6 +2072,75 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/altcha-lib": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/altcha-lib/-/altcha-lib-2.7.0.tgz",
+ "integrity": "sha512-gnysjfIpeT6k3RLaWRd7xVvfs8U0a5DHIS95Xgwn8znKq5uIj6c/oTH8DtcdyJS/ZV3I5kYpyurvmJFb0eFWvA==",
+ "license": "MIT",
+ "bin": {
+ "altcha-lib": "bin/cli.mjs"
+ },
+ "peerDependencies": {
+ "@fastify/cookie": "^11.0.0",
+ "@fastify/cors": "^11.0.0",
+ "@fastify/formbody": "^8.0.0 || ^9.0.0",
+ "@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0",
+ "@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0",
+ "@nestjs/platform-express": "^10.0.0 || ^11.0.0 || ^12.0.0",
+ "@nestjs/platform-fastify": "^10.0.0 || ^11.0.0 || ^12.0.0",
+ "@sveltejs/kit": "^2.0.0 || ^3.0.0",
+ "cookie-parser": "^1.0.0",
+ "cors": "^2.0.0",
+ "express": "^4.0.0 || ^5.0.0",
+ "fastify": "^5.0.0",
+ "h3": "^2.0.0",
+ "hono": "^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@fastify/cookie": {
+ "optional": true
+ },
+ "@fastify/cors": {
+ "optional": true
+ },
+ "@fastify/formbody": {
+ "optional": true
+ },
+ "@nestjs/common": {
+ "optional": true
+ },
+ "@nestjs/core": {
+ "optional": true
+ },
+ "@nestjs/platform-express": {
+ "optional": true
+ },
+ "@nestjs/platform-fastify": {
+ "optional": true
+ },
+ "@sveltejs/kit": {
+ "optional": true
+ },
+ "cookie-parser": {
+ "optional": true
+ },
+ "cors": {
+ "optional": true
+ },
+ "express": {
+ "optional": true
+ },
+ "fastify": {
+ "optional": true
+ },
+ "h3": {
+ "optional": true
+ },
+ "hono": {
+ "optional": true
+ }
+ }
+ },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index e5f88cb..906fa14 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -13,6 +13,7 @@
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"@uswriting/exiftool": "1.0.9",
+ "altcha-lib": "^2.7.0",
"axios": "^1.20.0",
"dayjs": "^1.11.23",
"hash-wasm": "^4.12.0",
diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts
index 7a5ae00..4d3a652 100644
--- a/frontend/src/config/index.ts
+++ b/frontend/src/config/index.ts
@@ -10,10 +10,16 @@ const env = (key: string): string => {
const envInt = (key: string): number => parseInt(env(key), 10);
+const isEnabled = (key: string): boolean => env(key) === 'true';
+
export const config = {
baseUrl: env('VITE_API_BASE_URL'),
clearnetDomain: env('VITE_CLEARNET_DOMAIN'),
onionHostname: env('VITE_ONION_HOSTNAME'),
+ altcha: {
+ enabled: isEnabled('VITE_ALTCHA_ENABLED'),
+ maxNumber: envInt('VITE_ALTCHA_MAX_NUMBER')
+ },
validation: {
upload: {
maxQueueSize: envInt('VITE_UPLOAD_MAX_QUEUE_SIZE'),
diff --git a/frontend/src/stores/altcha.ts b/frontend/src/stores/altcha.ts
new file mode 100644
index 0000000..141df4f
--- /dev/null
+++ b/frontend/src/stores/altcha.ts
@@ -0,0 +1,70 @@
+import { solveChallengeWorkers } from 'altcha-lib/v1';
+import type { Challenge, Payload } from 'altcha-lib/v1/types';
+import { defineStore } from 'pinia';
+import { config } from '@/config';
+import { api } from '@/plugins/axios';
+import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
+import AltchaSolveWorker from 'altcha-lib/v1/worker?worker';
+
+export const useAltchaStore = defineStore('altcha', () => {
+ const runProofOfWork = async (): Promise => {
+ if (!config.altcha.enabled) {
+ return undefined;
+ }
+
+ const challenge = await fetchChallenge();
+
+ return encodeSolvedChallenge(challenge);
+ };
+
+ const fetchChallenge = async (): Promise => {
+ const { data } = await api.get('/altcha/challenge');
+
+ return data;
+ };
+
+ const encodeSolvedChallenge = async ({
+ algorithm,
+ challenge: challengeValue,
+ salt,
+ signature,
+ maxnumber
+ }: Challenge): Promise => {
+ const { maxNumber: fallbackMaxNumber } = config.altcha;
+
+ const maxNumber = maxnumber ?? fallbackMaxNumber;
+ const cuncurrency = Math.min(8, Math.max(1, navigator.hardwareConcurrency ?? 4));
+
+ const solution = await solveChallengeWorkers(
+ () => new AltchaSolveWorker(),
+ cuncurrency,
+ challengeValue,
+ salt,
+ algorithm,
+ maxNumber
+ );
+
+ if (!solution) {
+ throw new Error('ALTCHA proof of work could not be solved');
+ }
+
+ const payload: Payload = {
+ algorithm,
+ challenge: challengeValue,
+ number: solution.number,
+ salt,
+ signature
+ };
+
+ const json = JSON.stringify(payload);
+
+ const textEncoder = new TextEncoder();
+ const bytes = textEncoder.encode(json);
+
+ return bytesToBase64(bytes);
+ };
+
+ return {
+ runProofOfWork
+ };
+});
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
index aa316f0..806b5a2 100644
--- a/frontend/src/vite-env.d.ts
+++ b/frontend/src/vite-env.d.ts
@@ -4,6 +4,8 @@ interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_CLEARNET_DOMAIN: string;
readonly VITE_ONION_HOSTNAME: string;
+ readonly VITE_ALTCHA_ENABLED: string;
+ readonly VITE_ALTCHA_MAX_NUMBER: string;
readonly VITE_UPLOAD_MAX_QUEUE_SIZE: string;
readonly VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX: string;
readonly VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES: string;
--
2.54.0
From 271c2b7b2d1b0c4b51657c8024a84002d90afe78 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Fri, 23 Oct 2026 18:34:13 +0200
Subject: [PATCH 06/15] Execute batch uploads with metadata gates, encryption,
and queue state.
Separate upload entries per file, ALTCHA on POST, and stripped-or-original fallback on metadata errors.
---
frontend/src/stores/metadata.ts | 108 ++++----
frontend/src/stores/upload.ts | 233 +++++++++++++++++-
.../src/types/metadata/MetadataPreview.ts | 6 +-
.../upload/PreparedHotlinkUploadRequest.ts | 6 +
.../types/upload/PreparedZkUploadRequest.ts | 6 +
frontend/src/types/upload/UploadQueueEntry.ts | 8 +
frontend/src/types/upload/UploadQueueRow.ts | 4 +-
.../upload/ZkUploadPrepareOptionsResult.ts | 5 -
frontend/src/utils/file/makeKeyFromFile.ts | 2 +
.../buildUploadOptionsFromBatchSettings.ts | 9 +
...oadPrepareOptionsFromBatchSettings.spec.ts | 39 +--
...ZkUploadPrepareOptionsFromBatchSettings.ts | 35 +--
.../upload/prepareHotlinkUploadRequest.ts | 4 +-
.../utils/upload/prepareZkUploadRequest.ts | 4 +-
14 files changed, 333 insertions(+), 136 deletions(-)
create mode 100644 frontend/src/types/upload/PreparedHotlinkUploadRequest.ts
create mode 100644 frontend/src/types/upload/PreparedZkUploadRequest.ts
create mode 100644 frontend/src/types/upload/UploadQueueEntry.ts
delete mode 100644 frontend/src/types/upload/ZkUploadPrepareOptionsResult.ts
create mode 100644 frontend/src/utils/file/makeKeyFromFile.ts
create mode 100644 frontend/src/utils/upload/buildUploadOptionsFromBatchSettings.ts
diff --git a/frontend/src/stores/metadata.ts b/frontend/src/stores/metadata.ts
index a06540b..49b4297 100644
--- a/frontend/src/stores/metadata.ts
+++ b/frontend/src/stores/metadata.ts
@@ -2,23 +2,29 @@ import { defineStore, storeToRefs } from 'pinia';
import { ref } from 'vue';
import { useExiftoolStore } from '@/stores/exiftool';
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
+import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
-const EXIFTOOL_PREPARE_ERROR_MESSAGE = 'Could not prepare ExifTool';
-const EXIFTOOL_INITIALIZING_MESSAGE = 'Initializing ExifTool';
-const METADATA_LOADING_MESSAGE = 'Loading metadata';
+const METADATA_IDLE_MESSAGE = 'Waiting to load metadata...';
+const EXIFTOOL_INITIALIZING_MESSAGE = 'Initializing ExifTool...';
+const METADATA_PROCESSING_MESSAGE = 'Stripping metadata...';
+const METADATA_READY_MESSAGE = 'Metadata stripped';
const METADATA_LOAD_ERROR_MESSAGE = 'Could not load image metadata';
const METADATA_STRIP_ERROR_MESSAGE = 'Could not strip image metadata';
-const METADATA_IDLE_MESSAGE = 'Waiting to load metadata';
-const METADATA_READY_MESSAGE = 'Metadata stripped';
+const EXIFTOOL_PREPARE_ERROR_MESSAGE = 'Could not prepare ExifTool';
const METADATA_IDLE_PREVIEW: MetadataPreviewEntry = {
status: 'idle',
message: METADATA_IDLE_MESSAGE
};
-const METADATA_LOADING_PREVIEW: MetadataPreviewEntry = {
- status: 'loading',
- message: METADATA_LOADING_MESSAGE
+const METADATA_LOAD_ERROR_PREVIEW: MetadataPreviewEntry = {
+ status: 'error',
+ message: METADATA_LOAD_ERROR_MESSAGE
+};
+
+const EXIFTOOL_PREPARE_ERROR_PREVIEW: MetadataPreviewEntry = {
+ status: 'error',
+ message: EXIFTOOL_PREPARE_ERROR_MESSAGE
};
export const useMetadataStore = defineStore('metadata', () => {
@@ -30,7 +36,13 @@ export const useMetadataStore = defineStore('metadata', () => {
const { initStatus: exiftoolInitStatus } = storeToRefs(exiftoolStore);
const { initializeExiftool, readMetadata, stripAllMetadata } = exiftoolStore;
- const makeKeyFromFile = (file: File): string => `${file.name}\0${file.size}\0${file.lastModified}`;
+ const setPreviewEntry = (key: string, entry: MetadataPreviewEntry): void => {
+ previewsByKey.value = { ...previewsByKey.value, [key]: entry };
+ };
+
+ const setStrippedBlob = (key: string, blob: Blob): void => {
+ strippedBlobByKey.value = { ...strippedBlobByKey.value, [key]: blob };
+ };
const getPreview = (file?: File | null): MetadataPreviewEntry => {
if (!file) {
@@ -56,48 +68,27 @@ export const useMetadataStore = defineStore('metadata', () => {
const key = makeKeyFromFile(file);
if (exiftoolInitStatus.value === 'error') {
- const entry: MetadataPreviewEntry = {
- status: 'error',
- message: EXIFTOOL_PREPARE_ERROR_MESSAGE
- };
+ setPreviewEntry(key, EXIFTOOL_PREPARE_ERROR_PREVIEW);
- previewsByKey.value = { ...previewsByKey.value, [key]: entry };
-
- return entry;
+ return EXIFTOOL_PREPARE_ERROR_PREVIEW;
}
- if (exiftoolInitStatus.value === 'ready') {
- previewsByKey.value = {
- ...previewsByKey.value,
- [key]: METADATA_LOADING_PREVIEW
- };
- } else {
- previewsByKey.value = {
- ...previewsByKey.value,
- [key]: { status: 'pending', message: EXIFTOOL_INITIALIZING_MESSAGE }
- };
+ if (exiftoolInitStatus.value !== 'ready') {
+ setPreviewEntry(key, { status: 'initializing', message: EXIFTOOL_INITIALIZING_MESSAGE });
try {
await initializeExiftool();
-
- previewsByKey.value = {
- ...previewsByKey.value,
- [key]: METADATA_LOADING_PREVIEW
- };
} catch (error) {
console.error('[metadata] ExifTool init failed', { file: file.name, error });
- const entry: MetadataPreviewEntry = {
- status: 'error',
- message: EXIFTOOL_PREPARE_ERROR_MESSAGE
- };
+ setPreviewEntry(key, EXIFTOOL_PREPARE_ERROR_PREVIEW);
- previewsByKey.value = { ...previewsByKey.value, [key]: entry };
-
- return entry;
+ return EXIFTOOL_PREPARE_ERROR_PREVIEW;
}
}
+ setPreviewEntry(key, { status: 'processing', message: METADATA_PROCESSING_MESSAGE });
+
let output: string;
try {
@@ -105,14 +96,9 @@ export const useMetadataStore = defineStore('metadata', () => {
} catch (error) {
console.error('[metadata] read failed', { file: file.name, error });
- const entry: MetadataPreviewEntry = {
- status: 'error',
- message: METADATA_LOAD_ERROR_MESSAGE
- };
+ setPreviewEntry(key, METADATA_LOAD_ERROR_PREVIEW);
- previewsByKey.value = { ...previewsByKey.value, [key]: entry };
-
- return entry;
+ return METADATA_LOAD_ERROR_PREVIEW;
}
let strippedBlob: Blob;
@@ -129,10 +115,11 @@ export const useMetadataStore = defineStore('metadata', () => {
const entry: MetadataPreviewEntry = {
status: 'error',
- message: METADATA_STRIP_ERROR_MESSAGE
+ message: METADATA_STRIP_ERROR_MESSAGE,
+ output
};
- previewsByKey.value = { ...previewsByKey.value, [key]: entry };
+ setPreviewEntry(key, entry);
return entry;
}
@@ -149,17 +136,12 @@ export const useMetadataStore = defineStore('metadata', () => {
} catch (error) {
console.error('[metadata] read after strip failed', { file: file.name, error });
- const entry: MetadataPreviewEntry = {
- status: 'error',
- message: METADATA_LOAD_ERROR_MESSAGE
- };
+ setPreviewEntry(key, METADATA_LOAD_ERROR_PREVIEW);
- previewsByKey.value = { ...previewsByKey.value, [key]: entry };
-
- return entry;
+ return METADATA_LOAD_ERROR_PREVIEW;
}
- strippedBlobByKey.value = { ...strippedBlobByKey.value, [key]: strippedBlob };
+ setStrippedBlob(key, strippedBlob);
const entry: MetadataPreviewEntry = {
status: 'ready',
@@ -168,13 +150,22 @@ export const useMetadataStore = defineStore('metadata', () => {
afterStripOutput
};
- previewsByKey.value = { ...previewsByKey.value, [key]: entry };
+ setPreviewEntry(key, entry);
return entry;
};
- const loadPreviews = (files: readonly File[]): Promise =>
- Promise.all(files.map(file => loadPreview(file)));
+ const loadPreviews = async (files: readonly File[]): Promise => {
+ const results: MetadataPreviewEntry[] = [];
+
+ for (const file of files) {
+ const result = await loadPreview(file);
+
+ results.push(result);
+ }
+
+ return results;
+ };
const clearAllPreviews = (): void => {
previewsByKey.value = {};
@@ -184,7 +175,6 @@ export const useMetadataStore = defineStore('metadata', () => {
return {
previewsByKey,
strippedBlobByKey,
- makeKeyFromFile,
getPreview,
getStrippedBlob,
loadPreview,
diff --git a/frontend/src/stores/upload.ts b/frontend/src/stores/upload.ts
index 977f66a..ee96ff9 100644
--- a/frontend/src/stores/upload.ts
+++ b/frontend/src/stores/upload.ts
@@ -1,12 +1,91 @@
import { defineStore } from 'pinia';
+import { ref } from 'vue';
import { ALTCHA_REQUEST_HEADER } from '@/consts/altchaRequestHeader';
import { api } from '@/plugins/axios';
+import { useAltchaStore } from '@/stores/altcha';
+import { useMetadataStore } from '@/stores/metadata';
import type { CreateHotlinkUploadRequest } from '@/types/upload/CreateHotlinkUploadRequest';
import type { CreateZkUploadRequest } from '@/types/upload/CreateZkUploadRequest';
import type { CreatedUploadResult } from '@/types/upload/CreatedUploadResult';
+import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
+import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
+import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
+import { buildUploadOptionsFromBatchSettings } from '@/utils/upload/buildUploadOptionsFromBatchSettings';
+import { buildZkUploadPrepareOptionsFromBatchSettings } from '@/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings';
+import { prepareHotlinkUploadRequest } from '@/utils/upload/prepareHotlinkUploadRequest';
+import { prepareZkUploadRequest } from '@/utils/upload/prepareZkUploadRequest';
+import type { PreparedZkUploadRequest } from '@/types/upload/PreparedZkUploadRequest';
+import type { PreparedHotlinkUploadRequest } from '@/types/upload/PreparedHotlinkUploadRequest';
+
+const UPLOAD_IDLE_MESSAGE = 'Waiting to upload...';
+const UPLOAD_QUEUED_MESSAGE = 'Upload queued';
+const UPLOAD_PREPARING_MESSAGE = 'Preparing upload...';
+const UPLOAD_UPLOADING_MESSAGE = 'Uploading...';
+const UPLOAD_UPLOADED_MESSAGE = 'Uploaded';
+const UPLOAD_READ_ERROR_MESSAGE = 'Could not read image';
+const UPLOAD_PROOF_OF_WORK_ERROR_MESSAGE = 'Proof of work failed';
+const UPLOAD_FAILED_MESSAGE = 'Upload failed';
+const UPLOAD_SKIPPED_METADATA_MESSAGE = 'Metadata not ready';
+const UPLOAD_STRIPPED_MISSING_MESSAGE = 'Stripped image missing';
+const UPLOAD_UNKNOWN_MODE_MESSAGE = 'Unknown share mode';
+
+const UPLOAD_IDLE_ENTRY: UploadQueueEntry = {
+ status: 'idle',
+ message: UPLOAD_IDLE_MESSAGE
+};
+
+const UPLOAD_QUEUED_ENTRY: UploadQueueEntry = {
+ status: 'queued',
+ message: UPLOAD_QUEUED_MESSAGE
+};
+
+const UPLOAD_UPLOADING_ENTRY: UploadQueueEntry = {
+ status: 'uploading',
+ message: UPLOAD_UPLOADING_MESSAGE
+};
+
+const UPLOAD_UPLOADED_ENTRY: UploadQueueEntry = {
+ status: 'uploaded',
+ message: UPLOAD_UPLOADED_MESSAGE
+};
+
+const UPLOAD_FAILED_ENTRY: UploadQueueEntry = {
+ status: 'failed',
+ message: UPLOAD_FAILED_MESSAGE
+};
+
+const UPLOAD_STRIPPED_MISSING_ENTRY: UploadQueueEntry = {
+ status: 'failed',
+ message: UPLOAD_STRIPPED_MISSING_MESSAGE
+};
export const useUploadStore = defineStore('upload', () => {
- const createZkUpload = async (body: CreateZkUploadRequest, altcha?: string): Promise => {
+ const uploadByKey = ref>({});
+
+ const metadataStore = useMetadataStore();
+ const altchaStore = useAltchaStore();
+
+ const { getPreview, getStrippedBlob } = metadataStore;
+
+ const setUploadEntry = (key: string, entry: UploadQueueEntry): void => {
+ uploadByKey.value = { ...uploadByKey.value, [key]: entry };
+ };
+
+ const getUpload = (file?: File | null): UploadQueueEntry => {
+ if (!file) {
+ return UPLOAD_IDLE_ENTRY;
+ }
+
+ const key = makeKeyFromFile(file);
+
+ return uploadByKey.value[key] ?? UPLOAD_IDLE_ENTRY;
+ };
+
+ const clearAllUploads = (): void => {
+ uploadByKey.value = {};
+ };
+
+ const uploadZk = async (body: CreateZkUploadRequest, altcha?: string): Promise => {
const { data } = await api.post('/uploads/zk', body, {
headers: getUploadRequestHeaders(altcha)
});
@@ -14,10 +93,7 @@ export const useUploadStore = defineStore('upload', () => {
return data;
};
- const createHotlinkUpload = async (
- body: CreateHotlinkUploadRequest,
- altcha?: string
- ): Promise => {
+ const uploadHotlink = async (body: CreateHotlinkUploadRequest, altcha?: string): Promise => {
const { data } = await api.post('/uploads/hotlink', body, {
headers: getUploadRequestHeaders(altcha)
});
@@ -27,8 +103,151 @@ export const useUploadStore = defineStore('upload', () => {
const getUploadRequestHeaders = (altcha?: string) => (altcha ? { [ALTCHA_REQUEST_HEADER]: altcha } : undefined);
+ const uploadBatch = async (
+ files: readonly File[],
+ settings: UploadBatchSettingsForm,
+ uploadDespiteMetadataFailure = false
+ ): Promise => {
+ initializeBatchUploadState(files, uploadDespiteMetadataFailure);
+
+ for (const file of files) {
+ const key = makeKeyFromFile(file);
+ const entry = uploadByKey.value[key];
+
+ if (!entry || entry.status !== 'queued') {
+ continue;
+ }
+
+ const strippedBlob = getStrippedBlob(file);
+ const preview = getPreview(file);
+ const uploadOriginal = !strippedBlob && uploadDespiteMetadataFailure && preview.status === 'error';
+
+ if (!strippedBlob && !uploadOriginal) {
+ setUploadEntry(key, UPLOAD_STRIPPED_MISSING_ENTRY);
+ continue;
+ }
+
+ const sourceBlob = strippedBlob ?? file;
+
+ setUploadEntry(key, { status: 'preparing', message: UPLOAD_PREPARING_MESSAGE });
+ let plaintext: Uint8Array;
+ const mimeType = sourceBlob.type || file.type || 'application/octet-stream';
+
+ try {
+ const arrayBuffer = await sourceBlob.arrayBuffer();
+
+ plaintext = new Uint8Array(arrayBuffer);
+ } catch (error) {
+ console.error('[upload] read stripped blob failed', { file: file.name, error });
+
+ setUploadEntry(key, {
+ status: 'failed',
+ message: UPLOAD_READ_ERROR_MESSAGE
+ });
+ continue;
+ }
+
+ let altcha: string | undefined;
+
+ try {
+ altcha = await altchaStore.runProofOfWork();
+ } catch (error) {
+ console.error('[upload] altcha failed', { file: file.name, error });
+
+ setUploadEntry(key, {
+ status: 'failed',
+ message: UPLOAD_PROOF_OF_WORK_ERROR_MESSAGE
+ });
+ continue;
+ }
+
+ if (settings.mode === 'zk') {
+ const options = buildZkUploadPrepareOptionsFromBatchSettings(settings);
+
+ let prepared: PreparedZkUploadRequest;
+
+ try {
+ prepared = await prepareZkUploadRequest(plaintext, options);
+ } catch (error) {
+ console.error('[upload] encrypt failed', { file: file.name, error });
+
+ setUploadEntry(key, UPLOAD_FAILED_ENTRY);
+ continue;
+ }
+
+ setUploadEntry(key, UPLOAD_UPLOADING_ENTRY);
+ try {
+ const { request } = prepared;
+
+ await uploadZk(request, altcha);
+
+ setUploadEntry(key, UPLOAD_UPLOADED_ENTRY);
+ } catch (error) {
+ console.error('[upload] upload failed', { file: file.name, error });
+
+ setUploadEntry(key, UPLOAD_FAILED_ENTRY);
+ }
+ } else if (settings.mode === 'hotlink') {
+ const options = buildUploadOptionsFromBatchSettings(settings);
+
+ let prepared: PreparedHotlinkUploadRequest;
+
+ try {
+ prepared = await prepareHotlinkUploadRequest(plaintext, mimeType, options);
+ } catch (error) {
+ console.error('[upload] encrypt failed', { file: file.name, error });
+
+ setUploadEntry(key, UPLOAD_FAILED_ENTRY);
+ continue;
+ }
+
+ setUploadEntry(key, UPLOAD_UPLOADING_ENTRY);
+ try {
+ const { request } = prepared;
+
+ await uploadHotlink(request, altcha);
+
+ setUploadEntry(key, UPLOAD_UPLOADED_ENTRY);
+ } catch (error) {
+ console.error('[upload] upload failed', { file: file.name, error });
+
+ setUploadEntry(key, UPLOAD_FAILED_ENTRY);
+ }
+ } else {
+ setUploadEntry(key, { status: 'failed', message: UPLOAD_UNKNOWN_MODE_MESSAGE });
+ }
+ }
+ };
+
+ const initializeBatchUploadState = (files: readonly File[], uploadDespiteMetadataFailure: boolean): void => {
+ uploadByKey.value = {};
+
+ for (const file of files) {
+ const key = makeKeyFromFile(file);
+ const preview = getPreview(file);
+ const strippedBlob = getStrippedBlob(file);
+
+ if (preview.status === 'ready') {
+ if (strippedBlob) {
+ setUploadEntry(key, UPLOAD_QUEUED_ENTRY);
+ } else {
+ setUploadEntry(key, UPLOAD_STRIPPED_MISSING_ENTRY);
+ }
+ } else if (preview.status === 'error' && uploadDespiteMetadataFailure) {
+ setUploadEntry(key, UPLOAD_QUEUED_ENTRY);
+ } else {
+ setUploadEntry(key, { status: 'skipped', message: UPLOAD_SKIPPED_METADATA_MESSAGE });
+ }
+
+ }
+ };
+
return {
- createZkUpload,
- createHotlinkUpload
+ uploadByKey,
+ getUpload,
+ clearAllUploads,
+ uploadBatch,
+ uploadZk,
+ uploadHotlink
};
});
diff --git a/frontend/src/types/metadata/MetadataPreview.ts b/frontend/src/types/metadata/MetadataPreview.ts
index 576afe2..c29be71 100644
--- a/frontend/src/types/metadata/MetadataPreview.ts
+++ b/frontend/src/types/metadata/MetadataPreview.ts
@@ -1,6 +1,6 @@
export type MetadataPreviewEntry =
| { status: 'idle'; message: string }
- | { status: 'pending'; message: string }
- | { status: 'loading'; message: string }
+ | { status: 'initializing'; message: string }
+ | { status: 'processing'; message: string }
| { status: 'ready'; message: string; output: string; afterStripOutput: string }
- | { status: 'error'; message: string };
+ | { status: 'error'; message: string; output?: string };
diff --git a/frontend/src/types/upload/PreparedHotlinkUploadRequest.ts b/frontend/src/types/upload/PreparedHotlinkUploadRequest.ts
new file mode 100644
index 0000000..f2074c3
--- /dev/null
+++ b/frontend/src/types/upload/PreparedHotlinkUploadRequest.ts
@@ -0,0 +1,6 @@
+import type { CreateHotlinkUploadRequest } from './CreateHotlinkUploadRequest';
+
+export type PreparedHotlinkUploadRequest = {
+ secret: string;
+ request: CreateHotlinkUploadRequest;
+};
diff --git a/frontend/src/types/upload/PreparedZkUploadRequest.ts b/frontend/src/types/upload/PreparedZkUploadRequest.ts
new file mode 100644
index 0000000..e3e94a8
--- /dev/null
+++ b/frontend/src/types/upload/PreparedZkUploadRequest.ts
@@ -0,0 +1,6 @@
+import type { CreateZkUploadRequest } from './CreateZkUploadRequest';
+
+export type PreparedZkUploadRequest = {
+ fragmentKey: string;
+ request: CreateZkUploadRequest;
+};
diff --git a/frontend/src/types/upload/UploadQueueEntry.ts b/frontend/src/types/upload/UploadQueueEntry.ts
new file mode 100644
index 0000000..699dab9
--- /dev/null
+++ b/frontend/src/types/upload/UploadQueueEntry.ts
@@ -0,0 +1,8 @@
+export type UploadQueueEntry =
+ | { status: 'idle'; message: string }
+ | { status: 'queued'; message: string }
+ | { status: 'preparing'; message: string }
+ | { status: 'uploading'; message: string }
+ | { status: 'uploaded'; message: string }
+ | { status: 'failed'; message: string }
+ | { status: 'skipped'; message: string };
diff --git a/frontend/src/types/upload/UploadQueueRow.ts b/frontend/src/types/upload/UploadQueueRow.ts
index 290b7df..ee4452c 100644
--- a/frontend/src/types/upload/UploadQueueRow.ts
+++ b/frontend/src/types/upload/UploadQueueRow.ts
@@ -1,7 +1,9 @@
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
+import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
export type UploadQueueRow = {
file: File;
key: string;
- preview: MetadataPreviewEntry;
+ metadata: MetadataPreviewEntry;
+ upload: UploadQueueEntry;
};
diff --git a/frontend/src/types/upload/ZkUploadPrepareOptionsResult.ts b/frontend/src/types/upload/ZkUploadPrepareOptionsResult.ts
deleted file mode 100644
index e6d88f4..0000000
--- a/frontend/src/types/upload/ZkUploadPrepareOptionsResult.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import type { ZkUploadPrepareOptions } from '@/types/upload/ZkUploadPrepareOptions';
-
-export type ZkUploadPrepareOptionsResult =
- | { ok: true; options: ZkUploadPrepareOptions }
- | { ok: false; error: string };
diff --git a/frontend/src/utils/file/makeKeyFromFile.ts b/frontend/src/utils/file/makeKeyFromFile.ts
new file mode 100644
index 0000000..b388036
--- /dev/null
+++ b/frontend/src/utils/file/makeKeyFromFile.ts
@@ -0,0 +1,2 @@
+export const makeKeyFromFile = (file: File): string =>
+ `${file.name}\0${file.size}\0${file.lastModified}`;
diff --git a/frontend/src/utils/upload/buildUploadOptionsFromBatchSettings.ts b/frontend/src/utils/upload/buildUploadOptionsFromBatchSettings.ts
new file mode 100644
index 0000000..0b9ecd4
--- /dev/null
+++ b/frontend/src/utils/upload/buildUploadOptionsFromBatchSettings.ts
@@ -0,0 +1,9 @@
+import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
+import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest';
+import { resolveUploadBatchExpiresAt } from '@/utils/upload/resolveUploadBatchExpiresAt';
+import { resolveUploadBatchMaxViews } from '@/utils/upload/resolveUploadBatchMaxViews';
+
+export const buildUploadOptionsFromBatchSettings = (settings: UploadBatchSettingsForm): UploadOptionsRequest => ({
+ expiresAt: resolveUploadBatchExpiresAt(settings.expiryPreset),
+ maxViews: resolveUploadBatchMaxViews(settings.maxViewsPreset)
+});
diff --git a/frontend/src/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings.spec.ts b/frontend/src/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings.spec.ts
index fa11bcf..ee2ab9b 100644
--- a/frontend/src/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings.spec.ts
+++ b/frontend/src/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings.spec.ts
@@ -27,15 +27,10 @@ describe('buildZkUploadPrepareOptionsFromBatchSettings', () => {
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
- }
+ expect(buildZkUploadPrepareOptionsFromBatchSettings(settings)).toEqual({
+ expiresAt: '2026-06-02T00:00:00.000Z',
+ maxViews: 10,
+ passwordProtected: false
});
});
@@ -46,29 +41,11 @@ describe('buildZkUploadPrepareOptionsFromBatchSettings', () => {
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.'
+ expiresAt: null,
+ maxViews: null,
+ passwordProtected: true,
+ password: 'batch-secret'
});
});
});
diff --git a/frontend/src/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings.ts b/frontend/src/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings.ts
index 8bf7320..d9e8229 100644
--- a/frontend/src/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings.ts
+++ b/frontend/src/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings.ts
@@ -1,39 +1,22 @@
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';
+import type { ZkUploadPrepareOptions } from '@/types/upload/ZkUploadPrepareOptions';
+import { buildUploadOptionsFromBatchSettings } from '@/utils/upload/buildUploadOptionsFromBatchSettings';
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)
- };
+): ZkUploadPrepareOptions => {
+ const uploadOptions = buildUploadOptionsFromBatchSettings(settings);
if (settings.passwordEnabled) {
return {
- ok: true,
- options: {
- ...uploadOptions,
- passwordProtected: true,
- password: settings.password
- }
+ ...uploadOptions,
+ passwordProtected: true,
+ password: settings.password
};
}
return {
- ok: true,
- options: {
- ...uploadOptions,
- passwordProtected: false
- }
+ ...uploadOptions,
+ passwordProtected: false
};
};
diff --git a/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts b/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts
index 0e6c508..5135adf 100644
--- a/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts
+++ b/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts
@@ -1,4 +1,4 @@
-import type { CreateHotlinkUploadRequest } from '@/types/upload/CreateHotlinkUploadRequest';
+import type { PreparedHotlinkUploadRequest } from '@/types/upload/PreparedHotlinkUploadRequest';
import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest';
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
import { encryptHotlinkUpload } from '@/utils/upload/encryptHotlinkUpload';
@@ -7,7 +7,7 @@ export const prepareHotlinkUploadRequest = async (
plaintext: Uint8Array,
mimeType: string,
options: UploadOptionsRequest
-): Promise<{ request: CreateHotlinkUploadRequest; secret: string }> => {
+): Promise => {
const encrypted = await encryptHotlinkUpload(plaintext);
return {
diff --git a/frontend/src/utils/upload/prepareZkUploadRequest.ts b/frontend/src/utils/upload/prepareZkUploadRequest.ts
index fe834d2..67ae225 100644
--- a/frontend/src/utils/upload/prepareZkUploadRequest.ts
+++ b/frontend/src/utils/upload/prepareZkUploadRequest.ts
@@ -1,4 +1,4 @@
-import type { CreateZkUploadRequest } from '@/types/upload/CreateZkUploadRequest';
+import type { PreparedZkUploadRequest } from '@/types/upload/PreparedZkUploadRequest';
import type { ZkUploadPrepareOptions } from '@/types/upload/ZkUploadPrepareOptions';
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url';
@@ -9,7 +9,7 @@ import { wrapZkDek } from '@/utils/upload/wrapZkDek';
export const prepareZkUploadRequest = async (
plaintext: Uint8Array,
options: ZkUploadPrepareOptions
-): Promise<{ request: CreateZkUploadRequest; fragmentKey: string }> => {
+): Promise => {
if (!options.passwordProtected) {
const { dek, iv, ciphertext } = await encryptZkUpload(plaintext);
--
2.54.0
From b4cc26a632ca3d72fe7e52ce5d2e756caab6dd1f Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Fri, 23 Oct 2026 18:34:17 +0200
Subject: [PATCH 07/15] 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.
---
docs/UPLOAD_PR_PLAN.md | 28 --------
frontend/src/assets/css/main.css | 3 +
.../components/upload/MetadataInspector.vue | 13 +++-
.../upload/UploadConfigureModal.vue | 70 +++++++++++++++++--
.../src/components/upload/UploadQueueList.vue | 39 ++++++++---
frontend/src/views/UploadView.vue | 64 ++++++++++++-----
6 files changed, 156 insertions(+), 61 deletions(-)
delete mode 100644 docs/UPLOAD_PR_PLAN.md
diff --git a/docs/UPLOAD_PR_PLAN.md b/docs/UPLOAD_PR_PLAN.md
deleted file mode 100644
index ef8680b..0000000
--- a/docs/UPLOAD_PR_PLAN.md
+++ /dev/null
@@ -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
diff --git a/frontend/src/assets/css/main.css b/frontend/src/assets/css/main.css
index 683fd9a..f0d965b 100644
--- a/frontend/src/assets/css/main.css
+++ b/frontend/src/assets/css/main.css
@@ -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);
diff --git a/frontend/src/components/upload/MetadataInspector.vue b/frontend/src/components/upload/MetadataInspector.vue
index a1ff4a9..e66f9b5 100644
--- a/frontend/src/components/upload/MetadataInspector.vue
+++ b/frontend/src/components/upload/MetadataInspector.vue
@@ -8,9 +8,16 @@
Select an image to view its metadata.
-
- {{ entry.message }}
-
+
+ {{ entry.message }}
+
+ Original
+ {{ entry.output }}
+
+
+
+
+
+
+
+
+ {{ metadataErrorCount }}
+ {{ metadataErrorCount === 1 ? 'image has' : 'images have' }} metadata error. Do you want to upload
+ them with original metadata?
+
+
+
+
+ Cancel
+
+
+ Upload
+
+
+
+
diff --git a/frontend/src/components/upload/UploadQueueList.vue b/frontend/src/components/upload/UploadQueueList.vue
index dfaa588..49e9a24 100644
--- a/frontend/src/components/upload/UploadQueueList.vue
+++ b/frontend/src/components/upload/UploadQueueList.vue
@@ -11,8 +11,11 @@
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
-
- {{ row.preview.message }}
+
+ {{ row.metadata.message }}
+
+
+ {{ row.upload.message }}
@@ -21,15 +24,12 @@
diff --git a/frontend/src/views/UploadView.vue b/frontend/src/views/UploadView.vue
index 3b54f0f..f61696e 100644
--- a/frontend/src/views/UploadView.vue
+++ b/frontend/src/views/UploadView.vue
@@ -11,9 +11,14 @@
-
+
@@ -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
([]);
const selectedFile = ref(null);
const selectionError = ref(null);
const loadingMetadata = ref(false);
+const batchUploading = ref(false);
const dropzoneRef = ref | null>(null);
const batchSettings = ref({
mode: 'zk',
@@ -75,6 +86,7 @@ const batchSettings = ref({
password: ''
});
const configureModalOpen = ref(false);
+const uploadConfirmed = ref(false);
const confirmedBatchSummary = ref(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 => {
- 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 => {
+ 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;
+ }
};
--
2.54.0
From 95ed6e5f5857b927fcf91127a63c0f74bcb356a6 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Sun, 25 Oct 2026 22:00:01 +0100
Subject: [PATCH 08/15] Add share URL builders and embed format helpers for
uploaded images.
Centralize clearnet/onion paths, delete links, and BBCode/Markdown/HTML output with unit tests and escape-html for safe HTML snippets.
---
frontend/package-lock.json | 15 +++++
frontend/package.json | 2 +
frontend/src/types/share/ShareNetworkTab.ts | 1 +
frontend/src/types/share/UploadedShareUrls.ts | 25 ++++++++
.../src/utils/share/buildBbcodeImage.spec.ts | 10 +++
frontend/src/utils/share/buildBbcodeImage.ts | 1 +
.../src/utils/share/buildDeletePath.spec.ts | 8 +++
frontend/src/utils/share/buildDeletePath.ts | 2 +
.../src/utils/share/buildHtmlImage.spec.ts | 22 +++++++
frontend/src/utils/share/buildHtmlImage.ts | 4 ++
.../src/utils/share/buildHtmlLink.spec.ts | 22 +++++++
frontend/src/utils/share/buildHtmlLink.ts | 4 ++
.../utils/share/buildMarkdownImage.spec.ts | 14 +++++
.../src/utils/share/buildMarkdownImage.ts | 1 +
.../src/utils/share/buildMarkdownLink.spec.ts | 10 +++
frontend/src/utils/share/buildMarkdownLink.ts | 1 +
.../src/utils/share/buildSiteShareUrl.spec.ts | 14 +++++
frontend/src/utils/share/buildSiteShareUrl.ts | 7 +++
.../src/utils/share/buildUploadedShareUrls.ts | 63 +++++++++++++++++++
19 files changed, 226 insertions(+)
create mode 100644 frontend/src/types/share/ShareNetworkTab.ts
create mode 100644 frontend/src/types/share/UploadedShareUrls.ts
create mode 100644 frontend/src/utils/share/buildBbcodeImage.spec.ts
create mode 100644 frontend/src/utils/share/buildBbcodeImage.ts
create mode 100644 frontend/src/utils/share/buildDeletePath.spec.ts
create mode 100644 frontend/src/utils/share/buildDeletePath.ts
create mode 100644 frontend/src/utils/share/buildHtmlImage.spec.ts
create mode 100644 frontend/src/utils/share/buildHtmlImage.ts
create mode 100644 frontend/src/utils/share/buildHtmlLink.spec.ts
create mode 100644 frontend/src/utils/share/buildHtmlLink.ts
create mode 100644 frontend/src/utils/share/buildMarkdownImage.spec.ts
create mode 100644 frontend/src/utils/share/buildMarkdownImage.ts
create mode 100644 frontend/src/utils/share/buildMarkdownLink.spec.ts
create mode 100644 frontend/src/utils/share/buildMarkdownLink.ts
create mode 100644 frontend/src/utils/share/buildSiteShareUrl.spec.ts
create mode 100644 frontend/src/utils/share/buildSiteShareUrl.ts
create mode 100644 frontend/src/utils/share/buildUploadedShareUrls.ts
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 84a87e3..dbf223f 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -14,6 +14,7 @@
"altcha-lib": "^2.7.0",
"axios": "^1.20.0",
"dayjs": "^1.11.23",
+ "escape-html": "^1.0.3",
"hash-wasm": "^4.12.0",
"pinia": "^4.0.3",
"pretty-bytes": "^7.2.0",
@@ -22,6 +23,7 @@
"vue-router": "^4.6.4"
},
"devDependencies": {
+ "@types/escape-html": "^1.0.4",
"@types/node": "^22.10.7",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1",
@@ -1717,6 +1719,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/escape-html": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@types/escape-html/-/escape-html-1.0.4.tgz",
+ "integrity": "sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2454,6 +2463,12 @@
"@esbuild/win32-x64": "0.28.2"
}
},
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 906fa14..4ee7d91 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -16,6 +16,7 @@
"altcha-lib": "^2.7.0",
"axios": "^1.20.0",
"dayjs": "^1.11.23",
+ "escape-html": "^1.0.3",
"hash-wasm": "^4.12.0",
"pinia": "^4.0.3",
"pretty-bytes": "^7.2.0",
@@ -24,6 +25,7 @@
"vue-router": "^4.6.4"
},
"devDependencies": {
+ "@types/escape-html": "^1.0.4",
"@types/node": "^22.10.7",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1",
diff --git a/frontend/src/types/share/ShareNetworkTab.ts b/frontend/src/types/share/ShareNetworkTab.ts
new file mode 100644
index 0000000..d3b8a12
--- /dev/null
+++ b/frontend/src/types/share/ShareNetworkTab.ts
@@ -0,0 +1 @@
+export type ShareNetworkTab = 'clearnet' | 'onion';
diff --git a/frontend/src/types/share/UploadedShareUrls.ts b/frontend/src/types/share/UploadedShareUrls.ts
new file mode 100644
index 0000000..471ee6a
--- /dev/null
+++ b/frontend/src/types/share/UploadedShareUrls.ts
@@ -0,0 +1,25 @@
+export type ShareNetworkPair = {
+ clearnet: string;
+ onion: string;
+};
+
+type ShareUrlsCore = {
+ viewer: ShareNetworkPair;
+ delete: ShareNetworkPair;
+};
+
+export type HotlinkShareUrls = ShareUrlsCore & {
+ mode: 'hotlink';
+ direct: ShareNetworkPair;
+ bbcode: string;
+ markdownImage: string;
+ htmlImage: string;
+};
+
+export type ZkShareUrls = ShareUrlsCore & {
+ mode: 'zk';
+ markdownLink: string;
+ htmlLink: string;
+};
+
+export type UploadedShareUrls = HotlinkShareUrls | ZkShareUrls;
diff --git a/frontend/src/utils/share/buildBbcodeImage.spec.ts b/frontend/src/utils/share/buildBbcodeImage.spec.ts
new file mode 100644
index 0000000..5953896
--- /dev/null
+++ b/frontend/src/utils/share/buildBbcodeImage.spec.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from 'vitest';
+import { buildBbcodeImage } from '@/utils/share/buildBbcodeImage';
+
+describe('buildBbcodeImage', () => {
+ it('wraps the image URL in BBCode img tags', () => {
+ expect(buildBbcodeImage('https://example.com/i/1/secret')).toBe(
+ '[img]https://example.com/i/1/secret[/img]'
+ );
+ });
+});
diff --git a/frontend/src/utils/share/buildBbcodeImage.ts b/frontend/src/utils/share/buildBbcodeImage.ts
new file mode 100644
index 0000000..55a00e0
--- /dev/null
+++ b/frontend/src/utils/share/buildBbcodeImage.ts
@@ -0,0 +1 @@
+export const buildBbcodeImage = (imageUrl: string): string => `[img]${imageUrl}[/img]`;
diff --git a/frontend/src/utils/share/buildDeletePath.spec.ts b/frontend/src/utils/share/buildDeletePath.spec.ts
new file mode 100644
index 0000000..5a768dc
--- /dev/null
+++ b/frontend/src/utils/share/buildDeletePath.spec.ts
@@ -0,0 +1,8 @@
+import { describe, expect, it } from 'vitest';
+import { buildDeletePath } from '@/utils/share/buildDeletePath';
+
+describe('buildDeletePath', () => {
+ it('builds delete route with encoded token', () => {
+ expect(buildDeletePath('upload-id', 'abc+def/token')).toBe('/d/upload-id?token=abc%2Bdef%2Ftoken');
+ });
+});
diff --git a/frontend/src/utils/share/buildDeletePath.ts b/frontend/src/utils/share/buildDeletePath.ts
new file mode 100644
index 0000000..528cbc7
--- /dev/null
+++ b/frontend/src/utils/share/buildDeletePath.ts
@@ -0,0 +1,2 @@
+export const buildDeletePath = (uploadId: string, deleteToken: string): string =>
+ `/d/${uploadId}?token=${encodeURIComponent(deleteToken)}`;
diff --git a/frontend/src/utils/share/buildHtmlImage.spec.ts b/frontend/src/utils/share/buildHtmlImage.spec.ts
new file mode 100644
index 0000000..37066fc
--- /dev/null
+++ b/frontend/src/utils/share/buildHtmlImage.spec.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { buildHtmlImage } from '@/utils/share/buildHtmlImage';
+
+describe('buildHtmlImage', () => {
+ it('builds an img tag with escaped alt', () => {
+ expect(buildHtmlImage('https://example.com/i/1/secret', 'photo.jpg')).toBe(
+ ' '
+ );
+ });
+
+ it('escapes quotes and markup in alt text', () => {
+ expect(buildHtmlImage('https://example.com/i/1/secret', 'x" onerror="alert(1)')).toBe(
+ ' '
+ );
+ });
+
+ it('escapes special characters in src when present', () => {
+ expect(buildHtmlImage('https://example.com/i/1/"bad', 'image')).toBe(
+ ' '
+ );
+ });
+});
diff --git a/frontend/src/utils/share/buildHtmlImage.ts b/frontend/src/utils/share/buildHtmlImage.ts
new file mode 100644
index 0000000..72d8745
--- /dev/null
+++ b/frontend/src/utils/share/buildHtmlImage.ts
@@ -0,0 +1,4 @@
+import escapeHtml from 'escape-html';
+
+export const buildHtmlImage = (imageUrl: string, alt = 'image'): string =>
+ ` `;
diff --git a/frontend/src/utils/share/buildHtmlLink.spec.ts b/frontend/src/utils/share/buildHtmlLink.spec.ts
new file mode 100644
index 0000000..ca5098e
--- /dev/null
+++ b/frontend/src/utils/share/buildHtmlLink.spec.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { buildHtmlLink } from '@/utils/share/buildHtmlLink';
+
+describe('buildHtmlLink', () => {
+ it('builds an anchor with escaped href and label', () => {
+ expect(buildHtmlLink('photo.jpg', 'https://example.com/v/1#key')).toBe(
+ 'photo.jpg '
+ );
+ });
+
+ it('escapes HTML in link label', () => {
+ expect(buildHtmlLink('', 'https://example.com/v/1')).toBe(
+ '<script>alert(1)</script> '
+ );
+ });
+
+ it('escapes quotes in href', () => {
+ expect(buildHtmlLink('label', 'https://example.com/x" y')).toBe(
+ 'label '
+ );
+ });
+});
diff --git a/frontend/src/utils/share/buildHtmlLink.ts b/frontend/src/utils/share/buildHtmlLink.ts
new file mode 100644
index 0000000..cc9ead3
--- /dev/null
+++ b/frontend/src/utils/share/buildHtmlLink.ts
@@ -0,0 +1,4 @@
+import escapeHtml from 'escape-html';
+
+export const buildHtmlLink = (label: string, url: string): string =>
+ `${escapeHtml(label)} `;
diff --git a/frontend/src/utils/share/buildMarkdownImage.spec.ts b/frontend/src/utils/share/buildMarkdownImage.spec.ts
new file mode 100644
index 0000000..db43195
--- /dev/null
+++ b/frontend/src/utils/share/buildMarkdownImage.spec.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest';
+import { buildMarkdownImage } from '@/utils/share/buildMarkdownImage';
+
+describe('buildMarkdownImage', () => {
+ it('builds a markdown image with alt and url', () => {
+ expect(buildMarkdownImage('https://example.com/i/1/secret', 'photo.jpg')).toBe(
+ ''
+ );
+ });
+
+ it('uses default alt when omitted', () => {
+ expect(buildMarkdownImage('https://example.com/i/1/secret')).toBe('');
+ });
+});
diff --git a/frontend/src/utils/share/buildMarkdownImage.ts b/frontend/src/utils/share/buildMarkdownImage.ts
new file mode 100644
index 0000000..accbe3d
--- /dev/null
+++ b/frontend/src/utils/share/buildMarkdownImage.ts
@@ -0,0 +1 @@
+export const buildMarkdownImage = (imageUrl: string, alt = 'image'): string => ``;
diff --git a/frontend/src/utils/share/buildMarkdownLink.spec.ts b/frontend/src/utils/share/buildMarkdownLink.spec.ts
new file mode 100644
index 0000000..eb9811f
--- /dev/null
+++ b/frontend/src/utils/share/buildMarkdownLink.spec.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from 'vitest';
+import { buildMarkdownLink } from '@/utils/share/buildMarkdownLink';
+
+describe('buildMarkdownLink', () => {
+ it('builds a markdown link with label and url', () => {
+ expect(buildMarkdownLink('photo.jpg', 'https://example.com/v/1#key')).toBe(
+ '[photo.jpg](https://example.com/v/1#key)'
+ );
+ });
+});
diff --git a/frontend/src/utils/share/buildMarkdownLink.ts b/frontend/src/utils/share/buildMarkdownLink.ts
new file mode 100644
index 0000000..c829620
--- /dev/null
+++ b/frontend/src/utils/share/buildMarkdownLink.ts
@@ -0,0 +1 @@
+export const buildMarkdownLink = (label: string, url: string): string => `[${label}](${url})`;
diff --git a/frontend/src/utils/share/buildSiteShareUrl.spec.ts b/frontend/src/utils/share/buildSiteShareUrl.spec.ts
new file mode 100644
index 0000000..d7d2c79
--- /dev/null
+++ b/frontend/src/utils/share/buildSiteShareUrl.spec.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest';
+import { buildSiteShareUrl } from '@/utils/share/buildSiteShareUrl';
+
+describe('buildSiteShareUrl', () => {
+ it('appends path and hash to origin', () => {
+ expect(buildSiteShareUrl('https://example.com', '/v/abc#key')).toBe('https://example.com/v/abc#key');
+ });
+
+ it('handles path without hash', () => {
+ expect(buildSiteShareUrl('http://localhost:5173', '/i/id/secret')).toBe(
+ 'http://localhost:5173/i/id/secret'
+ );
+ });
+});
diff --git a/frontend/src/utils/share/buildSiteShareUrl.ts b/frontend/src/utils/share/buildSiteShareUrl.ts
new file mode 100644
index 0000000..5cbbaed
--- /dev/null
+++ b/frontend/src/utils/share/buildSiteShareUrl.ts
@@ -0,0 +1,7 @@
+export const buildSiteShareUrl = (origin: string, pathWithOptionalHash: string): string => {
+ const hashIndex = pathWithOptionalHash.indexOf('#');
+ const pathname = hashIndex >= 0 ? pathWithOptionalHash.slice(0, hashIndex) : pathWithOptionalHash;
+ const hash = hashIndex >= 0 ? pathWithOptionalHash.slice(hashIndex) : '';
+
+ return `${origin}${pathname}${hash}`;
+};
diff --git a/frontend/src/utils/share/buildUploadedShareUrls.ts b/frontend/src/utils/share/buildUploadedShareUrls.ts
new file mode 100644
index 0000000..c342c84
--- /dev/null
+++ b/frontend/src/utils/share/buildUploadedShareUrls.ts
@@ -0,0 +1,63 @@
+import type { UploadedShareUrls } from '@/types/share/UploadedShareUrls';
+import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
+import { buildBbcodeImage } from '@/utils/share/buildBbcodeImage';
+import { buildHtmlImage } from '@/utils/share/buildHtmlImage';
+import { buildHtmlLink } from '@/utils/share/buildHtmlLink';
+import { buildMarkdownImage } from '@/utils/share/buildMarkdownImage';
+import { buildMarkdownLink } from '@/utils/share/buildMarkdownLink';
+import { config } from '@/config';
+import { buildDeletePath } from '@/utils/share/buildDeletePath';
+import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath';
+import { buildSiteShareUrl } from '@/utils/share/buildSiteShareUrl';
+import { buildZkViewerPath } from '@/utils/share/buildZkViewerPath';
+
+const { clearnet, onion } = config.shareOrigins;
+
+export const buildUploadedShareUrls = (
+ upload: UploadQueueEntry,
+ fileLabel: string
+): UploadedShareUrls | null => {
+ if (upload.status !== 'uploaded') {
+ return null;
+ }
+
+ const deletePath = buildDeletePath(upload.uploadId, upload.deleteToken);
+ const viewerPath = buildZkViewerPath(upload.uploadId, upload.urlSecret);
+
+ const viewer = {
+ clearnet: buildSiteShareUrl(clearnet, viewerPath),
+ onion: buildSiteShareUrl(onion, viewerPath)
+ };
+
+ const deleteUrls = {
+ clearnet: buildSiteShareUrl(clearnet, deletePath),
+ onion: buildSiteShareUrl(onion, deletePath)
+ };
+
+ const core = { viewer, delete: deleteUrls };
+
+ if (upload.mode === 'hotlink') {
+ const directPath = buildHotlinkImagePath(upload.uploadId, upload.urlSecret);
+
+ const direct = {
+ clearnet: buildSiteShareUrl(clearnet, directPath),
+ onion: buildSiteShareUrl(onion, directPath)
+ };
+
+ return {
+ mode: 'hotlink',
+ ...core,
+ direct,
+ bbcode: buildBbcodeImage(direct.clearnet),
+ markdownImage: buildMarkdownImage(direct.clearnet, fileLabel),
+ htmlImage: buildHtmlImage(direct.clearnet, fileLabel)
+ };
+ }
+
+ return {
+ mode: 'zk',
+ ...core,
+ markdownLink: buildMarkdownLink(fileLabel, viewer.clearnet),
+ htmlLink: buildHtmlLink(fileLabel, viewer.clearnet)
+ };
+};
--
2.54.0
From 5f6ee193e7141bfe9034199e7928774708b5a642 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Sun, 25 Oct 2026 22:00:05 +0100
Subject: [PATCH 09/15] Configure share link origins from clearnet scheme and
onion hostname.
Lets the frontend build full viewer and delete URLs for both networks without hardcoding host shape in components.
---
.env.example | 3 ++-
frontend/src/config/index.ts | 6 ++++--
frontend/src/vite-env.d.ts | 1 +
3 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/.env.example b/.env.example
index b342048..e6ff17d 100644
--- a/.env.example
+++ b/.env.example
@@ -33,8 +33,9 @@ VALIDATION_UPLOAD_MAX_IMAGE_BYTES=10485760 # 10MiB
VALIDATION_UPLOAD_JSON_BODY_PADDING_BYTES=2097152 # 2MiB
VITE_API_BASE_URL=http://localhost:3000/api
+VITE_CLEARNET_SCHEME=http
VITE_CLEARNET_DOMAIN=localhost:5173
-VITE_ONION_HOSTNAME=
+VITE_ONION_HOSTNAME=abcdefghijklmnop.onion
VITE_ALTCHA_ENABLED=true
VITE_ALTCHA_MAX_NUMBER=250000
VITE_UPLOAD_MAX_QUEUE_SIZE=10
diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts
index 4d3a652..69dd13f 100644
--- a/frontend/src/config/index.ts
+++ b/frontend/src/config/index.ts
@@ -14,8 +14,10 @@ const isEnabled = (key: string): boolean => env(key) === 'true';
export const config = {
baseUrl: env('VITE_API_BASE_URL'),
- clearnetDomain: env('VITE_CLEARNET_DOMAIN'),
- onionHostname: env('VITE_ONION_HOSTNAME'),
+ shareOrigins: {
+ clearnet: `${env('VITE_CLEARNET_SCHEME')}://${env('VITE_CLEARNET_DOMAIN')}`,
+ onion: `http://${env('VITE_ONION_HOSTNAME')}`
+ },
altcha: {
enabled: isEnabled('VITE_ALTCHA_ENABLED'),
maxNumber: envInt('VITE_ALTCHA_MAX_NUMBER')
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
index 806b5a2..2fca688 100644
--- a/frontend/src/vite-env.d.ts
+++ b/frontend/src/vite-env.d.ts
@@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
+ readonly VITE_CLEARNET_SCHEME: string;
readonly VITE_CLEARNET_DOMAIN: string;
readonly VITE_ONION_HOSTNAME: string;
readonly VITE_ALTCHA_ENABLED: string;
--
2.54.0
From 4e9a3368a6c67ba6ada54c9b49e6b2c6f97885aa Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Sun, 25 Oct 2026 22:00:05 +0100
Subject: [PATCH 10/15] Persist upload IDs and URL secrets on successful queue
entries.
Store server response fields and mode-specific secrets so share links can be built client-side after upload without precomputed paths.
---
frontend/src/stores/upload.ts | 31 ++++++++++++-------
frontend/src/types/upload/UploadQueueEntry.ts | 11 ++++++-
2 files changed, 30 insertions(+), 12 deletions(-)
diff --git a/frontend/src/stores/upload.ts b/frontend/src/stores/upload.ts
index ee96ff9..459dd94 100644
--- a/frontend/src/stores/upload.ts
+++ b/frontend/src/stores/upload.ts
@@ -44,11 +44,6 @@ const UPLOAD_UPLOADING_ENTRY: UploadQueueEntry = {
message: UPLOAD_UPLOADING_MESSAGE
};
-const UPLOAD_UPLOADED_ENTRY: UploadQueueEntry = {
- status: 'uploaded',
- message: UPLOAD_UPLOADED_MESSAGE
-};
-
const UPLOAD_FAILED_ENTRY: UploadQueueEntry = {
status: 'failed',
message: UPLOAD_FAILED_MESSAGE
@@ -177,11 +172,18 @@ export const useUploadStore = defineStore('upload', () => {
setUploadEntry(key, UPLOAD_UPLOADING_ENTRY);
try {
- const { request } = prepared;
+ const { request, fragmentKey } = prepared;
- await uploadZk(request, altcha);
+ const result = await uploadZk(request, altcha);
- setUploadEntry(key, UPLOAD_UPLOADED_ENTRY);
+ setUploadEntry(key, {
+ status: 'uploaded',
+ message: UPLOAD_UPLOADED_MESSAGE,
+ uploadId: result.id,
+ mode: 'zk',
+ deleteToken: result.deleteToken,
+ urlSecret: fragmentKey
+ });
} catch (error) {
console.error('[upload] upload failed', { file: file.name, error });
@@ -203,11 +205,18 @@ export const useUploadStore = defineStore('upload', () => {
setUploadEntry(key, UPLOAD_UPLOADING_ENTRY);
try {
- const { request } = prepared;
+ const { request, secret } = prepared;
- await uploadHotlink(request, altcha);
+ const result = await uploadHotlink(request, altcha);
- setUploadEntry(key, UPLOAD_UPLOADED_ENTRY);
+ setUploadEntry(key, {
+ status: 'uploaded',
+ message: UPLOAD_UPLOADED_MESSAGE,
+ uploadId: result.id,
+ mode: 'hotlink',
+ deleteToken: result.deleteToken,
+ urlSecret: secret
+ });
} catch (error) {
console.error('[upload] upload failed', { file: file.name, error });
diff --git a/frontend/src/types/upload/UploadQueueEntry.ts b/frontend/src/types/upload/UploadQueueEntry.ts
index 699dab9..d56923f 100644
--- a/frontend/src/types/upload/UploadQueueEntry.ts
+++ b/frontend/src/types/upload/UploadQueueEntry.ts
@@ -1,8 +1,17 @@
+import type { UploadMode } from '@/types/upload/UploadMode';
+
export type UploadQueueEntry =
| { status: 'idle'; message: string }
| { status: 'queued'; message: string }
| { status: 'preparing'; message: string }
| { status: 'uploading'; message: string }
- | { status: 'uploaded'; message: string }
+ | {
+ status: 'uploaded';
+ message: string;
+ uploadId: string;
+ deleteToken: string;
+ urlSecret: string;
+ mode: UploadMode;
+ }
| { status: 'failed'; message: string }
| { status: 'skipped'; message: string };
--
2.54.0
From f305ce7a39faa5f0ee9185f5fd38109a596aa2b8 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Sun, 25 Oct 2026 22:00:15 +0100
Subject: [PATCH 11/15] Refactor metadata inspector with shared tab list and
output panel.
Reuse InlineTabList for original/stripped tabs and a single pre block component to match patterns used elsewhere on the upload page.
---
frontend/src/components/InlineTabList.vue | 36 +++++++++++
.../components/upload/MetadataInspector.vue | 60 ++++---------------
.../components/upload/MetadataOutputPanel.vue | 5 ++
frontend/src/types/ui/InlineTabOption.ts | 4 ++
4 files changed, 58 insertions(+), 47 deletions(-)
create mode 100644 frontend/src/components/InlineTabList.vue
create mode 100644 frontend/src/components/upload/MetadataOutputPanel.vue
create mode 100644 frontend/src/types/ui/InlineTabOption.ts
diff --git a/frontend/src/components/InlineTabList.vue b/frontend/src/components/InlineTabList.vue
new file mode 100644
index 0000000..25944fd
--- /dev/null
+++ b/frontend/src/components/InlineTabList.vue
@@ -0,0 +1,36 @@
+
+
+
+ {{ option.label }}
+
+
+
+
+
diff --git a/frontend/src/components/upload/MetadataInspector.vue b/frontend/src/components/upload/MetadataInspector.vue
index e66f9b5..8e6a862 100644
--- a/frontend/src/components/upload/MetadataInspector.vue
+++ b/frontend/src/components/upload/MetadataInspector.vue
@@ -1,5 +1,5 @@
-
-
+
{{ confirmedBatchSummary }}
+
+
({
});
const configureModalOpen = ref(false);
const uploadConfirmed = ref(false);
+const showShareLinks = ref(false);
const confirmedBatchSummary = ref(null);
onBeforeMount(async () => {
@@ -114,6 +121,8 @@ const queueRows = computed(() =>
const selectedPreview = computed(() => metadataStore.getPreview(selectedFile.value));
+const selectedUpload = computed(() => uploadStore.getUpload(selectedFile.value));
+
const metadataErrorCount = computed(
() => queuedFiles.value.filter(file => metadataStore.getPreview(file).status === 'error').length
);
@@ -125,7 +134,7 @@ const onFilesPicked = async (picked: File[]): Promise => {
return;
}
- resetUploadSelection();
+ resetUploadSession();
if (picked.length === 0) {
return;
@@ -168,7 +177,7 @@ const onFilesPicked = async (picked: File[]): Promise => {
}
};
-const resetUploadSelection = (): void => {
+const resetUploadSession = (): void => {
selectionError.value = null;
clearAllPreviews();
queuedFiles.value = [];
@@ -177,6 +186,7 @@ const resetUploadSelection = (): void => {
dropzoneRef.value?.clearInput();
configureModalOpen.value = false;
uploadConfirmed.value = false;
+ showShareLinks.value = false;
confirmedBatchSummary.value = null;
};
@@ -195,6 +205,7 @@ const onBatchUpload = async (uploadDespiteMetadataFailure?: boolean): Promise
--
2.54.0
From 42fe209164f183242682bfa0fc643dc75fea8083 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Sun, 25 Oct 2026 22:00:16 +0100
Subject: [PATCH 13/15] Simplify upload modal, dropzone, and settings markup.
Remove redundant dialog ARIA wiring, hide the file input with hidden instead of sr-only, and trim footnote accessibility attributes that duplicated visible text.
---
.../upload/UploadConfigureModal.vue | 21 +++++--------------
.../src/components/upload/UploadDropzone.vue | 2 +-
.../components/upload/UploadSettingsPanel.vue | 6 ++----
3 files changed, 8 insertions(+), 21 deletions(-)
diff --git a/frontend/src/components/upload/UploadConfigureModal.vue b/frontend/src/components/upload/UploadConfigureModal.vue
index aba9e83..9a37fc5 100644
--- a/frontend/src/components/upload/UploadConfigureModal.vue
+++ b/frontend/src/components/upload/UploadConfigureModal.vue
@@ -1,14 +1,9 @@