Merge pull request 'Slave/upload ui' (#8) from slave/upload-ui into master
Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
+5
-4
@@ -20,11 +20,9 @@ CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
|||||||
|
|
||||||
ALTCHA_ENABLED=true
|
ALTCHA_ENABLED=true
|
||||||
ALTCHA_HMAC_KEY=change-me-use-long-random-secret
|
ALTCHA_HMAC_KEY=change-me-use-long-random-secret
|
||||||
ALTCHA_MAX_NUMBER=50000
|
ALTCHA_MAX_NUMBER=250000
|
||||||
ALTCHA_CHALLENGE_TTL_MS=600000
|
ALTCHA_CHALLENGE_TTL_MS=600000
|
||||||
|
|
||||||
NEVER_MODE_INACTIVITY_DAYS=365
|
|
||||||
|
|
||||||
THROTTLE_TTL_MS=60000
|
THROTTLE_TTL_MS=60000
|
||||||
THROTTLE_LIMIT=1000
|
THROTTLE_LIMIT=1000
|
||||||
THROTTLE_UPLOAD_TTL_MS=60000
|
THROTTLE_UPLOAD_TTL_MS=60000
|
||||||
@@ -35,8 +33,11 @@ VALIDATION_UPLOAD_MAX_IMAGE_BYTES=10485760 # 10MiB
|
|||||||
VALIDATION_UPLOAD_JSON_BODY_PADDING_BYTES=2097152 # 2MiB
|
VALIDATION_UPLOAD_JSON_BODY_PADDING_BYTES=2097152 # 2MiB
|
||||||
|
|
||||||
VITE_API_BASE_URL=http://localhost:3000/api
|
VITE_API_BASE_URL=http://localhost:3000/api
|
||||||
|
VITE_CLEARNET_SCHEME=http
|
||||||
VITE_CLEARNET_DOMAIN=localhost:5173
|
VITE_CLEARNET_DOMAIN=localhost:5173
|
||||||
VITE_ONION_HOSTNAME=
|
VITE_ONION_HOSTNAME=abcdefghijklmnop.onion
|
||||||
|
VITE_ALTCHA_ENABLED=true
|
||||||
|
VITE_ALTCHA_MAX_NUMBER=250000
|
||||||
VITE_UPLOAD_MAX_QUEUE_SIZE=10
|
VITE_UPLOAD_MAX_QUEUE_SIZE=10
|
||||||
VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX=1000000
|
VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX=1000000
|
||||||
VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES=10485760 # 10MiB
|
VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES=10485760 # 10MiB
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22.23.4-alpine
|
||||||
|
|
||||||
WORKDIR /backend
|
WORKDIR /backend
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const validConfig = (overrides: Record<string, unknown> = {}): Record<string, un
|
|||||||
ALTCHA_HMAC_KEY: 'secret',
|
ALTCHA_HMAC_KEY: 'secret',
|
||||||
ALTCHA_MAX_NUMBER: 50000,
|
ALTCHA_MAX_NUMBER: 50000,
|
||||||
ALTCHA_CHALLENGE_TTL_MS: 600_000,
|
ALTCHA_CHALLENGE_TTL_MS: 600_000,
|
||||||
NEVER_MODE_INACTIVITY_DAYS: 365,
|
|
||||||
THROTTLE_TTL_MS: 60000,
|
THROTTLE_TTL_MS: 60000,
|
||||||
THROTTLE_LIMIT: 1000,
|
THROTTLE_LIMIT: 1000,
|
||||||
THROTTLE_UPLOAD_TTL_MS: 60000,
|
THROTTLE_UPLOAD_TTL_MS: 60000,
|
||||||
|
|||||||
@@ -65,12 +65,6 @@ class EnvironmentVariables {
|
|||||||
@Max(3_600_000)
|
@Max(3_600_000)
|
||||||
ALTCHA_CHALLENGE_TTL_MS: number;
|
ALTCHA_CHALLENGE_TTL_MS: number;
|
||||||
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(1)
|
|
||||||
@Max(3650)
|
|
||||||
NEVER_MODE_INACTIVITY_DAYS: number;
|
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(1000)
|
@Min(1000)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22.23.4-alpine
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
|
|||||||
Generated
+366
@@ -11,7 +11,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@uswriting/exiftool": "1.0.9",
|
"@uswriting/exiftool": "1.0.9",
|
||||||
|
"altcha-lib": "^2.7.0",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
|
"dayjs": "^1.11.23",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
"hash-wasm": "^4.12.0",
|
"hash-wasm": "^4.12.0",
|
||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
"pretty-bytes": "^7.2.0",
|
"pretty-bytes": "^7.2.0",
|
||||||
@@ -20,10 +23,12 @@
|
|||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/escape-html": "^1.0.4",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@vitejs/plugin-vue": "^6.0.5",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
|
"unplugin-vue-components": "^32.1.0",
|
||||||
"vite": "^8.0.4",
|
"vite": "^8.0.4",
|
||||||
"vitest": "^3.2.7",
|
"vitest": "^3.2.7",
|
||||||
"vue-tsc": "^3.2.6"
|
"vue-tsc": "^3.2.6"
|
||||||
@@ -1714,6 +1719,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/escape-html": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/escape-html/-/escape-html-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -2037,6 +2049,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": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
@@ -2056,6 +2081,75 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -2144,6 +2238,22 @@
|
|||||||
"node": ">= 16"
|
"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": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -2156,12 +2266,25 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -2340,6 +2463,12 @@
|
|||||||
"@esbuild/win32-x64": "0.28.2"
|
"@esbuild/win32-x64": "0.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/estree-walker": {
|
"node_modules/estree-walker": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||||
@@ -2356,6 +2485,13 @@
|
|||||||
"node": ">=12.0.0"
|
"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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -2817,6 +2953,24 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/loupe": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||||
@@ -2863,6 +3017,38 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -2900,6 +3086,20 @@
|
|||||||
"integrity": "sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg==",
|
"integrity": "sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
@@ -2974,6 +3174,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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.28",
|
"version": "8.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
@@ -3023,6 +3235,37 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.2.11",
|
"version": "1.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz",
|
||||||
@@ -3238,6 +3481,13 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
@@ -3245,6 +3495,115 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "8.3.1",
|
"version": "8.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz",
|
||||||
@@ -3671,6 +4030,13 @@
|
|||||||
"typescript": ">=5.0.0"
|
"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": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
|||||||
@@ -13,7 +13,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@uswriting/exiftool": "1.0.9",
|
"@uswriting/exiftool": "1.0.9",
|
||||||
|
"altcha-lib": "^2.7.0",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
|
"dayjs": "^1.11.23",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
"hash-wasm": "^4.12.0",
|
"hash-wasm": "^4.12.0",
|
||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
"pretty-bytes": "^7.2.0",
|
"pretty-bytes": "^7.2.0",
|
||||||
@@ -22,10 +25,12 @@
|
|||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/escape-html": "^1.0.4",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@vitejs/plugin-vue": "^6.0.5",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
|
"unplugin-vue-components": "^32.1.0",
|
||||||
"vite": "^8.0.4",
|
"vite": "^8.0.4",
|
||||||
"vitest": "^3.2.7",
|
"vitest": "^3.2.7",
|
||||||
"vue-tsc": "^3.2.6"
|
"vue-tsc": "^3.2.6"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
|
--color-success: var(--success);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
--accent: oklch(0.94 0.02 260);
|
--accent: oklch(0.94 0.02 260);
|
||||||
--accent-foreground: oklch(0.28 0.03 260);
|
--accent-foreground: oklch(0.28 0.03 260);
|
||||||
--destructive: oklch(0.55 0.2 25);
|
--destructive: oklch(0.55 0.2 25);
|
||||||
|
--success: oklch(0.45 0.14 155);
|
||||||
--border: oklch(0.9 0.01 260);
|
--border: oklch(0.9 0.01 260);
|
||||||
--input: oklch(0.9 0.01 260);
|
--input: oklch(0.9 0.01 260);
|
||||||
--ring: oklch(0.42 0.08 260);
|
--ring: oklch(0.42 0.08 260);
|
||||||
@@ -59,6 +61,7 @@
|
|||||||
--accent: oklch(0.28 0.03 260);
|
--accent: oklch(0.28 0.03 260);
|
||||||
--accent-foreground: oklch(0.96 0.01 260);
|
--accent-foreground: oklch(0.96 0.01 260);
|
||||||
--destructive: oklch(0.62 0.2 25);
|
--destructive: oklch(0.62 0.2 25);
|
||||||
|
--success: oklch(0.68 0.14 155);
|
||||||
--border: oklch(0.32 0.02 260);
|
--border: oklch(0.32 0.02 260);
|
||||||
--input: oklch(0.32 0.02 260);
|
--input: oklch(0.32 0.02 260);
|
||||||
--ring: oklch(0.72 0.1 260);
|
--ring: oklch(0.72 0.1 260);
|
||||||
|
|||||||
Vendored
+26
@@ -0,0 +1,26 @@
|
|||||||
|
/* 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 {
|
||||||
|
InlineTabList: typeof import('./components/InlineTabList.vue')['default']
|
||||||
|
MetadataInspector: typeof import('./components/upload/MetadataInspector.vue')['default']
|
||||||
|
MetadataOutputPanel: typeof import('./components/upload/MetadataOutputPanel.vue')['default']
|
||||||
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
ShareLinkField: typeof import('./components/upload/ShareLinkField.vue')['default']
|
||||||
|
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']
|
||||||
|
UploadShareResults: typeof import('./components/upload/UploadShareResults.vue')['default']
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<div class="inline-flex w-fit rounded-md border border-border p-0.5">
|
||||||
|
<button
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
type="button"
|
||||||
|
class="rounded px-3 py-1.5 text-xs font-medium transition-colors"
|
||||||
|
:class="
|
||||||
|
modelValue === option.value
|
||||||
|
? 'bg-muted text-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
"
|
||||||
|
@click="select(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" generic="T extends string">
|
||||||
|
import { type PropType } from 'vue';
|
||||||
|
import type { InlineTabOption } from '@/types/ui/InlineTabOption';
|
||||||
|
|
||||||
|
const modelValue = defineModel<T>({ required: true });
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
options: {
|
||||||
|
type: Array as PropType<InlineTabOption<T>[]>,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const select = (value: T): void => {
|
||||||
|
modelValue.value = value;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -1,58 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="flex min-h-48 flex-col rounded-lg border border-border bg-card" aria-live="polite">
|
<section class="flex min-h-48 flex-col rounded-lg border border-border bg-card">
|
||||||
<header class="border-b border-border px-4 py-3">
|
<header class="border-b border-border px-4 py-3">
|
||||||
<h2 class="text-sm font-medium text-card-foreground">Metadata</h2>
|
<h2 class="text-sm font-medium text-card-foreground">Metadata Inspector</h2>
|
||||||
<p v-if="fileName" class="mt-0.5 truncate text-xs text-muted-foreground">{{ fileName }}</p>
|
<p v-if="fileName" class="mt-0.5 truncate text-xs text-muted-foreground">{{ fileName }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-3 p-4">
|
<div class="flex flex-1 flex-col gap-3 p-4">
|
||||||
<p v-if="!fileName" class="text-sm text-muted-foreground">Select an image to view its metadata.</p>
|
<p v-if="!fileName" class="text-sm text-muted-foreground">Select an image to view its metadata.</p>
|
||||||
|
|
||||||
<p v-else-if="entry.status === 'error'" class="text-sm text-destructive">
|
<template v-else-if="entry.status === 'error'">
|
||||||
{{ entry.message }}
|
<p class="text-sm text-destructive">{{ entry.message }}</p>
|
||||||
</p>
|
<p v-if="entry.output" class="text-xs font-medium text-foreground">Original</p>
|
||||||
|
<MetadataOutputPanel v-if="entry.output">{{ entry.output }}</MetadataOutputPanel>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else-if="entry.status === 'ready'">
|
<template v-else-if="entry.status === 'ready'">
|
||||||
<div
|
<InlineTabList v-model="activeTab" :options="metadataTabOptions" />
|
||||||
class="inline-flex w-fit rounded-md border border-border p-0.5"
|
|
||||||
role="tablist"
|
|
||||||
aria-label="Metadata view"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
class="rounded px-3 py-1.5 text-xs font-medium transition-colors"
|
|
||||||
:class="
|
|
||||||
activeTab === 'original'
|
|
||||||
? 'bg-muted text-foreground'
|
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
"
|
|
||||||
:aria-selected="activeTab === 'original'"
|
|
||||||
@click="activeTab = 'original'"
|
|
||||||
>
|
|
||||||
Original
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
class="rounded px-3 py-1.5 text-xs font-medium transition-colors"
|
|
||||||
:class="
|
|
||||||
activeTab === 'stripped'
|
|
||||||
? 'bg-muted text-foreground'
|
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
"
|
|
||||||
:aria-selected="activeTab === 'stripped'"
|
|
||||||
@click="activeTab = 'stripped'"
|
|
||||||
>
|
|
||||||
Stripped
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<pre
|
<MetadataOutputPanel>
|
||||||
role="tabpanel"
|
{{ activeTab === 'original' ? entry.output : entry.afterStripOutput }}
|
||||||
class="max-h-96 min-h-32 flex-1 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed whitespace-pre-wrap text-foreground"
|
</MetadataOutputPanel>
|
||||||
>{{ activeTab === 'original' ? entry.output : entry.afterStripOutput }}</pre
|
|
||||||
>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-else class="text-sm text-muted-foreground">{{ entry.message }}</p>
|
<p v-else class="text-sm text-muted-foreground">{{ entry.message }}</p>
|
||||||
@@ -64,6 +31,12 @@
|
|||||||
import { ref, watch, type PropType } from 'vue';
|
import { ref, watch, type PropType } from 'vue';
|
||||||
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
||||||
import type { MetadataTab } from '@/types/metadata/MetadataTab';
|
import type { MetadataTab } from '@/types/metadata/MetadataTab';
|
||||||
|
import type { InlineTabOption } from '@/types/ui/InlineTabOption';
|
||||||
|
|
||||||
|
const metadataTabOptions: InlineTabOption<MetadataTab>[] = [
|
||||||
|
{ value: 'original', label: 'Original' },
|
||||||
|
{ value: 'stripped', label: 'Stripped' }
|
||||||
|
];
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
fileName: {
|
fileName: {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<pre
|
||||||
|
class="max-h-96 min-h-32 flex-1 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed whitespace-pre-wrap text-foreground"
|
||||||
|
><slot /></pre>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<label class="flex flex-col gap-1.5 text-xs">
|
||||||
|
<span class="font-medium text-muted-foreground">{{ label }}</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readonly
|
||||||
|
class="min-w-0 flex-1 rounded-md border border-input bg-muted/40 px-3 py-2 font-mono text-xs text-foreground"
|
||||||
|
:value="value"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded-md border border-border px-3 py-2 text-xs font-medium transition-colors hover:bg-muted/60"
|
||||||
|
@click="onCopyClick"
|
||||||
|
>
|
||||||
|
{{ copied ? 'Copied' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onUnmounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const COPIED_RESET_MS = 1000;
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const copied = ref(false);
|
||||||
|
|
||||||
|
let copiedResetTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
// component stay mounted when user switching between images so we need to reset the copied state when the value changes
|
||||||
|
watch(
|
||||||
|
() => props.value,
|
||||||
|
() => resetCopied()
|
||||||
|
);
|
||||||
|
|
||||||
|
onUnmounted(() => clearCopiedResetTimeout());
|
||||||
|
|
||||||
|
const clearCopiedResetTimeout = (): void => {
|
||||||
|
if (copiedResetTimeout !== undefined) {
|
||||||
|
clearTimeout(copiedResetTimeout);
|
||||||
|
copiedResetTimeout = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetCopied = (): void => {
|
||||||
|
clearCopiedResetTimeout();
|
||||||
|
copied.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCopyClick = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(props.value);
|
||||||
|
|
||||||
|
copied.value = true;
|
||||||
|
|
||||||
|
clearCopiedResetTimeout();
|
||||||
|
|
||||||
|
copiedResetTimeout = setTimeout(() => {
|
||||||
|
copied.value = false;
|
||||||
|
}, COPIED_RESET_MS);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ShareLinkField] copy failed', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-4 sm:items-center">
|
||||||
|
<div class="flex w-full max-w-lg flex-col rounded-lg border border-border bg-card shadow-lg">
|
||||||
|
<header class="border-b border-border px-4 py-3">
|
||||||
|
<h2 class="text-base font-medium text-card-foreground">
|
||||||
|
Configure upload
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">Settings apply to all chosen images.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="px-4 py-4">
|
||||||
|
<UploadSettingsPanel v-model="settings" />
|
||||||
|
<p v-if="uploadValidationError" class="mt-3 text-sm text-destructive">
|
||||||
|
{{ uploadValidationError }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="flex flex-col-reverse gap-2 border-t border-border px-4 py-3 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-muted/60"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90"
|
||||||
|
@click="onUploadClick"
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="open && metadataErrorConfirmOpen"
|
||||||
|
class="fixed inset-0 z-60 flex items-end justify-center bg-black/50 p-4 sm:items-center"
|
||||||
|
>
|
||||||
|
<div class="flex w-full max-w-md flex-col rounded-lg border border-border bg-card shadow-lg">
|
||||||
|
<header class="border-b border-border px-4 py-3">
|
||||||
|
<h2 class="text-base font-medium text-card-foreground">
|
||||||
|
Metadata errors
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="px-4 py-4 text-sm text-muted-foreground">
|
||||||
|
{{ metadataErrorCount }}
|
||||||
|
{{ metadataErrorCount === 1 ? 'image has' : 'images have' }} metadata error. Do you want to upload
|
||||||
|
them with original metadata?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<footer class="flex flex-col-reverse gap-2 border-t border-border px-4 py-3 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-muted/60"
|
||||||
|
@click="metadataErrorConfirmOpen = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90"
|
||||||
|
@click="emitUpload(true)"
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, type PropType } from 'vue';
|
||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
import { validateUploadBatchSettings } from '@/utils/upload/validateUploadBatchSettings';
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('open', { required: true });
|
||||||
|
|
||||||
|
const settings = defineModel<UploadBatchSettingsForm>('settings', { required: true });
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
metadataErrorCount: {
|
||||||
|
type: Number as PropType<number>,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
upload: [uploadDespiteMetadataFailure?: boolean];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const uploadValidationError = ref<string | null>(null);
|
||||||
|
const metadataErrorConfirmOpen = ref(false);
|
||||||
|
|
||||||
|
watch(open, isOpen => {
|
||||||
|
if (!isOpen) {
|
||||||
|
uploadValidationError.value = null;
|
||||||
|
metadataErrorConfirmOpen.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = (): void => {
|
||||||
|
open.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitUpload = (uploadDespiteMetadataFailure?: boolean): void => {
|
||||||
|
uploadValidationError.value = null;
|
||||||
|
metadataErrorConfirmOpen.value = false;
|
||||||
|
|
||||||
|
emit('upload', uploadDespiteMetadataFailure);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUploadClick = (): void => {
|
||||||
|
const error = validateUploadBatchSettings(settings.value);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
uploadValidationError.value = error;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.metadataErrorCount > 0) {
|
||||||
|
metadataErrorConfirmOpen.value = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitUpload();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -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="hidden"
|
||||||
|
: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,74 @@
|
|||||||
|
<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 max-w-32 truncate text-xs sm:max-w-40" :class="metadataStatusClass(row.metadata)">
|
||||||
|
{{ row.metadata.message }}
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0 max-w-32 truncate text-xs sm:max-w-40" :class="uploadStatusClass(row.upload)">
|
||||||
|
{{ row.upload.message }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { type PropType } from 'vue';
|
||||||
|
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
||||||
|
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
|
||||||
|
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
|
||||||
|
import type { UploadQueueRow } from '@/types/upload/UploadQueueRow';
|
||||||
|
import prettyBytes from 'pretty-bytes';
|
||||||
|
|
||||||
|
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 metadataStatusClass = (entry: MetadataPreviewEntry): string => {
|
||||||
|
if (entry.status === 'error') {
|
||||||
|
return 'text-destructive';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.status === 'ready') {
|
||||||
|
return 'text-success';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text-muted-foreground';
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadStatusClass = (entry: UploadQueueEntry): string => {
|
||||||
|
if (entry.status === 'failed') {
|
||||||
|
return 'text-destructive';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.status === 'uploaded') {
|
||||||
|
return 'text-success';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text-muted-foreground';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-5">
|
||||||
|
<fieldset class="space-y-3">
|
||||||
|
<legend class="text-sm font-medium text-foreground">Share mode</legend>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label
|
||||||
|
class="flex cursor-pointer gap-3 rounded-md border border-border p-3 has-checked:border-primary has-checked:bg-muted/40"
|
||||||
|
>
|
||||||
|
<input v-model="settings.mode" class="mt-0.5" type="radio" name="upload-mode" value="zk" />
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="text-sm font-medium">Zero knowledge</span>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
Images are E2E encrypted. The server has no technical ability to decrypt them. Having the link
|
||||||
|
gives permission to view the image. You can additionally password-protect the upload so the
|
||||||
|
link alone is not enough. Great for private sharing, but doesn't work with BBCode, HTML,
|
||||||
|
etc.
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class="flex cursor-pointer gap-3 rounded-md border border-border p-3 has-checked:border-primary has-checked:bg-muted/40"
|
||||||
|
>
|
||||||
|
<input v-model="settings.mode" class="mt-0.5" type="radio" name="upload-mode" value="hotlink" />
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="text-sm font-medium">Hotlink</span>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
Still encrypted at rest, but the server decrypts on the fly. Works with BBCode, HTML,
|
||||||
|
etc.
|
||||||
|
Encryption here serves mainly as
|
||||||
|
<span class="whitespace-nowrap">
|
||||||
|
<span class="font-bold">crypto-shred</span
|
||||||
|
><sup class="font-bold text-[0.7em] text-muted-foreground">1</sup> </span
|
||||||
|
>, because hotlink images are intended to be public.
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="crypto-shred-footnote" class="text-xs italic leading-snug text-muted-foreground">
|
||||||
|
<sup class="mr-1 font-bold not-italic text-[0.85em]">1</sup>
|
||||||
|
Hard drives do not guarantee that deleted files cannot be recovered. Crypto-shred consists of storing
|
||||||
|
data as encrypted blobs, so recovered leftovers are useless without the decryption key, which is never
|
||||||
|
stored on the server.
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span class="font-medium">Expiry</span>
|
||||||
|
<select
|
||||||
|
v-model="settings.expiryPreset"
|
||||||
|
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option v-for="option in expiryOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span class="font-medium">Max views</span>
|
||||||
|
<select
|
||||||
|
v-model="settings.maxViewsPreset"
|
||||||
|
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option v-for="option in maxViewsOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="settings.mode === 'zk'" class="space-y-3 rounded-md border border-border p-3">
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||||
|
<input v-model="settings.passwordEnabled" type="checkbox" class="rounded border-input" />
|
||||||
|
<span class="font-medium">Set password</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Requires the link and this password to decrypt. Use when the link alone should not be enough.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-if="settings.passwordEnabled">
|
||||||
|
<label class="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span>Password</span>
|
||||||
|
<input
|
||||||
|
v-model="settings.password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { config } from '@/config';
|
||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
import type { UploadExpiryPreset } from '@/types/upload/UploadExpiryPreset';
|
||||||
|
import type { UploadMaxViewsPreset } from '@/types/upload/UploadMaxViewsPreset';
|
||||||
|
|
||||||
|
const settings = defineModel<UploadBatchSettingsForm>({ required: true });
|
||||||
|
|
||||||
|
const { maxViewsMax } = config.validation.upload;
|
||||||
|
|
||||||
|
const expiryOptions: { value: UploadExpiryPreset; label: string }[] = [
|
||||||
|
{ value: 'never', label: 'Never' },
|
||||||
|
{ value: '1h', label: '1 hour' },
|
||||||
|
{ value: '24h', label: '24 hours' },
|
||||||
|
{ value: '7d', label: '7 days' },
|
||||||
|
{ value: '30d', label: '30 days' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const maxViewsOptions = computed(() => {
|
||||||
|
const options: { value: UploadMaxViewsPreset; label: string; numeric: number | null }[] = [
|
||||||
|
{ value: 'unlimited', label: 'Unlimited', numeric: null },
|
||||||
|
{ value: '1', label: '1- Burn after read', numeric: 1 },
|
||||||
|
{ value: '10', label: '10', numeric: 10 },
|
||||||
|
{ value: '100', label: '100', numeric: 100 },
|
||||||
|
{ value: '1000', label: '1000', numeric: 1000 },
|
||||||
|
{ value: '10000', label: '10000', numeric: 10000 }
|
||||||
|
];
|
||||||
|
|
||||||
|
return options.filter(option => option.numeric === null || option.numeric <= maxViewsMax);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<section class="flex min-h-48 flex-col rounded-lg border border-border bg-card">
|
||||||
|
<header class="border-b border-border px-4 py-3">
|
||||||
|
<h2 class="text-sm font-medium text-card-foreground">Share links</h2>
|
||||||
|
<p v-if="fileName" class="mt-0.5 truncate text-xs text-muted-foreground">{{ fileName }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col gap-3 p-4">
|
||||||
|
<p v-if="!fileName" class="text-sm text-muted-foreground">Select an image to view share links.</p>
|
||||||
|
|
||||||
|
<template v-else-if="shareUrls">
|
||||||
|
<InlineTabList v-model="activeNetworkTab" :options="shareNetworkTabOptions" />
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<ShareLinkField label="Viewer link" :value="shareUrls.viewer[activeNetworkTab]" />
|
||||||
|
<ShareLinkField label="Delete link" :value="shareUrls.delete[activeNetworkTab]" />
|
||||||
|
<ShareLinkField
|
||||||
|
v-if="shareUrls.mode === 'hotlink'"
|
||||||
|
label="Direct image"
|
||||||
|
:value="shareUrls.direct[activeNetworkTab]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="activeNetworkTab === 'clearnet'">
|
||||||
|
<template v-if="shareUrls.mode === 'hotlink'">
|
||||||
|
<ShareLinkField label="BBCode" :value="shareUrls.bbcode" />
|
||||||
|
<ShareLinkField label="Markdown image" :value="shareUrls.markdownImage" />
|
||||||
|
<ShareLinkField label="HTML image" :value="shareUrls.htmlImage" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<ShareLinkField label="Markdown link" :value="shareUrls.markdownLink" />
|
||||||
|
<ShareLinkField label="HTML link" :value="shareUrls.htmlLink" />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-else class="text-sm text-destructive">No share links are available.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch, type PropType } from 'vue';
|
||||||
|
import type { ShareNetworkTab } from '@/types/share/ShareNetworkTab';
|
||||||
|
import type { InlineTabOption } from '@/types/ui/InlineTabOption';
|
||||||
|
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
|
||||||
|
import { buildUploadedShareUrls } from '@/utils/share/buildUploadedShareUrls';
|
||||||
|
|
||||||
|
const shareNetworkTabOptions: InlineTabOption<ShareNetworkTab>[] = [
|
||||||
|
{ value: 'clearnet', label: 'Clearnet' },
|
||||||
|
{ value: 'onion', label: 'Onion' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
fileName: {
|
||||||
|
type: String as PropType<string | null>,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
type: Object as PropType<UploadQueueEntry>,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeNetworkTab = ref<ShareNetworkTab>('clearnet');
|
||||||
|
|
||||||
|
const shareUrls = computed(() =>
|
||||||
|
props.fileName ? buildUploadedShareUrls(props.upload, props.fileName) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.fileName,
|
||||||
|
() => {
|
||||||
|
activeNetworkTab.value = 'clearnet';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
@@ -10,10 +10,18 @@ const env = (key: string): string => {
|
|||||||
|
|
||||||
const envInt = (key: string): number => parseInt(env(key), 10);
|
const envInt = (key: string): number => parseInt(env(key), 10);
|
||||||
|
|
||||||
|
const isEnabled = (key: string): boolean => env(key) === 'true';
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
baseUrl: env('VITE_API_BASE_URL'),
|
baseUrl: env('VITE_API_BASE_URL'),
|
||||||
clearnetDomain: env('VITE_CLEARNET_DOMAIN'),
|
shareOrigins: {
|
||||||
onionHostname: env('VITE_ONION_HOSTNAME'),
|
clearnet: `${env('VITE_CLEARNET_SCHEME')}://${env('VITE_CLEARNET_DOMAIN')}`,
|
||||||
|
onion: `http://${env('VITE_ONION_HOSTNAME')}`
|
||||||
|
},
|
||||||
|
altcha: {
|
||||||
|
enabled: isEnabled('VITE_ALTCHA_ENABLED'),
|
||||||
|
maxNumber: envInt('VITE_ALTCHA_MAX_NUMBER')
|
||||||
|
},
|
||||||
validation: {
|
validation: {
|
||||||
upload: {
|
upload: {
|
||||||
maxQueueSize: envInt('VITE_UPLOAD_MAX_QUEUE_SIZE'),
|
maxQueueSize: envInt('VITE_UPLOAD_MAX_QUEUE_SIZE'),
|
||||||
|
|||||||
@@ -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<string | undefined> => {
|
||||||
|
if (!config.altcha.enabled) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const challenge = await fetchChallenge();
|
||||||
|
|
||||||
|
return encodeSolvedChallenge(challenge);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchChallenge = async (): Promise<Challenge> => {
|
||||||
|
const { data } = await api.get<Challenge>('/altcha/challenge');
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodeSolvedChallenge = async ({
|
||||||
|
algorithm,
|
||||||
|
challenge: challengeValue,
|
||||||
|
salt,
|
||||||
|
signature,
|
||||||
|
maxnumber
|
||||||
|
}: Challenge): Promise<string> => {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -2,23 +2,29 @@ import { defineStore, storeToRefs } from 'pinia';
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useExiftoolStore } from '@/stores/exiftool';
|
import { useExiftoolStore } from '@/stores/exiftool';
|
||||||
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
||||||
|
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
|
||||||
|
|
||||||
const EXIFTOOL_PREPARE_ERROR_MESSAGE = 'Could not prepare ExifTool';
|
const METADATA_IDLE_MESSAGE = 'Waiting to load metadata...';
|
||||||
const EXIFTOOL_INITIALIZING_MESSAGE = 'Initializing ExifTool';
|
const EXIFTOOL_INITIALIZING_MESSAGE = 'Initializing ExifTool...';
|
||||||
const METADATA_LOADING_MESSAGE = 'Loading metadata';
|
const METADATA_PROCESSING_MESSAGE = 'Stripping metadata...';
|
||||||
|
const METADATA_READY_MESSAGE = 'Metadata stripped';
|
||||||
const METADATA_LOAD_ERROR_MESSAGE = 'Could not load image metadata';
|
const METADATA_LOAD_ERROR_MESSAGE = 'Could not load image metadata';
|
||||||
const METADATA_STRIP_ERROR_MESSAGE = 'Could not strip image metadata';
|
const METADATA_STRIP_ERROR_MESSAGE = 'Could not strip image metadata';
|
||||||
const METADATA_IDLE_MESSAGE = 'Waiting to load metadata';
|
const EXIFTOOL_PREPARE_ERROR_MESSAGE = 'Could not prepare ExifTool';
|
||||||
const METADATA_READY_MESSAGE = 'Metadata stripped';
|
|
||||||
|
|
||||||
const METADATA_IDLE_PREVIEW: MetadataPreviewEntry = {
|
const METADATA_IDLE_PREVIEW: MetadataPreviewEntry = {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
message: METADATA_IDLE_MESSAGE
|
message: METADATA_IDLE_MESSAGE
|
||||||
};
|
};
|
||||||
|
|
||||||
const METADATA_LOADING_PREVIEW: MetadataPreviewEntry = {
|
const METADATA_LOAD_ERROR_PREVIEW: MetadataPreviewEntry = {
|
||||||
status: 'loading',
|
status: 'error',
|
||||||
message: METADATA_LOADING_MESSAGE
|
message: METADATA_LOAD_ERROR_MESSAGE
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXIFTOOL_PREPARE_ERROR_PREVIEW: MetadataPreviewEntry = {
|
||||||
|
status: 'error',
|
||||||
|
message: EXIFTOOL_PREPARE_ERROR_MESSAGE
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useMetadataStore = defineStore('metadata', () => {
|
export const useMetadataStore = defineStore('metadata', () => {
|
||||||
@@ -30,7 +36,13 @@ export const useMetadataStore = defineStore('metadata', () => {
|
|||||||
const { initStatus: exiftoolInitStatus } = storeToRefs(exiftoolStore);
|
const { initStatus: exiftoolInitStatus } = storeToRefs(exiftoolStore);
|
||||||
const { initializeExiftool, readMetadata, stripAllMetadata } = 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 => {
|
const getPreview = (file?: File | null): MetadataPreviewEntry => {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -56,48 +68,27 @@ export const useMetadataStore = defineStore('metadata', () => {
|
|||||||
const key = makeKeyFromFile(file);
|
const key = makeKeyFromFile(file);
|
||||||
|
|
||||||
if (exiftoolInitStatus.value === 'error') {
|
if (exiftoolInitStatus.value === 'error') {
|
||||||
const entry: MetadataPreviewEntry = {
|
setPreviewEntry(key, EXIFTOOL_PREPARE_ERROR_PREVIEW);
|
||||||
status: 'error',
|
|
||||||
message: EXIFTOOL_PREPARE_ERROR_MESSAGE
|
|
||||||
};
|
|
||||||
|
|
||||||
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
|
return EXIFTOOL_PREPARE_ERROR_PREVIEW;
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exiftoolInitStatus.value === 'ready') {
|
if (exiftoolInitStatus.value !== 'ready') {
|
||||||
previewsByKey.value = {
|
setPreviewEntry(key, { status: 'initializing', message: EXIFTOOL_INITIALIZING_MESSAGE });
|
||||||
...previewsByKey.value,
|
|
||||||
[key]: METADATA_LOADING_PREVIEW
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
previewsByKey.value = {
|
|
||||||
...previewsByKey.value,
|
|
||||||
[key]: { status: 'pending', message: EXIFTOOL_INITIALIZING_MESSAGE }
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await initializeExiftool();
|
await initializeExiftool();
|
||||||
|
|
||||||
previewsByKey.value = {
|
|
||||||
...previewsByKey.value,
|
|
||||||
[key]: METADATA_LOADING_PREVIEW
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[metadata] ExifTool init failed', { file: file.name, error });
|
console.error('[metadata] ExifTool init failed', { file: file.name, error });
|
||||||
|
|
||||||
const entry: MetadataPreviewEntry = {
|
setPreviewEntry(key, EXIFTOOL_PREPARE_ERROR_PREVIEW);
|
||||||
status: 'error',
|
|
||||||
message: EXIFTOOL_PREPARE_ERROR_MESSAGE
|
|
||||||
};
|
|
||||||
|
|
||||||
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
|
return EXIFTOOL_PREPARE_ERROR_PREVIEW;
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPreviewEntry(key, { status: 'processing', message: METADATA_PROCESSING_MESSAGE });
|
||||||
|
|
||||||
let output: string;
|
let output: string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -105,14 +96,9 @@ export const useMetadataStore = defineStore('metadata', () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[metadata] read failed', { file: file.name, error });
|
console.error('[metadata] read failed', { file: file.name, error });
|
||||||
|
|
||||||
const entry: MetadataPreviewEntry = {
|
setPreviewEntry(key, METADATA_LOAD_ERROR_PREVIEW);
|
||||||
status: 'error',
|
|
||||||
message: METADATA_LOAD_ERROR_MESSAGE
|
|
||||||
};
|
|
||||||
|
|
||||||
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
|
return METADATA_LOAD_ERROR_PREVIEW;
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let strippedBlob: Blob;
|
let strippedBlob: Blob;
|
||||||
@@ -129,10 +115,11 @@ export const useMetadataStore = defineStore('metadata', () => {
|
|||||||
|
|
||||||
const entry: MetadataPreviewEntry = {
|
const entry: MetadataPreviewEntry = {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: METADATA_STRIP_ERROR_MESSAGE
|
message: METADATA_STRIP_ERROR_MESSAGE,
|
||||||
|
output
|
||||||
};
|
};
|
||||||
|
|
||||||
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
|
setPreviewEntry(key, entry);
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
@@ -149,17 +136,12 @@ export const useMetadataStore = defineStore('metadata', () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[metadata] read after strip failed', { file: file.name, error });
|
console.error('[metadata] read after strip failed', { file: file.name, error });
|
||||||
|
|
||||||
const entry: MetadataPreviewEntry = {
|
setPreviewEntry(key, METADATA_LOAD_ERROR_PREVIEW);
|
||||||
status: 'error',
|
|
||||||
message: METADATA_LOAD_ERROR_MESSAGE
|
|
||||||
};
|
|
||||||
|
|
||||||
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
|
return METADATA_LOAD_ERROR_PREVIEW;
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
strippedBlobByKey.value = { ...strippedBlobByKey.value, [key]: strippedBlob };
|
setStrippedBlob(key, strippedBlob);
|
||||||
|
|
||||||
const entry: MetadataPreviewEntry = {
|
const entry: MetadataPreviewEntry = {
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
@@ -168,13 +150,22 @@ export const useMetadataStore = defineStore('metadata', () => {
|
|||||||
afterStripOutput
|
afterStripOutput
|
||||||
};
|
};
|
||||||
|
|
||||||
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
|
setPreviewEntry(key, entry);
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPreviews = (files: readonly File[]): Promise<MetadataPreviewEntry[]> =>
|
const loadPreviews = async (files: readonly File[]): Promise<MetadataPreviewEntry[]> => {
|
||||||
Promise.all(files.map(file => loadPreview(file)));
|
const results: MetadataPreviewEntry[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const result = await loadPreview(file);
|
||||||
|
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
const clearAllPreviews = (): void => {
|
const clearAllPreviews = (): void => {
|
||||||
previewsByKey.value = {};
|
previewsByKey.value = {};
|
||||||
@@ -184,7 +175,6 @@ export const useMetadataStore = defineStore('metadata', () => {
|
|||||||
return {
|
return {
|
||||||
previewsByKey,
|
previewsByKey,
|
||||||
strippedBlobByKey,
|
strippedBlobByKey,
|
||||||
makeKeyFromFile,
|
|
||||||
getPreview,
|
getPreview,
|
||||||
getStrippedBlob,
|
getStrippedBlob,
|
||||||
loadPreview,
|
loadPreview,
|
||||||
|
|||||||
@@ -1,12 +1,86 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
import { ALTCHA_REQUEST_HEADER } from '@/consts/altchaRequestHeader';
|
import { ALTCHA_REQUEST_HEADER } from '@/consts/altchaRequestHeader';
|
||||||
import { api } from '@/plugins/axios';
|
import { api } from '@/plugins/axios';
|
||||||
|
import { useAltchaStore } from '@/stores/altcha';
|
||||||
|
import { useMetadataStore } from '@/stores/metadata';
|
||||||
import type { CreateHotlinkUploadRequest } from '@/types/upload/CreateHotlinkUploadRequest';
|
import type { CreateHotlinkUploadRequest } from '@/types/upload/CreateHotlinkUploadRequest';
|
||||||
import type { CreateZkUploadRequest } from '@/types/upload/CreateZkUploadRequest';
|
import type { CreateZkUploadRequest } from '@/types/upload/CreateZkUploadRequest';
|
||||||
import type { CreatedUploadResult } from '@/types/upload/CreatedUploadResult';
|
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_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', () => {
|
export const useUploadStore = defineStore('upload', () => {
|
||||||
const createZkUpload = async (body: CreateZkUploadRequest, altcha?: string): Promise<CreatedUploadResult> => {
|
const uploadByKey = ref<Record<string, UploadQueueEntry>>({});
|
||||||
|
|
||||||
|
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<CreatedUploadResult> => {
|
||||||
const { data } = await api.post<CreatedUploadResult>('/uploads/zk', body, {
|
const { data } = await api.post<CreatedUploadResult>('/uploads/zk', body, {
|
||||||
headers: getUploadRequestHeaders(altcha)
|
headers: getUploadRequestHeaders(altcha)
|
||||||
});
|
});
|
||||||
@@ -14,10 +88,7 @@ export const useUploadStore = defineStore('upload', () => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createHotlinkUpload = async (
|
const uploadHotlink = async (body: CreateHotlinkUploadRequest, altcha?: string): Promise<CreatedUploadResult> => {
|
||||||
body: CreateHotlinkUploadRequest,
|
|
||||||
altcha?: string
|
|
||||||
): Promise<CreatedUploadResult> => {
|
|
||||||
const { data } = await api.post<CreatedUploadResult>('/uploads/hotlink', body, {
|
const { data } = await api.post<CreatedUploadResult>('/uploads/hotlink', body, {
|
||||||
headers: getUploadRequestHeaders(altcha)
|
headers: getUploadRequestHeaders(altcha)
|
||||||
});
|
});
|
||||||
@@ -27,8 +98,165 @@ export const useUploadStore = defineStore('upload', () => {
|
|||||||
|
|
||||||
const getUploadRequestHeaders = (altcha?: string) => (altcha ? { [ALTCHA_REQUEST_HEADER]: altcha } : undefined);
|
const getUploadRequestHeaders = (altcha?: string) => (altcha ? { [ALTCHA_REQUEST_HEADER]: altcha } : undefined);
|
||||||
|
|
||||||
|
const uploadBatch = async (
|
||||||
|
files: readonly File[],
|
||||||
|
settings: UploadBatchSettingsForm,
|
||||||
|
uploadDespiteMetadataFailure = false
|
||||||
|
): Promise<void> => {
|
||||||
|
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, fragmentKey } = prepared;
|
||||||
|
|
||||||
|
const result = await uploadZk(request, altcha);
|
||||||
|
|
||||||
|
setUploadEntry(key, {
|
||||||
|
status: 'uploaded',
|
||||||
|
message: UPLOAD_UPLOADED_MESSAGE,
|
||||||
|
uploadId: result.id,
|
||||||
|
mode: 'zk',
|
||||||
|
deleteToken: result.deleteToken,
|
||||||
|
urlSecret: fragmentKey
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[upload] upload failed', { file: file.name, error });
|
||||||
|
|
||||||
|
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, secret } = prepared;
|
||||||
|
|
||||||
|
const result = await uploadHotlink(request, altcha);
|
||||||
|
|
||||||
|
setUploadEntry(key, {
|
||||||
|
status: 'uploaded',
|
||||||
|
message: UPLOAD_UPLOADED_MESSAGE,
|
||||||
|
uploadId: result.id,
|
||||||
|
mode: 'hotlink',
|
||||||
|
deleteToken: result.deleteToken,
|
||||||
|
urlSecret: secret
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[upload] upload failed', { file: file.name, error });
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
createZkUpload,
|
uploadByKey,
|
||||||
createHotlinkUpload
|
getUpload,
|
||||||
|
clearAllUploads,
|
||||||
|
uploadBatch,
|
||||||
|
uploadZk,
|
||||||
|
uploadHotlink
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type MetadataPreviewEntry =
|
export type MetadataPreviewEntry =
|
||||||
| { status: 'idle'; message: string }
|
| { status: 'idle'; message: string }
|
||||||
| { status: 'pending'; message: string }
|
| { status: 'initializing'; message: string }
|
||||||
| { status: 'loading'; message: string }
|
| { status: 'processing'; message: string }
|
||||||
| { status: 'ready'; message: string; output: string; afterStripOutput: string }
|
| { status: 'ready'; message: string; output: string; afterStripOutput: string }
|
||||||
| { status: 'error'; message: string };
|
| { status: 'error'; message: string; output?: string };
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export type ShareNetworkTab = 'clearnet' | 'onion';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export type ShareNetworkPair = {
|
||||||
|
clearnet: string;
|
||||||
|
onion: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShareUrlsCore = {
|
||||||
|
viewer: ShareNetworkPair;
|
||||||
|
delete: ShareNetworkPair;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HotlinkShareUrls = ShareUrlsCore & {
|
||||||
|
mode: 'hotlink';
|
||||||
|
direct: ShareNetworkPair;
|
||||||
|
bbcode: string;
|
||||||
|
markdownImage: string;
|
||||||
|
htmlImage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ZkShareUrls = ShareUrlsCore & {
|
||||||
|
mode: 'zk';
|
||||||
|
markdownLink: string;
|
||||||
|
htmlLink: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadedShareUrls = HotlinkShareUrls | ZkShareUrls;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type InlineTabOption<T extends string = string> = {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { CreateHotlinkUploadRequest } from './CreateHotlinkUploadRequest';
|
||||||
|
|
||||||
|
export type PreparedHotlinkUploadRequest = {
|
||||||
|
secret: string;
|
||||||
|
request: CreateHotlinkUploadRequest;
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { CreateZkUploadRequest } from './CreateZkUploadRequest';
|
||||||
|
|
||||||
|
export type PreparedZkUploadRequest = {
|
||||||
|
fragmentKey: string;
|
||||||
|
request: CreateZkUploadRequest;
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { UploadExpiryPreset } from '@/types/upload/UploadExpiryPreset';
|
||||||
|
import type { UploadMaxViewsPreset } from '@/types/upload/UploadMaxViewsPreset';
|
||||||
|
import type { UploadMode } from '@/types/upload/UploadMode';
|
||||||
|
|
||||||
|
export type UploadBatchSettingsForm = {
|
||||||
|
mode: UploadMode;
|
||||||
|
expiryPreset: UploadExpiryPreset;
|
||||||
|
maxViewsPreset: UploadMaxViewsPreset;
|
||||||
|
passwordEnabled: boolean;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { ManipulateType } from 'dayjs';
|
||||||
|
|
||||||
|
export type UploadExpiryOffset = {
|
||||||
|
amount: number;
|
||||||
|
unit: ManipulateType;
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type UploadExpiryPreset = 'never' | '1h' | '24h' | '7d' | '30d';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type UploadMaxViewsPreset = 'unlimited' | '1' | '10' | '100' | '1000' | '10000';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { UploadMode } from '@/types/upload/UploadMode';
|
||||||
|
|
||||||
|
export type UploadQueueEntry =
|
||||||
|
| { status: 'idle'; message: string }
|
||||||
|
| { status: 'queued'; message: string }
|
||||||
|
| { status: 'preparing'; message: string }
|
||||||
|
| { status: 'uploading'; message: string }
|
||||||
|
| {
|
||||||
|
status: 'uploaded';
|
||||||
|
message: string;
|
||||||
|
uploadId: string;
|
||||||
|
deleteToken: string;
|
||||||
|
urlSecret: string;
|
||||||
|
mode: UploadMode;
|
||||||
|
}
|
||||||
|
| { status: 'failed'; message: string }
|
||||||
|
| { status: 'skipped'; message: string };
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
||||||
|
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
|
||||||
|
|
||||||
|
export type UploadQueueRow = {
|
||||||
|
file: File;
|
||||||
|
key: string;
|
||||||
|
metadata: MetadataPreviewEntry;
|
||||||
|
upload: UploadQueueEntry;
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const makeKeyFromFile = (file: File): string =>
|
||||||
|
`${file.name}\0${file.size}\0${file.lastModified}`;
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { AxiosError, type InternalAxiosRequestConfig } from 'axios';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { resolveAxiosErrorMessage } from '@/utils/http/resolveAxiosErrorMessage';
|
|
||||||
|
|
||||||
const fallback = 'Upload failed';
|
|
||||||
|
|
||||||
const axiosErrorWithResponse = (data: unknown, message = 'Request failed'): AxiosError => {
|
|
||||||
return new AxiosError(
|
|
||||||
message,
|
|
||||||
AxiosError.ERR_BAD_REQUEST,
|
|
||||||
{} as InternalAxiosRequestConfig,
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
statusText: 'Bad Request',
|
|
||||||
headers: {},
|
|
||||||
config: {} as InternalAxiosRequestConfig,
|
|
||||||
data
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('resolveAxiosErrorMessage', () => {
|
|
||||||
it('falls back for non-Axios errors', () => {
|
|
||||||
expect(resolveAxiosErrorMessage(new Error('network'), fallback)).toBe('network');
|
|
||||||
expect(resolveAxiosErrorMessage('oops', fallback)).toBe(fallback);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back when Axios error has no response', () => {
|
|
||||||
const error = new AxiosError('timeout', AxiosError.ETIMEDOUT);
|
|
||||||
|
|
||||||
expect(resolveAxiosErrorMessage(error, fallback)).toBe('timeout');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses string response data', () => {
|
|
||||||
const error = axiosErrorWithResponse('Rate limit exceeded');
|
|
||||||
|
|
||||||
expect(resolveAxiosErrorMessage(error, fallback)).toBe('Rate limit exceeded');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses object message string (Nest-style)', () => {
|
|
||||||
const error = axiosErrorWithResponse({ message: 'Invalid ciphertext' });
|
|
||||||
|
|
||||||
expect(resolveAxiosErrorMessage(error, fallback)).toBe('Invalid ciphertext');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses a single string from message array', () => {
|
|
||||||
const error = axiosErrorWithResponse({ message: ['iv must be valid base64'] });
|
|
||||||
|
|
||||||
expect(resolveAxiosErrorMessage(error, fallback)).toBe('iv must be valid base64');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('joins multiple strings in message array', () => {
|
|
||||||
const error = axiosErrorWithResponse({
|
|
||||||
message: ['expiresAt must be a date', 'maxViews must be a number']
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(resolveAxiosErrorMessage(error, fallback)).toBe(
|
|
||||||
'expiresAt must be a date, maxViews must be a number'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores non-string entries in message array', () => {
|
|
||||||
const error = axiosErrorWithResponse({
|
|
||||||
message: ['valid', 42, null, 'also valid']
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(resolveAxiosErrorMessage(error, fallback)).toBe('valid, also valid');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back when response data has no usable message', () => {
|
|
||||||
const error = axiosErrorWithResponse({ message: [] }, 'HTTP 400');
|
|
||||||
|
|
||||||
expect(resolveAxiosErrorMessage(error, fallback)).toBe('HTTP 400');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back when response data is an object without message', () => {
|
|
||||||
const error = axiosErrorWithResponse({ statusCode: 400 }, 'HTTP 400');
|
|
||||||
|
|
||||||
expect(resolveAxiosErrorMessage(error, fallback)).toBe('HTTP 400');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { isAxiosError } from 'axios';
|
|
||||||
import { resolveErrorMessage } from '@/utils/resolveErrorMessage';
|
|
||||||
|
|
||||||
const getAxiosErrorResponseMessage = (data: unknown): string | null => {
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data !== 'object' || data === null || !('message' in data)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { message } = data;
|
|
||||||
|
|
||||||
if (typeof message === 'string') {
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(message)) {
|
|
||||||
const texts = message.filter((item): item is string => typeof item === 'string');
|
|
||||||
|
|
||||||
if (texts.length === 1) {
|
|
||||||
return texts[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (texts.length > 1) {
|
|
||||||
return texts.join(', ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const resolveAxiosErrorMessage = (error: unknown, fallback: string): string => {
|
|
||||||
if (!isAxiosError(error) || !error.response) {
|
|
||||||
return resolveErrorMessage(error, fallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
return getAxiosErrorResponseMessage(error.response.data) ?? resolveErrorMessage(error, fallback);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildBbcodeImage } from '@/utils/share/buildBbcodeImage';
|
||||||
|
|
||||||
|
describe('buildBbcodeImage', () => {
|
||||||
|
it('wraps the image URL in BBCode img tags', () => {
|
||||||
|
expect(buildBbcodeImage('https://example.com/i/1/secret')).toBe(
|
||||||
|
'[img]https://example.com/i/1/secret[/img]'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const buildBbcodeImage = (imageUrl: string): string => `[img]${imageUrl}[/img]`;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildDeletePath } from '@/utils/share/buildDeletePath';
|
||||||
|
|
||||||
|
describe('buildDeletePath', () => {
|
||||||
|
it('builds delete route with encoded token', () => {
|
||||||
|
expect(buildDeletePath('upload-id', 'abc+def/token')).toBe('/d/upload-id?token=abc%2Bdef%2Ftoken');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const buildDeletePath = (uploadId: string, deleteToken: string): string =>
|
||||||
|
`/d/${uploadId}?token=${encodeURIComponent(deleteToken)}`;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildHotlinkViewerPath } from '@/utils/share/buildHotlinkViewerPath';
|
||||||
|
|
||||||
|
describe('buildHotlinkViewerPath', () => {
|
||||||
|
it('places upload id and secret in the viewer path', () => {
|
||||||
|
expect(buildHotlinkViewerPath('upload-uuid', 'url-safe-secret')).toBe('/h/upload-uuid/url-safe-secret');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const buildHotlinkViewerPath = (uploadId: string, secret: string): string => `/h/${uploadId}/${secret}`;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildHtmlImage } from '@/utils/share/buildHtmlImage';
|
||||||
|
|
||||||
|
describe('buildHtmlImage', () => {
|
||||||
|
it('builds an img tag with escaped alt', () => {
|
||||||
|
expect(buildHtmlImage('https://example.com/i/1/secret', 'photo.jpg')).toBe(
|
||||||
|
'<img src="https://example.com/i/1/secret" alt="photo.jpg" />'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes quotes and markup in alt text', () => {
|
||||||
|
expect(buildHtmlImage('https://example.com/i/1/secret', 'x" onerror="alert(1)')).toBe(
|
||||||
|
'<img src="https://example.com/i/1/secret" alt="x" onerror="alert(1)" />'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes special characters in src when present', () => {
|
||||||
|
expect(buildHtmlImage('https://example.com/i/1/"bad', 'image')).toBe(
|
||||||
|
'<img src="https://example.com/i/1/"bad" alt="image" />'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import escapeHtml from 'escape-html';
|
||||||
|
|
||||||
|
export const buildHtmlImage = (imageUrl: string, alt = 'image'): string =>
|
||||||
|
`<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(alt)}" />`;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildHtmlLink } from '@/utils/share/buildHtmlLink';
|
||||||
|
|
||||||
|
describe('buildHtmlLink', () => {
|
||||||
|
it('builds an anchor with escaped href and label', () => {
|
||||||
|
expect(buildHtmlLink('photo.jpg', 'https://example.com/v/1#key')).toBe(
|
||||||
|
'<a href="https://example.com/v/1#key">photo.jpg</a>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes HTML in link label', () => {
|
||||||
|
expect(buildHtmlLink('<script>alert(1)</script>', 'https://example.com/v/1')).toBe(
|
||||||
|
'<a href="https://example.com/v/1"><script>alert(1)</script></a>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes quotes in href', () => {
|
||||||
|
expect(buildHtmlLink('label', 'https://example.com/x" y')).toBe(
|
||||||
|
'<a href="https://example.com/x" y">label</a>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import escapeHtml from 'escape-html';
|
||||||
|
|
||||||
|
export const buildHtmlLink = (label: string, url: string): string =>
|
||||||
|
`<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildMarkdownImage } from '@/utils/share/buildMarkdownImage';
|
||||||
|
|
||||||
|
describe('buildMarkdownImage', () => {
|
||||||
|
it('builds a markdown image with alt and url', () => {
|
||||||
|
expect(buildMarkdownImage('https://example.com/i/1/secret', 'photo.jpg')).toBe(
|
||||||
|
''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default alt when omitted', () => {
|
||||||
|
expect(buildMarkdownImage('https://example.com/i/1/secret')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const buildMarkdownImage = (imageUrl: string, alt = 'image'): string => ``;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildMarkdownLink } from '@/utils/share/buildMarkdownLink';
|
||||||
|
|
||||||
|
describe('buildMarkdownLink', () => {
|
||||||
|
it('builds a markdown link with label and url', () => {
|
||||||
|
expect(buildMarkdownLink('photo.jpg', 'https://example.com/v/1#key')).toBe(
|
||||||
|
'[photo.jpg](https://example.com/v/1#key)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const buildMarkdownLink = (label: string, url: string): string => `[${label}](${url})`;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildSiteShareUrl } from '@/utils/share/buildSiteShareUrl';
|
||||||
|
|
||||||
|
describe('buildSiteShareUrl', () => {
|
||||||
|
it('appends path and hash to origin', () => {
|
||||||
|
expect(buildSiteShareUrl('https://example.com', '/v/abc#key')).toBe('https://example.com/v/abc#key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles path without hash', () => {
|
||||||
|
expect(buildSiteShareUrl('http://localhost:5173', '/i/id/secret')).toBe(
|
||||||
|
'http://localhost:5173/i/id/secret'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const buildSiteShareUrl = (origin: string, pathWithOptionalHash: string): string => {
|
||||||
|
const hashIndex = pathWithOptionalHash.indexOf('#');
|
||||||
|
const pathname = hashIndex >= 0 ? pathWithOptionalHash.slice(0, hashIndex) : pathWithOptionalHash;
|
||||||
|
const hash = hashIndex >= 0 ? pathWithOptionalHash.slice(hashIndex) : '';
|
||||||
|
|
||||||
|
return `${origin}${pathname}${hash}`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { config } from '@/config';
|
||||||
|
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
|
||||||
|
import { buildUploadedShareUrls } from '@/utils/share/buildUploadedShareUrls';
|
||||||
|
|
||||||
|
const { clearnet, onion } = config.shareOrigins;
|
||||||
|
|
||||||
|
const uploaded = (
|
||||||
|
mode: 'zk' | 'hotlink',
|
||||||
|
overrides: Partial<Extract<UploadQueueEntry, { status: 'uploaded' }>> = {}
|
||||||
|
): UploadQueueEntry => ({
|
||||||
|
status: 'uploaded',
|
||||||
|
message: 'Uploaded',
|
||||||
|
uploadId: 'upload-id',
|
||||||
|
deleteToken: 'delete-token',
|
||||||
|
urlSecret: 'url-secret',
|
||||||
|
mode,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildUploadedShareUrls', () => {
|
||||||
|
it('returns null unless the upload succeeded', () => {
|
||||||
|
const statuses = ['idle', 'queued', 'preparing', 'uploading', 'failed', 'skipped'] as const;
|
||||||
|
|
||||||
|
for (const status of statuses) {
|
||||||
|
expect(buildUploadedShareUrls({ status, message: status }, 'photo.jpg')).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds zero-knowledge viewer, delete, and clearnet text embeds', () => {
|
||||||
|
const urls = buildUploadedShareUrls(
|
||||||
|
uploaded('zk', { deleteToken: 'a+b/c', urlSecret: 'fragment_key' }),
|
||||||
|
'a <photo> & "shot"'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(urls).toEqual({
|
||||||
|
mode: 'zk',
|
||||||
|
viewer: {
|
||||||
|
clearnet: `${clearnet}/v/upload-id#fragment_key`,
|
||||||
|
onion: `${onion}/v/upload-id#fragment_key`
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
clearnet: `${clearnet}/d/upload-id?token=a%2Bb%2Fc`,
|
||||||
|
onion: `${onion}/d/upload-id?token=a%2Bb%2Fc`
|
||||||
|
},
|
||||||
|
markdownLink: `[a <photo> & "shot"](${clearnet}/v/upload-id#fragment_key)`,
|
||||||
|
htmlLink: `<a href="${clearnet}/v/upload-id#fragment_key">a <photo> & "shot"</a>`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds hotlink viewer, direct image, delete, and clearnet image embeds', () => {
|
||||||
|
const urls = buildUploadedShareUrls(
|
||||||
|
uploaded('hotlink', { deleteToken: 'a+b/c', urlSecret: 'path_secret' }),
|
||||||
|
'a <photo> & "shot"'
|
||||||
|
);
|
||||||
|
|
||||||
|
const directClearnet = `${clearnet}/i/upload-id/path_secret`;
|
||||||
|
|
||||||
|
expect(urls).toEqual({
|
||||||
|
mode: 'hotlink',
|
||||||
|
viewer: {
|
||||||
|
clearnet: `${clearnet}/h/upload-id/path_secret`,
|
||||||
|
onion: `${onion}/h/upload-id/path_secret`
|
||||||
|
},
|
||||||
|
direct: {
|
||||||
|
clearnet: directClearnet,
|
||||||
|
onion: `${onion}/i/upload-id/path_secret`
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
clearnet: `${clearnet}/d/upload-id?token=a%2Bb%2Fc`,
|
||||||
|
onion: `${onion}/d/upload-id?token=a%2Bb%2Fc`
|
||||||
|
},
|
||||||
|
bbcode: `[img]${directClearnet}[/img]`,
|
||||||
|
markdownImage: ``,
|
||||||
|
htmlImage: `<img src="${directClearnet}" alt="a <photo> & "shot"" />`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { UploadedShareUrls } from '@/types/share/UploadedShareUrls';
|
||||||
|
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
|
||||||
|
import { buildBbcodeImage } from '@/utils/share/buildBbcodeImage';
|
||||||
|
import { buildHtmlImage } from '@/utils/share/buildHtmlImage';
|
||||||
|
import { buildHtmlLink } from '@/utils/share/buildHtmlLink';
|
||||||
|
import { buildMarkdownImage } from '@/utils/share/buildMarkdownImage';
|
||||||
|
import { buildMarkdownLink } from '@/utils/share/buildMarkdownLink';
|
||||||
|
import { config } from '@/config';
|
||||||
|
import { buildDeletePath } from '@/utils/share/buildDeletePath';
|
||||||
|
import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath';
|
||||||
|
import { buildHotlinkViewerPath } from '@/utils/share/buildHotlinkViewerPath';
|
||||||
|
import { buildSiteShareUrl } from '@/utils/share/buildSiteShareUrl';
|
||||||
|
import { buildZkViewerPath } from '@/utils/share/buildZkViewerPath';
|
||||||
|
|
||||||
|
const { clearnet, onion } = config.shareOrigins;
|
||||||
|
|
||||||
|
export const buildUploadedShareUrls = (
|
||||||
|
upload: UploadQueueEntry,
|
||||||
|
fileLabel: string
|
||||||
|
): UploadedShareUrls | null => {
|
||||||
|
if (upload.status !== 'uploaded') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletePath = buildDeletePath(upload.uploadId, upload.deleteToken);
|
||||||
|
|
||||||
|
const deleteUrls = {
|
||||||
|
clearnet: buildSiteShareUrl(clearnet, deletePath),
|
||||||
|
onion: buildSiteShareUrl(onion, deletePath)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (upload.mode === 'hotlink') {
|
||||||
|
const viewerPath = buildHotlinkViewerPath(upload.uploadId, upload.urlSecret);
|
||||||
|
const directPath = buildHotlinkImagePath(upload.uploadId, upload.urlSecret);
|
||||||
|
|
||||||
|
const viewer = {
|
||||||
|
clearnet: buildSiteShareUrl(clearnet, viewerPath),
|
||||||
|
onion: buildSiteShareUrl(onion, viewerPath)
|
||||||
|
};
|
||||||
|
|
||||||
|
const direct = {
|
||||||
|
clearnet: buildSiteShareUrl(clearnet, directPath),
|
||||||
|
onion: buildSiteShareUrl(onion, directPath)
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'hotlink',
|
||||||
|
delete: deleteUrls,
|
||||||
|
viewer,
|
||||||
|
direct,
|
||||||
|
bbcode: buildBbcodeImage(direct.clearnet),
|
||||||
|
markdownImage: buildMarkdownImage(direct.clearnet, fileLabel),
|
||||||
|
htmlImage: buildHtmlImage(direct.clearnet, fileLabel)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewerPath = buildZkViewerPath(upload.uploadId, upload.urlSecret);
|
||||||
|
|
||||||
|
const viewer = {
|
||||||
|
clearnet: buildSiteShareUrl(clearnet, viewerPath),
|
||||||
|
onion: buildSiteShareUrl(onion, viewerPath)
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'zk',
|
||||||
|
delete: deleteUrls,
|
||||||
|
viewer,
|
||||||
|
markdownLink: buildMarkdownLink(fileLabel, viewer.clearnet),
|
||||||
|
htmlLink: buildHtmlLink(fileLabel, viewer.clearnet)
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
import { buildZkUploadPrepareOptionsFromBatchSettings } from '@/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings';
|
||||||
|
|
||||||
|
const baseSettings: UploadBatchSettingsForm = {
|
||||||
|
mode: 'zk',
|
||||||
|
expiryPreset: 'never',
|
||||||
|
maxViewsPreset: 'unlimited',
|
||||||
|
passwordEnabled: false,
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildZkUploadPrepareOptionsFromBatchSettings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-06-01T00:00:00.000Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds non-password options', () => {
|
||||||
|
const settings = {
|
||||||
|
...baseSettings,
|
||||||
|
expiryPreset: '24h' as const,
|
||||||
|
maxViewsPreset: '10' as const
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildZkUploadPrepareOptionsFromBatchSettings(settings)).toEqual({
|
||||||
|
expiresAt: '2026-06-02T00:00:00.000Z',
|
||||||
|
maxViews: 10,
|
||||||
|
passwordProtected: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds password-protected options', () => {
|
||||||
|
const settings = {
|
||||||
|
...baseSettings,
|
||||||
|
passwordEnabled: true,
|
||||||
|
password: 'batch-secret'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildZkUploadPrepareOptionsFromBatchSettings(settings)).toEqual({
|
||||||
|
expiresAt: null,
|
||||||
|
maxViews: null,
|
||||||
|
passwordProtected: true,
|
||||||
|
password: 'batch-secret'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
import type { ZkUploadPrepareOptions } from '@/types/upload/ZkUploadPrepareOptions';
|
||||||
|
import { buildUploadOptionsFromBatchSettings } from '@/utils/upload/buildUploadOptionsFromBatchSettings';
|
||||||
|
|
||||||
|
export const buildZkUploadPrepareOptionsFromBatchSettings = (
|
||||||
|
settings: UploadBatchSettingsForm
|
||||||
|
): ZkUploadPrepareOptions => {
|
||||||
|
const uploadOptions = buildUploadOptionsFromBatchSettings(settings);
|
||||||
|
|
||||||
|
if (settings.passwordEnabled) {
|
||||||
|
return {
|
||||||
|
...uploadOptions,
|
||||||
|
passwordProtected: true,
|
||||||
|
password: settings.password
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...uploadOptions,
|
||||||
|
passwordProtected: false
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
import { formatUploadBatchSettingsSummary } from '@/utils/upload/formatUploadBatchSettingsSummary';
|
||||||
|
|
||||||
|
const baseSettings: UploadBatchSettingsForm = {
|
||||||
|
mode: 'zk',
|
||||||
|
expiryPreset: 'never',
|
||||||
|
maxViewsPreset: 'unlimited',
|
||||||
|
passwordEnabled: false,
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('formatUploadBatchSettingsSummary', () => {
|
||||||
|
it('formats default zk settings', () => {
|
||||||
|
expect(formatUploadBatchSettingsSummary(baseSettings)).toBe(
|
||||||
|
'Zero knowledge · Never expires · Unlimited views'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes password when enabled', () => {
|
||||||
|
const settings = {
|
||||||
|
...baseSettings,
|
||||||
|
passwordEnabled: true,
|
||||||
|
password: 'secret'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(formatUploadBatchSettingsSummary(settings)).toBe(
|
||||||
|
'Zero knowledge · Never expires · Unlimited views · Password protected'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats hotlink mode', () => {
|
||||||
|
const settings = {
|
||||||
|
...baseSettings,
|
||||||
|
mode: 'hotlink' as const,
|
||||||
|
expiryPreset: '24h' as const,
|
||||||
|
maxViewsPreset: '10' as const
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(formatUploadBatchSettingsSummary(settings)).toBe('Hotlink · 24 hours · 10 views');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
|
||||||
|
const EXPIRY_LABELS: Record<UploadBatchSettingsForm['expiryPreset'], string> = {
|
||||||
|
never: 'Never expires',
|
||||||
|
'1h': '1 hour',
|
||||||
|
'24h': '24 hours',
|
||||||
|
'7d': '7 days',
|
||||||
|
'30d': '30 days'
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_VIEWS_LABELS: Record<UploadBatchSettingsForm['maxViewsPreset'], string> = {
|
||||||
|
unlimited: 'Unlimited views',
|
||||||
|
'1': 'Burn after read',
|
||||||
|
'10': '10 views',
|
||||||
|
'100': '100 views',
|
||||||
|
'1000': '1,000 views',
|
||||||
|
'10000': '10,000 views'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatUploadBatchSettingsSummary = (settings: UploadBatchSettingsForm): string => {
|
||||||
|
const modeLabel = settings.mode === 'zk' ? 'Zero knowledge' : 'Hotlink';
|
||||||
|
const parts = [modeLabel, EXPIRY_LABELS[settings.expiryPreset], MAX_VIEWS_LABELS[settings.maxViewsPreset]];
|
||||||
|
|
||||||
|
if (settings.mode === 'zk' && settings.passwordEnabled) {
|
||||||
|
parts.push('Password protected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' · ');
|
||||||
|
};
|
||||||
@@ -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 type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest';
|
||||||
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
|
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
|
||||||
import { encryptHotlinkUpload } from '@/utils/upload/encryptHotlinkUpload';
|
import { encryptHotlinkUpload } from '@/utils/upload/encryptHotlinkUpload';
|
||||||
@@ -7,7 +7,7 @@ export const prepareHotlinkUploadRequest = async (
|
|||||||
plaintext: Uint8Array,
|
plaintext: Uint8Array,
|
||||||
mimeType: string,
|
mimeType: string,
|
||||||
options: UploadOptionsRequest
|
options: UploadOptionsRequest
|
||||||
): Promise<{ request: CreateHotlinkUploadRequest; secret: string }> => {
|
): Promise<PreparedHotlinkUploadRequest> => {
|
||||||
const encrypted = await encryptHotlinkUpload(plaintext);
|
const encrypted = await encryptHotlinkUpload(plaintext);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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 type { ZkUploadPrepareOptions } from '@/types/upload/ZkUploadPrepareOptions';
|
||||||
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
|
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
|
||||||
import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url';
|
import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url';
|
||||||
@@ -9,7 +9,7 @@ import { wrapZkDek } from '@/utils/upload/wrapZkDek';
|
|||||||
export const prepareZkUploadRequest = async (
|
export const prepareZkUploadRequest = async (
|
||||||
plaintext: Uint8Array,
|
plaintext: Uint8Array,
|
||||||
options: ZkUploadPrepareOptions
|
options: ZkUploadPrepareOptions
|
||||||
): Promise<{ request: CreateZkUploadRequest; fragmentKey: string }> => {
|
): Promise<PreparedZkUploadRequest> => {
|
||||||
if (!options.passwordProtected) {
|
if (!options.passwordProtected) {
|
||||||
const { dek, iv, ciphertext } = await encryptZkUpload(plaintext);
|
const { dek, iv, ciphertext } = await encryptZkUpload(plaintext);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { resolveUploadBatchExpiresAt } from '@/utils/upload/resolveUploadBatchExpiresAt';
|
||||||
|
|
||||||
|
describe('resolveUploadBatchExpiresAt', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for never', () => {
|
||||||
|
expect(resolveUploadBatchExpiresAt('never')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds preset offsets from the reference time', () => {
|
||||||
|
expect(resolveUploadBatchExpiresAt('1h')).toBe('2026-01-01T13:00:00.000Z');
|
||||||
|
expect(resolveUploadBatchExpiresAt('24h')).toBe('2026-01-02T12:00:00.000Z');
|
||||||
|
expect(resolveUploadBatchExpiresAt('7d')).toBe('2026-01-08T12:00:00.000Z');
|
||||||
|
expect(resolveUploadBatchExpiresAt('30d')).toBe('2026-01-31T12:00:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type { UploadExpiryPreset } from '@/types/upload/UploadExpiryPreset';
|
||||||
|
import type { UploadExpiryOffset } from '@/types/upload/UploadExpiryOffset';
|
||||||
|
|
||||||
|
const EXPIRY_OFFSET: Record<Exclude<UploadExpiryPreset, 'never'>, UploadExpiryOffset> = {
|
||||||
|
'1h': { amount: 1, unit: 'hour' },
|
||||||
|
'24h': { amount: 24, unit: 'hour' },
|
||||||
|
'7d': { amount: 7, unit: 'day' },
|
||||||
|
'30d': { amount: 30, unit: 'day' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveUploadBatchExpiresAt = (preset: UploadExpiryPreset): string | null => {
|
||||||
|
if (preset === 'never') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { amount, unit } = EXPIRY_OFFSET[preset];
|
||||||
|
|
||||||
|
return dayjs().add(amount, unit).toISOString();
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveUploadBatchMaxViews } from '@/utils/upload/resolveUploadBatchMaxViews';
|
||||||
|
|
||||||
|
describe('resolveUploadBatchMaxViews', () => {
|
||||||
|
it('maps presets to API values', () => {
|
||||||
|
expect(resolveUploadBatchMaxViews('unlimited')).toBeNull();
|
||||||
|
expect(resolveUploadBatchMaxViews('1')).toBe(1);
|
||||||
|
expect(resolveUploadBatchMaxViews('10')).toBe(10);
|
||||||
|
expect(resolveUploadBatchMaxViews('100')).toBe(100);
|
||||||
|
expect(resolveUploadBatchMaxViews('1000')).toBe(1000);
|
||||||
|
expect(resolveUploadBatchMaxViews('10000')).toBe(10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { UploadMaxViewsPreset } from '@/types/upload/UploadMaxViewsPreset';
|
||||||
|
|
||||||
|
export const resolveUploadBatchMaxViews = (preset: UploadMaxViewsPreset): number | null => {
|
||||||
|
if (preset === 'unlimited') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseInt(preset, 10);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
import { validateUploadBatchSettings } from '@/utils/upload/validateUploadBatchSettings';
|
||||||
|
|
||||||
|
const baseSettings: UploadBatchSettingsForm = {
|
||||||
|
mode: 'zk',
|
||||||
|
expiryPreset: 'never',
|
||||||
|
maxViewsPreset: 'unlimited',
|
||||||
|
passwordEnabled: false,
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('validateUploadBatchSettings', () => {
|
||||||
|
it('allows hotlink mode without password', () => {
|
||||||
|
const settings = {
|
||||||
|
...baseSettings,
|
||||||
|
mode: 'hotlink' as const,
|
||||||
|
passwordEnabled: true,
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(validateUploadBatchSettings(settings)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires password when zk protection is enabled', () => {
|
||||||
|
const settings = {
|
||||||
|
...baseSettings,
|
||||||
|
passwordEnabled: true,
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(validateUploadBatchSettings(settings)).toBe('Enter a password or turn off password protection.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows zk upload when password is set', () => {
|
||||||
|
const settings = {
|
||||||
|
...baseSettings,
|
||||||
|
passwordEnabled: true,
|
||||||
|
password: 'secret'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(validateUploadBatchSettings(settings)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
|
||||||
|
export const validateUploadBatchSettings = (settings: UploadBatchSettingsForm): string | null => {
|
||||||
|
if (settings.mode !== 'zk' || !settings.passwordEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.password.length === 0) {
|
||||||
|
return 'Enter a password or turn off password protection.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -9,72 +9,92 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="flex flex-col gap-2">
|
||||||
<label
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
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"
|
<UploadDropzone
|
||||||
:class="loadingMetadata ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
|
ref="dropzoneRef"
|
||||||
>
|
:disabled="loadingMetadata || batchUploading"
|
||||||
Choose images
|
@pick="onFilesPicked"
|
||||||
<input
|
|
||||||
ref="fileInputRef"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
multiple
|
|
||||||
class="sr-only"
|
|
||||||
:disabled="loadingMetadata"
|
|
||||||
@change="onFileInputChange"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
|
|
||||||
<p v-if="selectionError" class="mt-2 text-sm text-destructive">{{ selectionError }}</p>
|
<button
|
||||||
|
v-if="!uploadConfirmed"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex shrink-0 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 sm:self-center"
|
||||||
|
:disabled="!canOpenConfigure"
|
||||||
|
@click="configureModalOpen = true"
|
||||||
|
>
|
||||||
|
Configure & upload
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul v-if="queueRows.length > 0" class="divide-y divide-border rounded-lg border border-border">
|
<p v-if="selectionError" class="text-sm text-destructive">{{ selectionError }}</p>
|
||||||
<li v-for="row in queueRows" :key="row.key">
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
<p v-if="confirmedBatchSummary" class="text-center text-xs text-muted-foreground">
|
||||||
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60"
|
{{ confirmedBatchSummary }}
|
||||||
:class="isFileSelected(row.file) ? 'bg-muted/80' : ''"
|
</p>
|
||||||
@click="selectedFile = row.file"
|
|
||||||
>
|
<UploadQueueList :rows="queueRows" :selected-file="selectedFile" @select="selectedFile = $event" />
|
||||||
<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">
|
<UploadShareResults
|
||||||
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
|
v-if="showShareLinks"
|
||||||
</span>
|
:file-name="selectedFile?.name ?? null"
|
||||||
<span class="shrink-0 text-xs" :class="statusTextClass(row.preview)">
|
:upload="selectedUpload"
|
||||||
{{ row.preview.message }}
|
/>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" />
|
<MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" />
|
||||||
|
|
||||||
|
<UploadConfigureModal
|
||||||
|
v-model:open="configureModalOpen"
|
||||||
|
v-model:settings="batchSettings"
|
||||||
|
:metadata-error-count="metadataErrorCount"
|
||||||
|
@upload="onBatchUpload"
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeMount, ref } from 'vue';
|
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 { config } from '@/config';
|
||||||
import { useExiftoolStore } from '@/stores/exiftool';
|
import { useExiftoolStore } from '@/stores/exiftool';
|
||||||
import { useMetadataStore } from '@/stores/metadata';
|
import { useMetadataStore } from '@/stores/metadata';
|
||||||
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
import { useUploadStore } from '@/stores/upload';
|
||||||
import type { PickedUploadAnalysis } from '@/types/upload/PickedUploadAnalysis';
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
|
||||||
|
import { analyzePickedUploadFiles } from '@/utils/upload/analyzePickedUploadFiles';
|
||||||
|
import { buildPickedUploadWarning } from '@/utils/upload/buildPickedUploadWarning';
|
||||||
|
import { formatUploadBatchSettingsSummary } from '@/utils/upload/formatUploadBatchSettingsSummary';
|
||||||
import prettyBytes from 'pretty-bytes';
|
import prettyBytes from 'pretty-bytes';
|
||||||
|
|
||||||
const { maxQueueSize, maxImageBytes } = config.validation.upload;
|
const { maxQueueSize, maxImageBytes } = config.validation.upload;
|
||||||
|
|
||||||
const exiftoolStore = useExiftoolStore();
|
const exiftoolStore = useExiftoolStore();
|
||||||
const metadataStore = useMetadataStore();
|
const metadataStore = useMetadataStore();
|
||||||
|
const uploadStore = useUploadStore();
|
||||||
|
|
||||||
const { initializeExiftool } = exiftoolStore;
|
const { initializeExiftool } = exiftoolStore;
|
||||||
const { makeKeyFromFile, getPreview, loadPreviews, clearAllPreviews } = metadataStore;
|
const { loadPreviews, clearAllPreviews } = metadataStore;
|
||||||
|
const { clearAllUploads, uploadBatch } = uploadStore;
|
||||||
|
|
||||||
const queuedFiles = ref<File[]>([]);
|
const queuedFiles = ref<File[]>([]);
|
||||||
const selectedFile = ref<File | null>(null);
|
const selectedFile = ref<File | null>(null);
|
||||||
const selectionError = ref<string | null>(null);
|
const selectionError = ref<string | null>(null);
|
||||||
const loadingMetadata = ref(false);
|
const loadingMetadata = ref(false);
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
const batchUploading = ref(false);
|
||||||
|
const dropzoneRef = ref<InstanceType<typeof UploadDropzone> | null>(null);
|
||||||
|
const batchSettings = ref<UploadBatchSettingsForm>({
|
||||||
|
mode: 'zk',
|
||||||
|
expiryPreset: 'never',
|
||||||
|
maxViewsPreset: 'unlimited',
|
||||||
|
passwordEnabled: false,
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
const configureModalOpen = ref(false);
|
||||||
|
const uploadConfirmed = ref(false);
|
||||||
|
const showShareLinks = ref(false);
|
||||||
|
const confirmedBatchSummary = ref<string | null>(null);
|
||||||
|
|
||||||
onBeforeMount(async () => {
|
onBeforeMount(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -87,36 +107,44 @@ onBeforeMount(async () => {
|
|||||||
const maxImageSizeFormatted = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 }));
|
const maxImageSizeFormatted = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 }));
|
||||||
|
|
||||||
const queueRows = computed(() =>
|
const queueRows = computed(() =>
|
||||||
queuedFiles.value.map(file => ({
|
queuedFiles.value.map(file => {
|
||||||
|
const key = makeKeyFromFile(file);
|
||||||
|
|
||||||
|
return {
|
||||||
file,
|
file,
|
||||||
key: makeKeyFromFile(file),
|
key,
|
||||||
preview: getPreview(file)
|
metadata: metadataStore.getPreview(file),
|
||||||
}))
|
upload: uploadStore.getUpload(file)
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedPreview = computed(() => getPreview(selectedFile.value));
|
const selectedPreview = computed(() => metadataStore.getPreview(selectedFile.value));
|
||||||
|
|
||||||
const isFileSelected = (file: File): boolean =>
|
const selectedUpload = computed(() => uploadStore.getUpload(selectedFile.value));
|
||||||
selectedFile.value !== null && makeKeyFromFile(selectedFile.value) === makeKeyFromFile(file);
|
|
||||||
|
|
||||||
const statusTextClass = (entry: MetadataPreviewEntry): string =>
|
const metadataErrorCount = computed(
|
||||||
entry.status === 'error' ? 'text-destructive' : 'text-muted-foreground';
|
() => queuedFiles.value.filter(file => metadataStore.getPreview(file).status === 'error').length
|
||||||
|
);
|
||||||
|
|
||||||
const onFileInputChange = async (event: Event): Promise<void> => {
|
const canOpenConfigure = computed(() => queuedFiles.value.length > 0 && !loadingMetadata.value);
|
||||||
if (loadingMetadata.value) {
|
|
||||||
|
const onFilesPicked = async (picked: File[]): Promise<void> => {
|
||||||
|
if (loadingMetadata.value || batchUploading.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = event.target as HTMLInputElement;
|
resetUploadSession();
|
||||||
const picked = input.files ? [...input.files] : [];
|
|
||||||
|
|
||||||
resetUploadSelection();
|
|
||||||
|
|
||||||
if (picked.length === 0) {
|
if (picked.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accepted, nonImageCount, oversizedImagesCount, queueOmittedImagesCount } = analyzePickedUploadFiles(picked);
|
const { accepted, nonImageCount, oversizedImagesCount, queueOmittedImagesCount } = analyzePickedUploadFiles(
|
||||||
|
picked,
|
||||||
|
maxImageBytes,
|
||||||
|
maxQueueSize
|
||||||
|
);
|
||||||
|
|
||||||
if (nonImageCount === picked.length) {
|
if (nonImageCount === picked.length) {
|
||||||
selectionError.value = 'Only image files can be added.';
|
selectionError.value = 'Only image files can be added.';
|
||||||
@@ -130,7 +158,10 @@ const onFileInputChange = async (event: Event): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectionError.value = buildPickedUploadWarning({ nonImageCount, oversizedImagesCount, queueOmittedImagesCount });
|
selectionError.value = buildPickedUploadWarning(
|
||||||
|
{ nonImageCount, oversizedImagesCount, queueOmittedImagesCount },
|
||||||
|
maxQueueSize
|
||||||
|
);
|
||||||
|
|
||||||
queuedFiles.value = accepted;
|
queuedFiles.value = accepted;
|
||||||
selectedFile.value = accepted[0] ?? null;
|
selectedFile.value = accepted[0] ?? null;
|
||||||
@@ -146,50 +177,35 @@ const onFileInputChange = async (event: Event): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetUploadSelection = (): void => {
|
const resetUploadSession = (): void => {
|
||||||
selectionError.value = null;
|
selectionError.value = null;
|
||||||
clearAllPreviews();
|
clearAllPreviews();
|
||||||
queuedFiles.value = [];
|
queuedFiles.value = [];
|
||||||
selectedFile.value = null;
|
selectedFile.value = null;
|
||||||
|
clearAllUploads();
|
||||||
if (fileInputRef.value) {
|
dropzoneRef.value?.clearInput();
|
||||||
fileInputRef.value.value = '';
|
configureModalOpen.value = false;
|
||||||
}
|
uploadConfirmed.value = false;
|
||||||
|
showShareLinks.value = false;
|
||||||
|
confirmedBatchSummary.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const analyzePickedUploadFiles = (picked: File[]): PickedUploadAnalysis => {
|
const onBatchUpload = async (uploadDespiteMetadataFailure?: boolean): Promise<void> => {
|
||||||
const images = picked.filter(file => file.type.startsWith('image/'));
|
const settings = batchSettings.value;
|
||||||
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 {
|
confirmedBatchSummary.value = formatUploadBatchSettingsSummary(settings);
|
||||||
accepted,
|
uploadConfirmed.value = true;
|
||||||
nonImageCount,
|
configureModalOpen.value = false;
|
||||||
oversizedImagesCount,
|
|
||||||
queueOmittedImagesCount
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildPickedUploadWarning = (
|
batchUploading.value = true;
|
||||||
summary: Pick<PickedUploadAnalysis, 'nonImageCount' | 'oversizedImagesCount' | 'queueOmittedImagesCount'>
|
|
||||||
): string | null => {
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (summary.nonImageCount > 0) {
|
try {
|
||||||
parts.push(`${summary.nonImageCount} non-image file(s) were skipped.`);
|
await uploadBatch(queuedFiles.value, settings, uploadDespiteMetadataFailure);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
batchUploading.value = false;
|
||||||
|
showShareLinks.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Vendored
+3
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_BASE_URL: string;
|
readonly VITE_API_BASE_URL: string;
|
||||||
|
readonly VITE_CLEARNET_SCHEME: string;
|
||||||
readonly VITE_CLEARNET_DOMAIN: string;
|
readonly VITE_CLEARNET_DOMAIN: string;
|
||||||
readonly VITE_ONION_HOSTNAME: 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_UPLOAD_MAX_QUEUE_SIZE: string;
|
||||||
readonly VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX: string;
|
readonly VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX: string;
|
||||||
readonly VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES: string;
|
readonly VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES: string;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import Components from 'unplugin-vue-components/vite';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -12,5 +13,11 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, 'src')
|
'@': path.resolve(__dirname, 'src')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [vue(), tailwindcss()]
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
Components({
|
||||||
|
dts: 'src/components.d.ts'
|
||||||
|
}),
|
||||||
|
tailwindcss()
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user