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 @@
+
+ {{ selectionError }}
+
+
+
+
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
{{ selectionError }}
-