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