Slave/upload ui #8
+5
-4
@@ -20,11 +20,9 @@ CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
ALTCHA_ENABLED=true
|
||||
ALTCHA_HMAC_KEY=change-me-use-long-random-secret
|
||||
ALTCHA_MAX_NUMBER=50000
|
||||
ALTCHA_MAX_NUMBER=250000
|
||||
ALTCHA_CHALLENGE_TTL_MS=600000
|
||||
|
||||
NEVER_MODE_INACTIVITY_DAYS=365
|
||||
|
||||
THROTTLE_TTL_MS=60000
|
||||
THROTTLE_LIMIT=1000
|
||||
THROTTLE_UPLOAD_TTL_MS=60000
|
||||
@@ -35,8 +33,11 @@ VALIDATION_UPLOAD_MAX_IMAGE_BYTES=10485760 # 10MiB
|
||||
VALIDATION_UPLOAD_JSON_BODY_PADDING_BYTES=2097152 # 2MiB
|
||||
|
||||
VITE_API_BASE_URL=http://localhost:3000/api
|
||||
VITE_CLEARNET_SCHEME=http
|
||||
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_VALIDATION_UPLOAD_MAX_VIEWS_MAX=1000000
|
||||
VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES=10485760 # 10MiB
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine
|
||||
FROM node:22.23.4-alpine
|
||||
|
||||
WORKDIR /backend
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ const validConfig = (overrides: Record<string, unknown> = {}): Record<string, un
|
||||
ALTCHA_HMAC_KEY: 'secret',
|
||||
ALTCHA_MAX_NUMBER: 50000,
|
||||
ALTCHA_CHALLENGE_TTL_MS: 600_000,
|
||||
NEVER_MODE_INACTIVITY_DAYS: 365,
|
||||
THROTTLE_TTL_MS: 60000,
|
||||
THROTTLE_LIMIT: 1000,
|
||||
THROTTLE_UPLOAD_TTL_MS: 60000,
|
||||
|
||||
@@ -65,12 +65,6 @@ class EnvironmentVariables {
|
||||
@Max(3_600_000)
|
||||
ALTCHA_CHALLENGE_TTL_MS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(3650)
|
||||
NEVER_MODE_INACTIVITY_DAYS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1000)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine
|
||||
FROM node:22.23.4-alpine
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
|
||||
Generated
+366
@@ -11,7 +11,10 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@uswriting/exiftool": "1.0.9",
|
||||
"altcha-lib": "^2.7.0",
|
||||
"axios": "^1.20.0",
|
||||
"dayjs": "^1.11.23",
|
||||
"escape-html": "^1.0.3",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"pinia": "^4.0.3",
|
||||
"pretty-bytes": "^7.2.0",
|
||||
@@ -20,10 +23,12 @@
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/escape-html": "^1.0.4",
|
||||
"@types/node": "^22.10.7",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.0.4",
|
||||
"vitest": "^3.2.7",
|
||||
"vue-tsc": "^3.2.6"
|
||||
@@ -1714,6 +1719,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.9",
|
||||
"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": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
@@ -2056,6 +2081,75 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/altcha-lib": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/altcha-lib/-/altcha-lib-2.7.0.tgz",
|
||||
"integrity": "sha512-gnysjfIpeT6k3RLaWRd7xVvfs8U0a5DHIS95Xgwn8znKq5uIj6c/oTH8DtcdyJS/ZV3I5kYpyurvmJFb0eFWvA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"altcha-lib": "bin/cli.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fastify/cookie": "^11.0.0",
|
||||
"@fastify/cors": "^11.0.0",
|
||||
"@fastify/formbody": "^8.0.0 || ^9.0.0",
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"@nestjs/platform-fastify": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"@sveltejs/kit": "^2.0.0 || ^3.0.0",
|
||||
"cookie-parser": "^1.0.0",
|
||||
"cors": "^2.0.0",
|
||||
"express": "^4.0.0 || ^5.0.0",
|
||||
"fastify": "^5.0.0",
|
||||
"h3": "^2.0.0",
|
||||
"hono": "^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@fastify/cookie": {
|
||||
"optional": true
|
||||
},
|
||||
"@fastify/cors": {
|
||||
"optional": true
|
||||
},
|
||||
"@fastify/formbody": {
|
||||
"optional": true
|
||||
},
|
||||
"@nestjs/common": {
|
||||
"optional": true
|
||||
},
|
||||
"@nestjs/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@nestjs/platform-express": {
|
||||
"optional": true
|
||||
},
|
||||
"@nestjs/platform-fastify": {
|
||||
"optional": true
|
||||
},
|
||||
"@sveltejs/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"cookie-parser": {
|
||||
"optional": true
|
||||
},
|
||||
"cors": {
|
||||
"optional": true
|
||||
},
|
||||
"express": {
|
||||
"optional": true
|
||||
},
|
||||
"fastify": {
|
||||
"optional": true
|
||||
},
|
||||
"h3": {
|
||||
"optional": true
|
||||
},
|
||||
"hono": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -2144,6 +2238,22 @@
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -2156,12 +2266,25 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz",
|
||||
"integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.23",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
|
||||
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2340,6 +2463,12 @@
|
||||
"@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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
@@ -2356,6 +2485,13 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz",
|
||||
"integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -2817,6 +2953,24 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/local-pkg": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
|
||||
"integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mlly": "^1.7.4",
|
||||
"pkg-types": "^2.3.0",
|
||||
"quansync": "^0.2.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
@@ -2863,6 +3017,38 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
|
||||
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.16.0",
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^1.3.1",
|
||||
"ufo": "^1.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly/node_modules/confbox": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mlly/node_modules/pkg-types": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.1.8",
|
||||
"mlly": "^1.7.4",
|
||||
"pathe": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2900,6 +3086,20 @@
|
||||
"integrity": "sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
|
||||
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
@@ -2974,6 +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": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
@@ -3023,6 +3235,37 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
|
||||
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz",
|
||||
@@ -3238,6 +3481,13 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
|
||||
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
@@ -3245,6 +3495,115 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.4.0.tgz",
|
||||
"integrity": "sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"picomatch": "^4.0.7",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@farmfe/core": "*",
|
||||
"@rsbuild/core": "*",
|
||||
"@rspack/core": "*",
|
||||
"bun-types-no-globals": "*",
|
||||
"esbuild": "*",
|
||||
"rolldown": "*",
|
||||
"rollup": "*",
|
||||
"unloader": "*",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@farmfe/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@rsbuild/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@rspack/core": {
|
||||
"optional": true
|
||||
},
|
||||
"bun-types-no-globals": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"rolldown": {
|
||||
"optional": true
|
||||
},
|
||||
"rollup": {
|
||||
"optional": true
|
||||
},
|
||||
"unloader": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
},
|
||||
"webpack": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-utils": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz",
|
||||
"integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-vue-components": {
|
||||
"version": "32.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz",
|
||||
"integrity": "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"local-pkg": "^1.2.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"mlly": "^1.8.2",
|
||||
"obug": "^2.1.1",
|
||||
"picomatch": "^4.0.4",
|
||||
"tinyglobby": "^0.2.16",
|
||||
"unplugin": "^3.0.0",
|
||||
"unplugin-utils": "^0.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": "^3.2.2 || ^4.0.0",
|
||||
"vue": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz",
|
||||
@@ -3671,6 +4030,13 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@uswriting/exiftool": "1.0.9",
|
||||
"altcha-lib": "^2.7.0",
|
||||
"axios": "^1.20.0",
|
||||
"dayjs": "^1.11.23",
|
||||
"escape-html": "^1.0.3",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"pinia": "^4.0.3",
|
||||
"pretty-bytes": "^7.2.0",
|
||||
@@ -22,10 +25,12 @@
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/escape-html": "^1.0.4",
|
||||
"@types/node": "^22.10.7",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.0.4",
|
||||
"vitest": "^3.2.7",
|
||||
"vue-tsc": "^3.2.6"
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-success: var(--success);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
@@ -40,6 +41,7 @@
|
||||
--accent: oklch(0.94 0.02 260);
|
||||
--accent-foreground: oklch(0.28 0.03 260);
|
||||
--destructive: oklch(0.55 0.2 25);
|
||||
--success: oklch(0.45 0.14 155);
|
||||
--border: oklch(0.9 0.01 260);
|
||||
--input: oklch(0.9 0.01 260);
|
||||
--ring: oklch(0.42 0.08 260);
|
||||
@@ -59,6 +61,7 @@
|
||||
--accent: oklch(0.28 0.03 260);
|
||||
--accent-foreground: oklch(0.96 0.01 260);
|
||||
--destructive: oklch(0.62 0.2 25);
|
||||
--success: oklch(0.68 0.14 155);
|
||||
--border: oklch(0.32 0.02 260);
|
||||
--input: oklch(0.32 0.02 260);
|
||||
--ring: oklch(0.72 0.1 260);
|
||||
|
||||
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>
|
||||
<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">
|
||||
<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>
|
||||
</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 its metadata.</p>
|
||||
|
||||
<p v-else-if="entry.status === 'error'" class="text-sm text-destructive">
|
||||
{{ entry.message }}
|
||||
</p>
|
||||
<template v-else-if="entry.status === 'error'">
|
||||
<p class="text-sm text-destructive">{{ entry.message }}</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'">
|
||||
<div
|
||||
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>
|
||||
<InlineTabList v-model="activeTab" :options="metadataTabOptions" />
|
||||
|
||||
<pre
|
||||
role="tabpanel"
|
||||
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"
|
||||
>{{ activeTab === 'original' ? entry.output : entry.afterStripOutput }}</pre
|
||||
>
|
||||
<MetadataOutputPanel>
|
||||
{{ activeTab === 'original' ? entry.output : entry.afterStripOutput }}
|
||||
</MetadataOutputPanel>
|
||||
</template>
|
||||
|
||||
<p v-else class="text-sm text-muted-foreground">{{ entry.message }}</p>
|
||||
@@ -64,6 +31,12 @@
|
||||
import { ref, watch, type PropType } from 'vue';
|
||||
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
||||
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({
|
||||
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 isEnabled = (key: string): boolean => env(key) === 'true';
|
||||
|
||||
export const config = {
|
||||
baseUrl: env('VITE_API_BASE_URL'),
|
||||
clearnetDomain: env('VITE_CLEARNET_DOMAIN'),
|
||||
onionHostname: env('VITE_ONION_HOSTNAME'),
|
||||
shareOrigins: {
|
||||
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: {
|
||||
upload: {
|
||||
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 { useExiftoolStore } from '@/stores/exiftool';
|
||||
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
||||
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
|
||||
|
||||
const EXIFTOOL_PREPARE_ERROR_MESSAGE = 'Could not prepare ExifTool';
|
||||
const EXIFTOOL_INITIALIZING_MESSAGE = 'Initializing ExifTool';
|
||||
const METADATA_LOADING_MESSAGE = 'Loading metadata';
|
||||
const METADATA_IDLE_MESSAGE = 'Waiting to load metadata...';
|
||||
const EXIFTOOL_INITIALIZING_MESSAGE = 'Initializing ExifTool...';
|
||||
const METADATA_PROCESSING_MESSAGE = 'Stripping metadata...';
|
||||
const METADATA_READY_MESSAGE = 'Metadata stripped';
|
||||
const METADATA_LOAD_ERROR_MESSAGE = 'Could not load image metadata';
|
||||
const METADATA_STRIP_ERROR_MESSAGE = 'Could not strip image metadata';
|
||||
const METADATA_IDLE_MESSAGE = 'Waiting to load metadata';
|
||||
const METADATA_READY_MESSAGE = 'Metadata stripped';
|
||||
const EXIFTOOL_PREPARE_ERROR_MESSAGE = 'Could not prepare ExifTool';
|
||||
|
||||
const METADATA_IDLE_PREVIEW: MetadataPreviewEntry = {
|
||||
status: 'idle',
|
||||
message: METADATA_IDLE_MESSAGE
|
||||
};
|
||||
|
||||
const METADATA_LOADING_PREVIEW: MetadataPreviewEntry = {
|
||||
status: 'loading',
|
||||
message: METADATA_LOADING_MESSAGE
|
||||
const METADATA_LOAD_ERROR_PREVIEW: MetadataPreviewEntry = {
|
||||
status: 'error',
|
||||
message: METADATA_LOAD_ERROR_MESSAGE
|
||||
};
|
||||
|
||||
const EXIFTOOL_PREPARE_ERROR_PREVIEW: MetadataPreviewEntry = {
|
||||
status: 'error',
|
||||
message: EXIFTOOL_PREPARE_ERROR_MESSAGE
|
||||
};
|
||||
|
||||
export const useMetadataStore = defineStore('metadata', () => {
|
||||
@@ -30,7 +36,13 @@ export const useMetadataStore = defineStore('metadata', () => {
|
||||
const { initStatus: exiftoolInitStatus } = storeToRefs(exiftoolStore);
|
||||
const { initializeExiftool, readMetadata, stripAllMetadata } = exiftoolStore;
|
||||
|
||||
const makeKeyFromFile = (file: File): string => `${file.name}\0${file.size}\0${file.lastModified}`;
|
||||
const setPreviewEntry = (key: string, entry: MetadataPreviewEntry): void => {
|
||||
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
|
||||
};
|
||||
|
||||
const setStrippedBlob = (key: string, blob: Blob): void => {
|
||||
strippedBlobByKey.value = { ...strippedBlobByKey.value, [key]: blob };
|
||||
};
|
||||
|
||||
const getPreview = (file?: File | null): MetadataPreviewEntry => {
|
||||
if (!file) {
|
||||
@@ -56,48 +68,27 @@ export const useMetadataStore = defineStore('metadata', () => {
|
||||
const key = makeKeyFromFile(file);
|
||||
|
||||
if (exiftoolInitStatus.value === 'error') {
|
||||
const entry: MetadataPreviewEntry = {
|
||||
status: 'error',
|
||||
message: EXIFTOOL_PREPARE_ERROR_MESSAGE
|
||||
};
|
||||
setPreviewEntry(key, EXIFTOOL_PREPARE_ERROR_PREVIEW);
|
||||
|
||||
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
|
||||
|
||||
return entry;
|
||||
return EXIFTOOL_PREPARE_ERROR_PREVIEW;
|
||||
}
|
||||
|
||||
if (exiftoolInitStatus.value === 'ready') {
|
||||
previewsByKey.value = {
|
||||
...previewsByKey.value,
|
||||
[key]: METADATA_LOADING_PREVIEW
|
||||
};
|
||||
} else {
|
||||
previewsByKey.value = {
|
||||
...previewsByKey.value,
|
||||
[key]: { status: 'pending', message: EXIFTOOL_INITIALIZING_MESSAGE }
|
||||
};
|
||||
if (exiftoolInitStatus.value !== 'ready') {
|
||||
setPreviewEntry(key, { status: 'initializing', message: EXIFTOOL_INITIALIZING_MESSAGE });
|
||||
|
||||
try {
|
||||
await initializeExiftool();
|
||||
|
||||
previewsByKey.value = {
|
||||
...previewsByKey.value,
|
||||
[key]: METADATA_LOADING_PREVIEW
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[metadata] ExifTool init failed', { file: file.name, error });
|
||||
|
||||
const entry: MetadataPreviewEntry = {
|
||||
status: 'error',
|
||||
message: EXIFTOOL_PREPARE_ERROR_MESSAGE
|
||||
};
|
||||
setPreviewEntry(key, EXIFTOOL_PREPARE_ERROR_PREVIEW);
|
||||
|
||||
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
|
||||
|
||||
return entry;
|
||||
return EXIFTOOL_PREPARE_ERROR_PREVIEW;
|
||||
}
|
||||
}
|
||||
|
||||
setPreviewEntry(key, { status: 'processing', message: METADATA_PROCESSING_MESSAGE });
|
||||
|
||||
let output: string;
|
||||
|
||||
try {
|
||||
@@ -105,14 +96,9 @@ export const useMetadataStore = defineStore('metadata', () => {
|
||||
} catch (error) {
|
||||
console.error('[metadata] read failed', { file: file.name, error });
|
||||
|
||||
const entry: MetadataPreviewEntry = {
|
||||
status: 'error',
|
||||
message: METADATA_LOAD_ERROR_MESSAGE
|
||||
};
|
||||
setPreviewEntry(key, METADATA_LOAD_ERROR_PREVIEW);
|
||||
|
||||
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
|
||||
|
||||
return entry;
|
||||
return METADATA_LOAD_ERROR_PREVIEW;
|
||||
}
|
||||
|
||||
let strippedBlob: Blob;
|
||||
@@ -129,10 +115,11 @@ export const useMetadataStore = defineStore('metadata', () => {
|
||||
|
||||
const entry: MetadataPreviewEntry = {
|
||||
status: 'error',
|
||||
message: METADATA_STRIP_ERROR_MESSAGE
|
||||
message: METADATA_STRIP_ERROR_MESSAGE,
|
||||
output
|
||||
};
|
||||
|
||||
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
|
||||
setPreviewEntry(key, entry);
|
||||
|
||||
return entry;
|
||||
}
|
||||
@@ -149,17 +136,12 @@ export const useMetadataStore = defineStore('metadata', () => {
|
||||
} catch (error) {
|
||||
console.error('[metadata] read after strip failed', { file: file.name, error });
|
||||
|
||||
const entry: MetadataPreviewEntry = {
|
||||
status: 'error',
|
||||
message: METADATA_LOAD_ERROR_MESSAGE
|
||||
};
|
||||
setPreviewEntry(key, METADATA_LOAD_ERROR_PREVIEW);
|
||||
|
||||
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
|
||||
|
||||
return entry;
|
||||
return METADATA_LOAD_ERROR_PREVIEW;
|
||||
}
|
||||
|
||||
strippedBlobByKey.value = { ...strippedBlobByKey.value, [key]: strippedBlob };
|
||||
setStrippedBlob(key, strippedBlob);
|
||||
|
||||
const entry: MetadataPreviewEntry = {
|
||||
status: 'ready',
|
||||
@@ -168,13 +150,22 @@ export const useMetadataStore = defineStore('metadata', () => {
|
||||
afterStripOutput
|
||||
};
|
||||
|
||||
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
|
||||
setPreviewEntry(key, entry);
|
||||
|
||||
return entry;
|
||||
};
|
||||
|
||||
const loadPreviews = (files: readonly File[]): Promise<MetadataPreviewEntry[]> =>
|
||||
Promise.all(files.map(file => loadPreview(file)));
|
||||
const loadPreviews = async (files: readonly File[]): Promise<MetadataPreviewEntry[]> => {
|
||||
const results: MetadataPreviewEntry[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const result = await loadPreview(file);
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const clearAllPreviews = (): void => {
|
||||
previewsByKey.value = {};
|
||||
@@ -184,7 +175,6 @@ export const useMetadataStore = defineStore('metadata', () => {
|
||||
return {
|
||||
previewsByKey,
|
||||
strippedBlobByKey,
|
||||
makeKeyFromFile,
|
||||
getPreview,
|
||||
getStrippedBlob,
|
||||
loadPreview,
|
||||
|
||||
@@ -1,12 +1,86 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { ALTCHA_REQUEST_HEADER } from '@/consts/altchaRequestHeader';
|
||||
import { api } from '@/plugins/axios';
|
||||
import { useAltchaStore } from '@/stores/altcha';
|
||||
import { useMetadataStore } from '@/stores/metadata';
|
||||
import type { CreateHotlinkUploadRequest } from '@/types/upload/CreateHotlinkUploadRequest';
|
||||
import type { CreateZkUploadRequest } from '@/types/upload/CreateZkUploadRequest';
|
||||
import type { CreatedUploadResult } from '@/types/upload/CreatedUploadResult';
|
||||
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
|
||||
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
|
||||
import { buildUploadOptionsFromBatchSettings } from '@/utils/upload/buildUploadOptionsFromBatchSettings';
|
||||
import { buildZkUploadPrepareOptionsFromBatchSettings } from '@/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings';
|
||||
import { prepareHotlinkUploadRequest } from '@/utils/upload/prepareHotlinkUploadRequest';
|
||||
import { prepareZkUploadRequest } from '@/utils/upload/prepareZkUploadRequest';
|
||||
import type { PreparedZkUploadRequest } from '@/types/upload/PreparedZkUploadRequest';
|
||||
import type { PreparedHotlinkUploadRequest } from '@/types/upload/PreparedHotlinkUploadRequest';
|
||||
|
||||
const UPLOAD_IDLE_MESSAGE = 'Waiting to upload...';
|
||||
const UPLOAD_QUEUED_MESSAGE = 'Upload queued';
|
||||
const UPLOAD_PREPARING_MESSAGE = 'Preparing upload...';
|
||||
const UPLOAD_UPLOADING_MESSAGE = 'Uploading...';
|
||||
const UPLOAD_UPLOADED_MESSAGE = 'Uploaded';
|
||||
const UPLOAD_READ_ERROR_MESSAGE = 'Could not read image';
|
||||
const UPLOAD_PROOF_OF_WORK_ERROR_MESSAGE = 'Proof of work failed';
|
||||
const UPLOAD_FAILED_MESSAGE = 'Upload failed';
|
||||
const UPLOAD_SKIPPED_METADATA_MESSAGE = 'Metadata not ready';
|
||||
const UPLOAD_STRIPPED_MISSING_MESSAGE = 'Stripped image missing';
|
||||
const UPLOAD_UNKNOWN_MODE_MESSAGE = 'Unknown share mode';
|
||||
|
||||
const UPLOAD_IDLE_ENTRY: UploadQueueEntry = {
|
||||
status: 'idle',
|
||||
message: UPLOAD_IDLE_MESSAGE
|
||||
};
|
||||
|
||||
const UPLOAD_QUEUED_ENTRY: UploadQueueEntry = {
|
||||
status: 'queued',
|
||||
message: UPLOAD_QUEUED_MESSAGE
|
||||
};
|
||||
|
||||
const UPLOAD_UPLOADING_ENTRY: UploadQueueEntry = {
|
||||
status: 'uploading',
|
||||
message: UPLOAD_UPLOADING_MESSAGE
|
||||
};
|
||||
|
||||
const UPLOAD_FAILED_ENTRY: UploadQueueEntry = {
|
||||
status: 'failed',
|
||||
message: UPLOAD_FAILED_MESSAGE
|
||||
};
|
||||
|
||||
const UPLOAD_STRIPPED_MISSING_ENTRY: UploadQueueEntry = {
|
||||
status: 'failed',
|
||||
message: UPLOAD_STRIPPED_MISSING_MESSAGE
|
||||
};
|
||||
|
||||
export const useUploadStore = defineStore('upload', () => {
|
||||
const createZkUpload = async (body: CreateZkUploadRequest, altcha?: string): Promise<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, {
|
||||
headers: getUploadRequestHeaders(altcha)
|
||||
});
|
||||
@@ -14,10 +88,7 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
return data;
|
||||
};
|
||||
|
||||
const createHotlinkUpload = async (
|
||||
body: CreateHotlinkUploadRequest,
|
||||
altcha?: string
|
||||
): Promise<CreatedUploadResult> => {
|
||||
const uploadHotlink = async (body: CreateHotlinkUploadRequest, altcha?: string): Promise<CreatedUploadResult> => {
|
||||
const { data } = await api.post<CreatedUploadResult>('/uploads/hotlink', body, {
|
||||
headers: getUploadRequestHeaders(altcha)
|
||||
});
|
||||
@@ -27,8 +98,165 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
|
||||
const getUploadRequestHeaders = (altcha?: string) => (altcha ? { [ALTCHA_REQUEST_HEADER]: altcha } : undefined);
|
||||
|
||||
const uploadBatch = async (
|
||||
files: readonly File[],
|
||||
settings: UploadBatchSettingsForm,
|
||||
uploadDespiteMetadataFailure = false
|
||||
): Promise<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 {
|
||||
createZkUpload,
|
||||
createHotlinkUpload
|
||||
uploadByKey,
|
||||
getUpload,
|
||||
clearAllUploads,
|
||||
uploadBatch,
|
||||
uploadZk,
|
||||
uploadHotlink
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type MetadataPreviewEntry =
|
||||
| { status: 'idle'; message: string }
|
||||
| { status: 'pending'; message: string }
|
||||
| { status: 'loading'; message: string }
|
||||
| { status: 'initializing'; message: string }
|
||||
| { status: 'processing'; message: string }
|
||||
| { status: 'ready'; message: string; output: string; afterStripOutput: string }
|
||||
| { status: 'error'; message: string };
|
||||
| { status: 'error'; message: string; output?: string };
|
||||
|
||||
@@ -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 { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
|
||||
import { encryptHotlinkUpload } from '@/utils/upload/encryptHotlinkUpload';
|
||||
@@ -7,7 +7,7 @@ export const prepareHotlinkUploadRequest = async (
|
||||
plaintext: Uint8Array,
|
||||
mimeType: string,
|
||||
options: UploadOptionsRequest
|
||||
): Promise<{ request: CreateHotlinkUploadRequest; secret: string }> => {
|
||||
): Promise<PreparedHotlinkUploadRequest> => {
|
||||
const encrypted = await encryptHotlinkUpload(plaintext);
|
||||
|
||||
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 { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
|
||||
import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url';
|
||||
@@ -9,7 +9,7 @@ import { wrapZkDek } from '@/utils/upload/wrapZkDek';
|
||||
export const prepareZkUploadRequest = async (
|
||||
plaintext: Uint8Array,
|
||||
options: ZkUploadPrepareOptions
|
||||
): Promise<{ request: CreateZkUploadRequest; fragmentKey: string }> => {
|
||||
): Promise<PreparedZkUploadRequest> => {
|
||||
if (!options.passwordProtected) {
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90"
|
||||
:class="loadingMetadata ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
|
||||
>
|
||||
Choose images
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="sr-only"
|
||||
:disabled="loadingMetadata"
|
||||
@change="onFileInputChange"
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<UploadDropzone
|
||||
ref="dropzoneRef"
|
||||
:disabled="loadingMetadata || batchUploading"
|
||||
@pick="onFilesPicked"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<p v-if="selectionError" class="text-sm text-destructive">{{ selectionError }}</p>
|
||||
</div>
|
||||
|
||||
<ul v-if="queueRows.length > 0" class="divide-y divide-border rounded-lg border border-border">
|
||||
<li v-for="row in queueRows" :key="row.key">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60"
|
||||
:class="isFileSelected(row.file) ? 'bg-muted/80' : ''"
|
||||
@click="selectedFile = row.file"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ row.file.name }}</span>
|
||||
<span class="shrink-0 text-xs text-muted-foreground">
|
||||
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
|
||||
</span>
|
||||
<span class="shrink-0 text-xs" :class="statusTextClass(row.preview)">
|
||||
{{ row.preview.message }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="confirmedBatchSummary" class="text-center text-xs text-muted-foreground">
|
||||
{{ confirmedBatchSummary }}
|
||||
</p>
|
||||
|
||||
<UploadQueueList :rows="queueRows" :selected-file="selectedFile" @select="selectedFile = $event" />
|
||||
|
||||
<UploadShareResults
|
||||
v-if="showShareLinks"
|
||||
:file-name="selectedFile?.name ?? null"
|
||||
:upload="selectedUpload"
|
||||
/>
|
||||
|
||||
<MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" />
|
||||
|
||||
<UploadConfigureModal
|
||||
v-model:open="configureModalOpen"
|
||||
v-model:settings="batchSettings"
|
||||
:metadata-error-count="metadataErrorCount"
|
||||
@upload="onBatchUpload"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeMount, ref } from 'vue';
|
||||
import MetadataInspector from '@/components/upload/MetadataInspector.vue';
|
||||
import type UploadDropzone from '@/components/upload/UploadDropzone.vue';
|
||||
import { config } from '@/config';
|
||||
import { useExiftoolStore } from '@/stores/exiftool';
|
||||
import { useMetadataStore } from '@/stores/metadata';
|
||||
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
|
||||
import type { PickedUploadAnalysis } from '@/types/upload/PickedUploadAnalysis';
|
||||
import { useUploadStore } from '@/stores/upload';
|
||||
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';
|
||||
|
||||
const { maxQueueSize, maxImageBytes } = config.validation.upload;
|
||||
|
||||
const exiftoolStore = useExiftoolStore();
|
||||
const metadataStore = useMetadataStore();
|
||||
const uploadStore = useUploadStore();
|
||||
|
||||
const { initializeExiftool } = exiftoolStore;
|
||||
const { makeKeyFromFile, getPreview, loadPreviews, clearAllPreviews } = metadataStore;
|
||||
const { loadPreviews, clearAllPreviews } = metadataStore;
|
||||
const { clearAllUploads, uploadBatch } = uploadStore;
|
||||
|
||||
const queuedFiles = ref<File[]>([]);
|
||||
const selectedFile = ref<File | null>(null);
|
||||
const selectionError = ref<string | null>(null);
|
||||
const loadingMetadata = ref(false);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const 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 () => {
|
||||
try {
|
||||
@@ -87,36 +107,44 @@ onBeforeMount(async () => {
|
||||
const maxImageSizeFormatted = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 }));
|
||||
|
||||
const queueRows = computed(() =>
|
||||
queuedFiles.value.map(file => ({
|
||||
file,
|
||||
key: makeKeyFromFile(file),
|
||||
preview: getPreview(file)
|
||||
}))
|
||||
queuedFiles.value.map(file => {
|
||||
const key = makeKeyFromFile(file);
|
||||
|
||||
return {
|
||||
file,
|
||||
key,
|
||||
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 =>
|
||||
selectedFile.value !== null && makeKeyFromFile(selectedFile.value) === makeKeyFromFile(file);
|
||||
const selectedUpload = computed(() => uploadStore.getUpload(selectedFile.value));
|
||||
|
||||
const statusTextClass = (entry: MetadataPreviewEntry): string =>
|
||||
entry.status === 'error' ? 'text-destructive' : 'text-muted-foreground';
|
||||
const metadataErrorCount = computed(
|
||||
() => queuedFiles.value.filter(file => metadataStore.getPreview(file).status === 'error').length
|
||||
);
|
||||
|
||||
const onFileInputChange = async (event: Event): Promise<void> => {
|
||||
if (loadingMetadata.value) {
|
||||
const canOpenConfigure = computed(() => queuedFiles.value.length > 0 && !loadingMetadata.value);
|
||||
|
||||
const onFilesPicked = async (picked: File[]): Promise<void> => {
|
||||
if (loadingMetadata.value || batchUploading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = event.target as HTMLInputElement;
|
||||
const picked = input.files ? [...input.files] : [];
|
||||
|
||||
resetUploadSelection();
|
||||
resetUploadSession();
|
||||
|
||||
if (picked.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { accepted, nonImageCount, oversizedImagesCount, queueOmittedImagesCount } = analyzePickedUploadFiles(picked);
|
||||
const { accepted, nonImageCount, oversizedImagesCount, queueOmittedImagesCount } = analyzePickedUploadFiles(
|
||||
picked,
|
||||
maxImageBytes,
|
||||
maxQueueSize
|
||||
);
|
||||
|
||||
if (nonImageCount === picked.length) {
|
||||
selectionError.value = 'Only image files can be added.';
|
||||
@@ -130,7 +158,10 @@ const onFileInputChange = async (event: Event): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionError.value = buildPickedUploadWarning({ nonImageCount, oversizedImagesCount, queueOmittedImagesCount });
|
||||
selectionError.value = buildPickedUploadWarning(
|
||||
{ nonImageCount, oversizedImagesCount, queueOmittedImagesCount },
|
||||
maxQueueSize
|
||||
);
|
||||
|
||||
queuedFiles.value = accepted;
|
||||
selectedFile.value = accepted[0] ?? null;
|
||||
@@ -146,50 +177,35 @@ const onFileInputChange = async (event: Event): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
const resetUploadSelection = (): void => {
|
||||
const resetUploadSession = (): void => {
|
||||
selectionError.value = null;
|
||||
clearAllPreviews();
|
||||
queuedFiles.value = [];
|
||||
selectedFile.value = null;
|
||||
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = '';
|
||||
}
|
||||
clearAllUploads();
|
||||
dropzoneRef.value?.clearInput();
|
||||
configureModalOpen.value = false;
|
||||
uploadConfirmed.value = false;
|
||||
showShareLinks.value = false;
|
||||
confirmedBatchSummary.value = null;
|
||||
};
|
||||
|
||||
const analyzePickedUploadFiles = (picked: File[]): PickedUploadAnalysis => {
|
||||
const images = picked.filter(file => file.type.startsWith('image/'));
|
||||
const nonImageCount = picked.length - images.length;
|
||||
const imagesWithinSizeLimit = images.filter(file => file.size <= maxImageBytes);
|
||||
const oversizedImagesCount = images.length - imagesWithinSizeLimit.length;
|
||||
const accepted = imagesWithinSizeLimit.slice(0, maxQueueSize);
|
||||
const queueOmittedImagesCount = imagesWithinSizeLimit.length - accepted.length;
|
||||
const onBatchUpload = async (uploadDespiteMetadataFailure?: boolean): Promise<void> => {
|
||||
const settings = batchSettings.value;
|
||||
|
||||
return {
|
||||
accepted,
|
||||
nonImageCount,
|
||||
oversizedImagesCount,
|
||||
queueOmittedImagesCount
|
||||
};
|
||||
};
|
||||
confirmedBatchSummary.value = formatUploadBatchSettingsSummary(settings);
|
||||
uploadConfirmed.value = true;
|
||||
configureModalOpen.value = false;
|
||||
|
||||
const buildPickedUploadWarning = (
|
||||
summary: Pick<PickedUploadAnalysis, 'nonImageCount' | 'oversizedImagesCount' | 'queueOmittedImagesCount'>
|
||||
): string | null => {
|
||||
const parts: string[] = [];
|
||||
batchUploading.value = true;
|
||||
|
||||
if (summary.nonImageCount > 0) {
|
||||
parts.push(`${summary.nonImageCount} non-image file(s) were skipped.`);
|
||||
try {
|
||||
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>
|
||||
|
||||
Vendored
+3
@@ -2,8 +2,11 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
readonly VITE_CLEARNET_SCHEME: string;
|
||||
readonly VITE_CLEARNET_DOMAIN: string;
|
||||
readonly VITE_ONION_HOSTNAME: string;
|
||||
readonly VITE_ALTCHA_ENABLED: string;
|
||||
readonly VITE_ALTCHA_MAX_NUMBER: string;
|
||||
readonly VITE_UPLOAD_MAX_QUEUE_SIZE: string;
|
||||
readonly VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX: string;
|
||||
readonly VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES: string;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import Components from 'unplugin-vue-components/vite';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -12,5 +13,11 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
plugins: [vue(), tailwindcss()]
|
||||
plugins: [
|
||||
vue(),
|
||||
Components({
|
||||
dts: 'src/components.d.ts'
|
||||
}),
|
||||
tailwindcss()
|
||||
]
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user