Slave/upload ui #8
Generated
+274
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Vendored
+20
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div>
|
||||
<label
|
||||
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90"
|
||||
:class="disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
|
||||
>
|
||||
Choose images
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="sr-only"
|
||||
:disabled="disabled"
|
||||
@change="onFileInputChange"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p v-if="selectionError" class="mt-2 text-sm text-destructive">{{ selectionError }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, type PropType } from 'vue';
|
||||
|
||||
defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
selectionError: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
pick: [files: File[]];
|
||||
}>();
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const onFileInputChange = (event: Event): void => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const picked = input.files ? [...input.files] : [];
|
||||
|
||||
emit('pick', picked);
|
||||
};
|
||||
|
||||
const clearInput = (): void => {
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ clearInput });
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<ul v-if="rows.length > 0" class="divide-y divide-border rounded-lg border border-border">
|
||||
<li v-for="row in rows" :key="row.key">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60"
|
||||
:class="isRowSelected(row) ? 'bg-muted/80' : ''"
|
||||
@click="emit('select', row.file)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ row.file.name }}</span>
|
||||
<span class="shrink-0 text-xs text-muted-foreground">
|
||||
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
|
||||
</span>
|
||||
<span class="shrink-0 text-xs" :class="statusTextClass(row.preview)">
|
||||
{{ row.preview.message }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type PropType } from 'vue';
|
||||
import { useMetadataStore } from '@/stores/metadata';
|
||||
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
||||
import type { UploadQueueRow } from '@/types/upload/UploadQueueRow';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
|
||||
const metadataStore = useMetadataStore();
|
||||
|
||||
const { makeKeyFromFile } = metadataStore;
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array as PropType<UploadQueueRow[]>,
|
||||
required: true
|
||||
},
|
||||
selectedFile: {
|
||||
type: Object as PropType<File | null>,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [file: File];
|
||||
}>();
|
||||
|
||||
const isRowSelected = (row: UploadQueueRow): boolean =>
|
||||
props.selectedFile !== null && makeKeyFromFile(props.selectedFile) === row.key;
|
||||
|
||||
const statusTextClass = (entry: MetadataPreviewEntry): string =>
|
||||
entry.status === 'error' ? 'text-destructive' : 'text-muted-foreground';
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
||||
|
||||
export type UploadQueueRow = {
|
||||
file: File;
|
||||
key: string;
|
||||
preview: MetadataPreviewEntry;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { PickedUploadAnalysis } from '@/types/upload/PickedUploadAnalysis';
|
||||
|
||||
export const buildPickedUploadWarning = (
|
||||
summary: Pick<PickedUploadAnalysis, 'nonImageCount' | 'oversizedImagesCount' | 'queueOmittedImagesCount'>,
|
||||
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;
|
||||
};
|
||||
@@ -9,44 +9,14 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90"
|
||||
:class="loadingMetadata ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
|
||||
>
|
||||
Choose images
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="sr-only"
|
||||
:disabled="loadingMetadata"
|
||||
@change="onFileInputChange"
|
||||
/>
|
||||
</label>
|
||||
<UploadDropzone
|
||||
ref="dropzoneRef"
|
||||
:disabled="loadingMetadata"
|
||||
:selection-error="selectionError"
|
||||
@pick="onFilesPicked"
|
||||
/>
|
||||
|
||||
<p v-if="selectionError" class="mt-2 text-sm text-destructive">{{ selectionError }}</p>
|
||||
</div>
|
||||
|
||||
<ul v-if="queueRows.length > 0" class="divide-y divide-border rounded-lg border border-border">
|
||||
<li v-for="row in queueRows" :key="row.key">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60"
|
||||
:class="isFileSelected(row.file) ? 'bg-muted/80' : ''"
|
||||
@click="selectedFile = row.file"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ row.file.name }}</span>
|
||||
<span class="shrink-0 text-xs text-muted-foreground">
|
||||
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
|
||||
</span>
|
||||
<span class="shrink-0 text-xs" :class="statusTextClass(row.preview)">
|
||||
{{ row.preview.message }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<UploadQueueList :rows="queueRows" :selected-file="selectedFile" @select="selectedFile = $event" />
|
||||
|
||||
<MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" />
|
||||
</main>
|
||||
@@ -54,12 +24,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeMount, ref } from 'vue';
|
||||
import MetadataInspector from '@/components/upload/MetadataInspector.vue';
|
||||
import type UploadDropzone from '@/components/upload/UploadDropzone.vue';
|
||||
import { config } from '@/config';
|
||||
import { useExiftoolStore } from '@/stores/exiftool';
|
||||
import { useMetadataStore } from '@/stores/metadata';
|
||||
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
||||
import type { PickedUploadAnalysis } from '@/types/upload/PickedUploadAnalysis';
|
||||
import { analyzePickedUploadFiles } from '@/utils/upload/analyzePickedUploadFiles';
|
||||
import { buildPickedUploadWarning } from '@/utils/upload/buildPickedUploadWarning';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
|
||||
const { maxQueueSize, maxImageBytes } = config.validation.upload;
|
||||
@@ -74,7 +44,7 @@ const queuedFiles = ref<File[]>([]);
|
||||
const selectedFile = ref<File | null>(null);
|
||||
const selectionError = ref<string | null>(null);
|
||||
const loadingMetadata = ref(false);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const dropzoneRef = ref<InstanceType<typeof UploadDropzone> | null>(null);
|
||||
|
||||
onBeforeMount(async () => {
|
||||
try {
|
||||
@@ -96,27 +66,22 @@ const queueRows = computed(() =>
|
||||
|
||||
const selectedPreview = computed(() => getPreview(selectedFile.value));
|
||||
|
||||
const isFileSelected = (file: File): boolean =>
|
||||
selectedFile.value !== null && makeKeyFromFile(selectedFile.value) === makeKeyFromFile(file);
|
||||
|
||||
const statusTextClass = (entry: MetadataPreviewEntry): string =>
|
||||
entry.status === 'error' ? 'text-destructive' : 'text-muted-foreground';
|
||||
|
||||
const onFileInputChange = async (event: Event): Promise<void> => {
|
||||
const onFilesPicked = async (picked: File[]): Promise<void> => {
|
||||
if (loadingMetadata.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = event.target as HTMLInputElement;
|
||||
const picked = input.files ? [...input.files] : [];
|
||||
|
||||
resetUploadSelection();
|
||||
|
||||
if (picked.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { accepted, nonImageCount, oversizedImagesCount, queueOmittedImagesCount } = analyzePickedUploadFiles(picked);
|
||||
const { accepted, nonImageCount, oversizedImagesCount, queueOmittedImagesCount } = analyzePickedUploadFiles(
|
||||
picked,
|
||||
maxImageBytes,
|
||||
maxQueueSize
|
||||
);
|
||||
|
||||
if (nonImageCount === picked.length) {
|
||||
selectionError.value = 'Only image files can be added.';
|
||||
@@ -130,7 +95,10 @@ const onFileInputChange = async (event: Event): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionError.value = buildPickedUploadWarning({ nonImageCount, oversizedImagesCount, queueOmittedImagesCount });
|
||||
selectionError.value = buildPickedUploadWarning(
|
||||
{ nonImageCount, oversizedImagesCount, queueOmittedImagesCount },
|
||||
maxQueueSize
|
||||
);
|
||||
|
||||
queuedFiles.value = accepted;
|
||||
selectedFile.value = accepted[0] ?? null;
|
||||
@@ -151,45 +119,6 @@ const resetUploadSelection = (): void => {
|
||||
clearAllPreviews();
|
||||
queuedFiles.value = [];
|
||||
selectedFile.value = null;
|
||||
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const analyzePickedUploadFiles = (picked: File[]): 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
|
||||
};
|
||||
};
|
||||
|
||||
const buildPickedUploadWarning = (
|
||||
summary: Pick<PickedUploadAnalysis, 'nonImageCount' | 'oversizedImagesCount' | 'queueOmittedImagesCount'>
|
||||
): 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;
|
||||
dropzoneRef.value?.clearInput();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user