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 @@ + + + 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 @@ + + + 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 @@

-
- + -

{{ selectionError }}

-
- - + @@ -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() + ] }); 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 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 @@ @@ -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; }; 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; 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); 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 }} -

+ 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 @@
- +