after audit

This commit is contained in:
Justin Visser 2026-03-01 18:14:22 +01:00
parent e8e20637e6
commit a5165dc3ba
80 changed files with 8489 additions and 7302 deletions

View file

@ -15,7 +15,9 @@
"devDependencies": {
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.21",
"happy-dom": "^20.7.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
@ -474,6 +476,24 @@
"node": ">=12"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -550,6 +570,24 @@
"node": ">= 8"
}
},
"node_modules/@one-ini/wasm": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
"dev": true,
"license": "MIT"
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
@ -917,6 +955,23 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@ -1227,6 +1282,27 @@
"integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==",
"license": "MIT"
},
"node_modules/@vue/test-utils": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz",
"integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-beautify": "^1.14.9",
"vue-component-type-helpers": "^2.0.0"
}
},
"node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
"integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/alien-signals": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
@ -1234,6 +1310,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@ -1502,6 +1604,26 @@
"node": ">= 6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@ -1512,6 +1634,32 @@
"node": ">= 6"
}
},
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
"integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ini": "^1.3.4",
"proto-list": "~1.2.1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -1580,6 +1728,42 @@
"dev": true,
"license": "MIT"
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/editorconfig": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
"integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@one-ini/wasm": "0.1.1",
"commander": "^10.0.0",
"minimatch": "^9.0.1",
"semver": "^7.5.3"
},
"bin": {
"editorconfig": "bin/editorconfig"
},
"engines": {
"node": ">=14"
}
},
"node_modules/editorconfig/node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.286",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
@ -1587,6 +1771,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@ -1728,6 +1919,23 @@
"node": ">=8"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@ -1767,6 +1975,28 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -1780,6 +2010,24 @@
"node": ">=10.13.0"
}
},
"node_modules/happy-dom": {
"version": "20.7.0",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.7.0.tgz",
"integrity": "sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.18.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -1803,6 +2051,13 @@
"he": "bin/he"
}
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"dev": true,
"license": "ISC"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -1842,6 +2097,16 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@ -1865,6 +2130,29 @@
"node": ">=0.12.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@ -1875,6 +2163,38 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/js-beautify": {
"version": "1.15.4",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
"integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"config-chain": "^1.1.13",
"editorconfig": "^1.0.4",
"glob": "^10.4.2",
"js-cookie": "^3.0.5",
"nopt": "^7.2.1"
},
"bin": {
"css-beautify": "js/bin/css-beautify.js",
"html-beautify": "js/bin/html-beautify.js",
"js-beautify": "js/bin/js-beautify.js"
},
"engines": {
"node": ">=14"
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@ -1902,6 +2222,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -1951,6 +2278,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -2002,6 +2339,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/nopt": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
"integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
"dev": true,
"license": "ISC",
"dependencies": {
"abbrev": "^2.0.0"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@ -2032,6 +2385,13 @@
"node": ">= 6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@ -2039,6 +2399,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@ -2046,6 +2416,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
@ -2286,6 +2673,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
"dev": true,
"license": "ISC"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -2431,6 +2825,42 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@ -2438,6 +2868,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -2461,6 +2904,110 @@
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@ -2906,6 +3453,13 @@
}
}
},
"node_modules/vue-component-type-helpers": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz",
"integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==",
"dev": true,
"license": "MIT"
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
@ -2964,6 +3518,32 @@
"typescript": ">=5.0.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"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",
@ -2980,6 +3560,126 @@
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View file

@ -21,7 +21,9 @@
"devDependencies": {
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.21",
"happy-dom": "^20.7.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",

View file

@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { ApiRequestError } from "@/lib/api/errors";
import { useRequestState } from "@/composables/useRequestState";
describe("useRequestState", () => {
it("initializes with all values null", () => {
const { errorMessage, errorRequestId, successMessage } = useRequestState();
expect(errorMessage.value).toBeNull();
expect(errorRequestId.value).toBeNull();
expect(successMessage.value).toBeNull();
});
it("assignError extracts message and requestId from ApiRequestError", () => {
const { errorMessage, errorRequestId, assignError } = useRequestState();
assignError(
new ApiRequestError({ status: 409, code: "conflict", message: "Conflict occurred", requestId: "req-42" }),
"fallback",
);
expect(errorMessage.value).toBe("Conflict occurred");
expect(errorRequestId.value).toBe("req-42");
});
it("assignError uses Error.message for generic errors", () => {
const { errorMessage, errorRequestId, assignError } = useRequestState();
assignError(new Error("something broke"), "fallback");
expect(errorMessage.value).toBe("something broke");
expect(errorRequestId.value).toBeNull();
});
it("assignError uses fallback for non-Error values", () => {
const { errorMessage, assignError } = useRequestState();
assignError("not an error object", "fallback text");
expect(errorMessage.value).toBe("fallback text");
});
it("setSuccess sets the success message", () => {
const { successMessage, setSuccess } = useRequestState();
setSuccess("Operation completed");
expect(successMessage.value).toBe("Operation completed");
});
it("clearAlerts resets all messages", () => {
const { errorMessage, errorRequestId, successMessage, assignError, setSuccess, clearAlerts } = useRequestState();
assignError(new ApiRequestError({ status: 500, code: "err", message: "fail", requestId: "r1" }), "fb");
setSuccess("ok");
clearAlerts();
expect(errorMessage.value).toBeNull();
expect(errorRequestId.value).toBeNull();
expect(successMessage.value).toBeNull();
});
});

View file

@ -0,0 +1,40 @@
import { ref } from "vue";
import { ApiRequestError } from "@/lib/api/errors";
export function useRequestState() {
const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null);
function clearAlerts(): void {
errorMessage.value = null;
errorRequestId.value = null;
successMessage.value = null;
}
function assignError(error: unknown, fallback: string): void {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
return;
}
if (error instanceof Error && error.message) {
errorMessage.value = error.message;
return;
}
errorMessage.value = fallback;
}
function setSuccess(message: string): void {
successMessage.value = message;
}
return {
errorMessage,
errorRequestId,
successMessage,
clearAlerts,
assignError,
setSuccess,
};
}

View file

@ -0,0 +1,139 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import PublicationTableRow from "./PublicationTableRow.vue";
import type { PublicationItem } from "@/features/publications";
function buildItem(overrides: Partial<PublicationItem> = {}): PublicationItem {
return {
publication_id: 1,
scholar_profile_id: 10,
title: "Test Publication",
year: 2025,
citation_count: 42,
pub_url: "https://example.com/pub",
pdf_url: null,
pdf_status: null,
is_read: false,
is_favorite: false,
is_new_in_latest_run: true,
scholar_label: "Dr. Test",
first_seen_at: "2025-01-15T00:00:00Z",
display_identifier: null,
...overrides,
} as PublicationItem;
}
const defaultProps = {
item: buildItem(),
itemKey: "pub-1",
selected: false,
favoriteUpdating: false,
retrying: false,
canRetry: false,
};
describe("PublicationTableRow", () => {
it("renders the publication title", () => {
const wrapper = mount(PublicationTableRow, { props: defaultProps });
expect(wrapper.text()).toContain("Test Publication");
});
it("renders scholar label", () => {
const wrapper = mount(PublicationTableRow, { props: defaultProps });
expect(wrapper.text()).toContain("Dr. Test");
});
it("renders year and citation count", () => {
const wrapper = mount(PublicationTableRow, { props: defaultProps });
expect(wrapper.text()).toContain("2025");
expect(wrapper.text()).toContain("42");
});
it("shows New badge when is_new_in_latest_run is true", () => {
const wrapper = mount(PublicationTableRow, { props: defaultProps });
expect(wrapper.text()).toContain("New");
});
it("shows Seen badge when is_new_in_latest_run is false", () => {
const wrapper = mount(PublicationTableRow, {
props: { ...defaultProps, item: buildItem({ is_new_in_latest_run: false }) },
});
expect(wrapper.text()).toContain("Seen");
});
it("shows Unread badge for unread items", () => {
const wrapper = mount(PublicationTableRow, { props: defaultProps });
expect(wrapper.text()).toContain("Unread");
});
it("shows Read badge for read items", () => {
const wrapper = mount(PublicationTableRow, {
props: { ...defaultProps, item: buildItem({ is_read: true }) },
});
expect(wrapper.text()).toContain("Read");
});
it("disables checkbox when item is read", () => {
const wrapper = mount(PublicationTableRow, {
props: { ...defaultProps, item: buildItem({ is_read: true }) },
});
const checkbox = wrapper.find('input[type="checkbox"]');
expect((checkbox.element as HTMLInputElement).disabled).toBe(true);
});
it("emits toggle-selection when checkbox is changed", async () => {
const wrapper = mount(PublicationTableRow, { props: defaultProps });
await wrapper.find('input[type="checkbox"]').trigger("change");
expect(wrapper.emitted("toggle-selection")).toHaveLength(1);
});
it("emits toggle-favorite when star button is clicked", async () => {
const wrapper = mount(PublicationTableRow, { props: defaultProps });
await wrapper.find(".favorite-star-button").trigger("click");
expect(wrapper.emitted("toggle-favorite")).toHaveLength(1);
});
it("shows filled star when item is favorite", () => {
const wrapper = mount(PublicationTableRow, {
props: { ...defaultProps, item: buildItem({ is_favorite: true }) },
});
expect(wrapper.find(".favorite-star-on").exists()).toBe(true);
expect(wrapper.text()).toContain("★");
});
it("shows empty star when item is not favorite", () => {
const wrapper = mount(PublicationTableRow, { props: defaultProps });
expect(wrapper.find(".favorite-star-off").exists()).toBe(true);
expect(wrapper.text()).toContain("☆");
});
it("shows Available link when pdf_url is set", () => {
const wrapper = mount(PublicationTableRow, {
props: { ...defaultProps, item: buildItem({ pdf_url: "https://example.com/paper.pdf" }) },
});
expect(wrapper.text()).toContain("Available");
});
it("shows retry button when canRetry is true and no pdf_url", () => {
const wrapper = mount(PublicationTableRow, {
props: { ...defaultProps, canRetry: true },
});
expect(wrapper.text()).toContain("Missing (Retry)");
});
it("emits retry-pdf when retry button is clicked", async () => {
const wrapper = mount(PublicationTableRow, {
props: { ...defaultProps, canRetry: true },
});
await wrapper.find(".pdf-retry-button").trigger("click");
expect(wrapper.emitted("retry-pdf")).toHaveLength(1);
});
it("shows Retrying... label when retrying", () => {
const wrapper = mount(PublicationTableRow, {
props: { ...defaultProps, canRetry: true, retrying: true },
});
expect(wrapper.text()).toContain("Retrying...");
});
});

View file

@ -0,0 +1,178 @@
<script setup lang="ts">
import AppBadge from "@/components/ui/AppBadge.vue";
import type { PublicationItem } from "@/features/publications";
const props = defineProps<{
item: PublicationItem;
itemKey: string;
selected: boolean;
favoriteUpdating: boolean;
retrying: boolean;
canRetry: boolean;
}>();
const emit = defineEmits<{
(e: "toggle-selection", event: Event): void;
(e: "toggle-favorite"): void;
(e: "retry-pdf"): void;
}>();
function primaryUrl(): string | null {
return props.item.pub_url || props.item.pdf_url;
}
function identifierUrl(): string | null {
return props.item.display_identifier?.url ?? null;
}
function identifierLabel(): string | null {
return props.item.display_identifier?.label ?? null;
}
function pdfPendingLabel(): string {
if (props.item.pdf_status === "queued") return "Queued";
if (props.item.pdf_status === "running") return "Resolving...";
if (props.item.pdf_status === "failed") return "Missing";
return "Untracked";
}
function formatDate(value: string): string {
const asDate = new Date(value);
if (Number.isNaN(asDate.getTime())) return value;
return asDate.toLocaleDateString();
}
</script>
<template>
<tr>
<td>
<input
type="checkbox"
class="h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
:checked="selected"
:disabled="item.is_read"
:aria-label="`Select publication ${item.title}`"
@change="emit('toggle-selection', $event)"
/>
</td>
<td>
<button
type="button"
class="favorite-star-button"
:class="item.is_favorite ? 'favorite-star-on' : 'favorite-star-off'"
:aria-label="item.is_favorite ? `Remove ${item.title} from favorites` : `Add ${item.title} to favorites`"
:aria-pressed="item.is_favorite"
:disabled="favoriteUpdating"
@click="emit('toggle-favorite')"
>
{{ item.is_favorite ? "★" : "☆" }}
</button>
</td>
<td class="max-w-0">
<div class="grid min-w-0 gap-1">
<a
v-if="primaryUrl()"
:href="primaryUrl() || ''"
target="_blank"
rel="noreferrer"
class="link-inline block truncate font-medium"
:title="item.title"
>
{{ item.title }}
</a>
<span v-else class="block truncate font-medium" :title="item.title">{{ item.title }}</span>
<a
v-if="identifierUrl()"
:href="identifierUrl() || ''"
target="_blank"
rel="noreferrer"
class="link-inline block truncate text-xs"
:title="identifierLabel() || ''"
>
{{ identifierLabel() }}
</a>
</div>
</td>
<td>
<span class="block truncate" :title="item.scholar_label">{{ item.scholar_label }}</span>
</td>
<td class="whitespace-nowrap">
<a
v-if="item.pdf_url"
:href="item.pdf_url"
target="_blank"
rel="noreferrer"
class="pdf-link-button"
title="Open PDF"
>
<svg class="mr-1 h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Available
</a>
<button
v-else-if="canRetry"
type="button"
class="pdf-retry-button"
:disabled="retrying"
@click="emit('retry-pdf')"
>
<svg v-if="retrying" class="mr-1 h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ retrying ? "Retrying..." : "Missing (Retry)" }}
</button>
<span v-else class="pdf-state-label" :class="{ 'bg-surface-accent-muted border-accent-300 text-accent-700': item.pdf_status === 'running' || item.pdf_status === 'queued' }">
<svg v-if="item.pdf_status === 'running'" class="mr-1 h-3 w-3 animate-spin text-accent-600" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ pdfPendingLabel() }}
</span>
</td>
<td class="whitespace-nowrap">{{ item.year ?? "n/a" }}</td>
<td class="whitespace-nowrap">{{ item.citation_count }}</td>
<td>
<div class="status-badges-row">
<AppBadge :tone="item.is_new_in_latest_run ? 'info' : 'neutral'">
{{ item.is_new_in_latest_run ? "New" : "Seen" }}
</AppBadge>
<AppBadge :tone="item.is_read ? 'success' : 'warning'">
{{ item.is_read ? "Read" : "Unread" }}
</AppBadge>
</div>
</td>
<td class="whitespace-nowrap">{{ formatDate(item.first_seen_at) }}</td>
</tr>
</template>
<style scoped>
.favorite-star-button {
@apply inline-flex h-7 w-7 items-center justify-center rounded-full border text-sm leading-none transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-50;
}
.favorite-star-on {
@apply border-warning-300 bg-warning-100 text-warning-700 hover:bg-warning-200;
}
.favorite-star-off {
@apply border-stroke-default bg-surface-card-muted text-ink-muted hover:border-stroke-interactive hover:text-ink-secondary;
}
.pdf-link-button {
@apply inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium border-state-success-border bg-state-success-bg text-state-success-text shadow-sm transition hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset;
}
.pdf-retry-button {
@apply inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium border-state-warning-border bg-state-warning-bg text-state-warning-text transition hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-50;
}
.pdf-state-label {
@apply inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium border-stroke-default bg-surface-card-muted text-secondary;
}
.status-badges-row {
@apply inline-flex items-center gap-1 whitespace-nowrap;
}
</style>

View file

@ -0,0 +1,89 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import PublicationToolbar from "./PublicationToolbar.vue";
const defaultProps = {
mode: "all" as const,
selectedScholarFilter: "",
searchQuery: "",
favoriteOnly: false,
actionBusy: false,
loading: false,
scholars: [],
startRunDisabled: false,
startRunDisabledReason: null,
startRunButtonLabel: "Check now",
};
describe("PublicationToolbar", () => {
it("renders mode, scholar filter, and search inputs", () => {
const wrapper = mount(PublicationToolbar, { props: defaultProps });
expect(wrapper.text()).toContain("Status");
expect(wrapper.text()).toContain("Scholar");
expect(wrapper.text()).toContain("Search");
});
it("renders the start run button with provided label", () => {
const wrapper = mount(PublicationToolbar, { props: defaultProps });
expect(wrapper.text()).toContain("Check now");
});
it("disables start run button when startRunDisabled is true", () => {
const wrapper = mount(PublicationToolbar, {
props: { ...defaultProps, startRunDisabled: true, startRunDisabledReason: "Cooldown active" },
});
const buttons = wrapper.findAll("button");
const runButton = buttons.find((b) => b.text().includes("Check now"));
expect(runButton?.attributes("disabled")).toBeDefined();
});
it("emits start-run when start button is clicked", async () => {
const wrapper = mount(PublicationToolbar, { props: defaultProps });
const buttons = wrapper.findAll("button");
const runButton = buttons.find((b) => b.text().includes("Check now"));
await runButton?.trigger("click");
expect(wrapper.emitted("start-run")).toHaveLength(1);
});
it("emits favorite-only-changed when star filter is clicked", async () => {
const wrapper = mount(PublicationToolbar, { props: defaultProps });
await wrapper.find(".favorite-filter-button").trigger("click");
expect(wrapper.emitted("favorite-only-changed")).toHaveLength(1);
});
it("shows active star filter style when favoriteOnly is true", () => {
const wrapper = mount(PublicationToolbar, { props: { ...defaultProps, favoriteOnly: true } });
expect(wrapper.find(".favorite-filter-on").exists()).toBe(true);
});
it("shows inactive star filter style when favoriteOnly is false", () => {
const wrapper = mount(PublicationToolbar, { props: defaultProps });
expect(wrapper.find(".favorite-filter-off").exists()).toBe(true);
});
it("renders scholar options from props", () => {
const scholars = [
{ id: 1, scholar_id: "abc123", display_name: "Alice", profile_image_url: null, profile_image_source: "none" as const, is_enabled: true, baseline_completed: true, last_run_dt: null, last_run_status: null },
{ id: 2, scholar_id: "def456", display_name: "", profile_image_url: null, profile_image_source: "none" as const, is_enabled: true, baseline_completed: true, last_run_dt: null, last_run_status: null },
];
const wrapper = mount(PublicationToolbar, { props: { ...defaultProps, scholars } });
expect(wrapper.text()).toContain("Alice");
expect(wrapper.text()).toContain("def456");
});
it("shows clear button only when search query is non-empty", () => {
const empty = mount(PublicationToolbar, { props: defaultProps });
expect(empty.text()).not.toContain("Clear");
const withQuery = mount(PublicationToolbar, { props: { ...defaultProps, searchQuery: "test" } });
expect(withQuery.text()).toContain("Clear");
});
it("emits reset-search when clear button is clicked", async () => {
const wrapper = mount(PublicationToolbar, { props: { ...defaultProps, searchQuery: "test" } });
const clearButton = wrapper.findAll("button").find((b) => b.text().includes("Clear"));
await clearButton?.trigger("click");
expect(wrapper.emitted("reset-search")).toHaveLength(1);
});
});

View file

@ -0,0 +1,148 @@
<script setup lang="ts">
import AppButton from "@/components/ui/AppButton.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppInput from "@/components/ui/AppInput.vue";
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
import AppSelect from "@/components/ui/AppSelect.vue";
import type { PublicationMode } from "@/features/publications";
import type { ScholarProfile } from "@/features/scholars";
defineProps<{
mode: PublicationMode;
selectedScholarFilter: string;
searchQuery: string;
favoriteOnly: boolean;
actionBusy: boolean;
loading: boolean;
scholars: ScholarProfile[];
startRunDisabled: boolean;
startRunDisabledReason: string | null;
startRunButtonLabel: string;
}>();
const emit = defineEmits<{
(e: "update:mode", value: PublicationMode): void;
(e: "update:selectedScholarFilter", value: string): void;
(e: "update:searchQuery", value: string): void;
(e: "mode-changed"): void;
(e: "scholar-filter-changed"): void;
(e: "favorite-only-changed"): void;
(e: "reset-search"): void;
(e: "start-run"): void;
(e: "refresh"): void;
}>();
function scholarLabel(item: ScholarProfile): string {
return item.display_name || item.scholar_id;
}
</script>
<template>
<div class="grid gap-3 xl:grid-cols-[minmax(0,13rem)_minmax(0,18rem)_minmax(0,1fr)_auto] xl:items-end">
<div class="grid gap-1 text-xs text-secondary">
<div class="flex items-center gap-1">
<span>Status</span>
<AppHelpHint text="All shows full history. Unread and New narrow the dataset before search." />
</div>
<AppSelect
:model-value="mode"
:disabled="actionBusy"
@update:model-value="emit('update:mode', $event as PublicationMode)"
@change="emit('mode-changed')"
>
<option value="all">All records</option>
<option value="unread">Unread</option>
<option value="latest">New (latest run)</option>
</AppSelect>
</div>
<label class="grid gap-1 text-xs text-secondary" for="publications-scholar-filter">
<span class="inline-flex items-center gap-1">
Scholar
<AppHelpHint text="Filter to one tracked scholar profile. Filter is synced to URL query." />
</span>
<AppSelect
id="publications-scholar-filter"
:model-value="selectedScholarFilter"
:disabled="actionBusy"
@update:model-value="emit('update:selectedScholarFilter', $event as string)"
@change="emit('scholar-filter-changed')"
>
<option value="">All scholars</option>
<option v-for="scholar in scholars" :key="scholar.id" :value="String(scholar.id)">
{{ scholarLabel(scholar) }}
</option>
</AppSelect>
</label>
<label class="grid gap-1 text-xs text-secondary" for="publications-search-input">
<span class="inline-flex items-center gap-1">
Search
<AppHelpHint text="Searches title, scholar name, and venue." />
</span>
<div class="flex min-w-0 items-center gap-2">
<AppInput
id="publications-search-input"
:model-value="searchQuery"
placeholder="Search title, scholar, venue, year"
:disabled="loading"
@update:model-value="emit('update:searchQuery', $event as string)"
/>
<AppButton
v-if="searchQuery.trim().length > 0"
variant="secondary"
class="shrink-0"
:disabled="loading"
@click="emit('reset-search')"
>
Clear
</AppButton>
</div>
</label>
<div class="flex flex-wrap items-end justify-end gap-2">
<button
type="button"
class="favorite-filter-button"
:class="favoriteOnly ? 'favorite-filter-on' : 'favorite-filter-off'"
:disabled="actionBusy"
:title="favoriteOnly ? 'Favorites-only filter is active' : 'Show only favorites'"
:aria-pressed="favoriteOnly"
:aria-label="favoriteOnly ? 'Disable favorites-only filter' : 'Enable favorites-only filter'"
@click="emit('favorite-only-changed')"
>
<span aria-hidden="true">{{ favoriteOnly ? "★" : "☆" }}</span>
</button>
<AppButton
variant="secondary"
:disabled="startRunDisabled"
:title="startRunDisabledReason || undefined"
@click="emit('start-run')"
>
{{ startRunButtonLabel }}
</AppButton>
<AppRefreshButton
variant="ghost"
:disabled="loading"
:loading="loading"
title="Refresh publications"
loading-title="Refreshing publications"
@click="emit('refresh')"
/>
</div>
</div>
</template>
<style scoped>
.favorite-filter-button {
@apply inline-flex min-h-10 h-10 w-10 items-center justify-center rounded-full border text-base leading-none transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-50;
}
.favorite-filter-on {
@apply border-warning-300 bg-warning-100 text-warning-700 hover:bg-warning-200;
}
.favorite-filter-off {
@apply border-stroke-default bg-surface-card-muted text-ink-muted hover:border-stroke-interactive hover:text-ink-secondary;
}
</style>

View file

@ -0,0 +1,118 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { createPinia, setActivePinia } from "pinia";
vi.mock("vue-router", () => ({
useRoute: vi.fn(() => ({ query: {} })),
useRouter: vi.fn(() => ({ replace: vi.fn() })),
}));
vi.mock("@/features/publications", async (importOriginal) => {
const original = (await importOriginal()) as Record<string, unknown>;
return {
...original,
listPublications: vi.fn(),
};
});
vi.mock("@/features/scholars", () => ({
listScholars: vi.fn(),
}));
import { listPublications } from "@/features/publications";
import { listScholars } from "@/features/scholars";
import { usePublicationData } from "./usePublicationData";
const mockedListPublications = vi.mocked(listPublications);
const mockedListScholars = vi.mocked(listScholars);
describe("usePublicationData", () => {
beforeEach(() => {
setActivePinia(createPinia());
mockedListPublications.mockReset();
mockedListScholars.mockReset();
});
it("initializes with default filter values", () => {
const data = usePublicationData();
expect(data.mode.value).toBe("all");
expect(data.favoriteOnly.value).toBe(false);
expect(data.searchQuery.value).toBe("");
expect(data.selectedScholarFilter.value).toBe("");
expect(data.loading.value).toBe(true);
});
it("loadScholarFilters calls listScholars", async () => {
mockedListScholars.mockResolvedValueOnce([
{ id: 1, scholar_id: "abc", display_name: "Test", profile_image_url: null, profile_image_source: "none" as const, is_enabled: true, baseline_completed: true, last_run_dt: null, last_run_status: null },
]);
const data = usePublicationData();
await data.loadScholarFilters();
expect(mockedListScholars).toHaveBeenCalled();
expect(data.scholars.value).toHaveLength(1);
});
it("loadPublications calls listPublications with current filters", async () => {
mockedListPublications.mockResolvedValueOnce({
publications: [],
mode: "all" as const,
favorite_only: false,
selected_scholar_profile_id: null,
unread_count: 0,
favorites_count: 0,
latest_count: 0,
new_count: 0,
total_count: 0,
page: 1,
page_size: 25,
snapshot: "",
has_next: false,
has_prev: false,
});
const data = usePublicationData();
await data.loadPublications();
expect(mockedListPublications).toHaveBeenCalled();
});
it("resetPageAndSnapshot resets page to 1", () => {
const data = usePublicationData();
data.currentPage.value = 5;
data.resetPageAndSnapshot();
expect(data.currentPage.value).toBe(1);
});
it("toggleSort reverses direction for same key", async () => {
mockedListPublications.mockResolvedValue({
publications: [],
mode: "all" as const,
favorite_only: false,
selected_scholar_profile_id: null,
unread_count: 0,
favorites_count: 0,
latest_count: 0,
new_count: 0,
total_count: 0,
page: 1,
page_size: 25,
snapshot: "",
has_next: false,
has_prev: false,
});
const data = usePublicationData();
const initialDirection = data.sortDirection.value;
await data.toggleSort(data.sortKey.value);
expect(data.sortDirection.value).toBe(initialDirection === "asc" ? "desc" : "asc");
});
it("sortMarker returns arrow indicators", () => {
const data = usePublicationData();
expect(data.sortMarker(data.sortKey.value)).toMatch(/[↑↓]/);
expect(data.sortMarker("year" as never)).toBe("↕");
});
it("computed pagination properties work correctly", () => {
const data = usePublicationData();
expect(data.hasPrevPage.value).toBe(false);
expect(data.totalPages.value).toBe(1);
expect(data.totalCount.value).toBe(0);
});
});

View file

@ -0,0 +1,338 @@
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
listPublications,
type PublicationItem,
type PublicationMode,
type PublicationsResult,
} from "@/features/publications";
import { listScholars, type ScholarProfile } from "@/features/scholars";
import { ApiRequestError } from "@/lib/api/errors";
import { useRunStatusStore } from "@/stores/run_status";
export type PublicationSortKey =
| "title"
| "scholar"
| "year"
| "citations"
| "first_seen"
| "pdf_status";
export function publicationKey(item: PublicationItem): string {
return `${item.scholar_profile_id}:${item.publication_id}`;
}
export function usePublicationData() {
const loading = ref(true);
const mode = ref<PublicationMode>("all");
const favoriteOnly = ref(false);
const selectedScholarFilter = ref("");
const searchQuery = ref("");
const sortKey = ref<PublicationSortKey>("first_seen");
const sortDirection = ref<"asc" | "desc">("desc");
const currentPage = ref(1);
const pageSize = ref("50");
const publicationSnapshot = ref<string | null>(null);
const scholars = ref<ScholarProfile[]>([]);
const listState = ref<PublicationsResult | null>(null);
const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const route = useRoute();
const router = useRouter();
const textCollator = new Intl.Collator(undefined, { sensitivity: "base", numeric: true });
const runStatus = useRunStatusStore();
// --- Route sync ---
function normalizeScholarFilterQuery(value: unknown): string {
if (Array.isArray(value)) return normalizeScholarFilterQuery(value[0]);
if (typeof value !== "string") return "";
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? String(parsed) : "";
}
function normalizeFavoriteOnlyQuery(value: unknown): boolean {
if (Array.isArray(value)) return normalizeFavoriteOnlyQuery(value[0]);
if (typeof value !== "string") return false;
const normalized = value.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
function normalizePageQuery(value: unknown): number {
if (Array.isArray(value)) return normalizePageQuery(value[0]);
if (typeof value !== "string") return 1;
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
}
function syncFiltersFromRoute(): boolean {
let changed = false;
const nextScholar = normalizeScholarFilterQuery(route.query.scholar);
const nextFavoriteOnly = normalizeFavoriteOnlyQuery(route.query.favorite);
const nextPage = normalizePageQuery(route.query.page);
if (selectedScholarFilter.value !== nextScholar) { selectedScholarFilter.value = nextScholar; changed = true; }
if (favoriteOnly.value !== nextFavoriteOnly) { favoriteOnly.value = nextFavoriteOnly; changed = true; }
if (currentPage.value !== nextPage) { currentPage.value = nextPage; changed = true; }
return changed;
}
async function syncFiltersToRoute(): Promise<void> {
const nextScholar = selectedScholarFilter.value.trim();
const currentScholar = normalizeScholarFilterQuery(route.query.scholar);
const currentFavoriteOnly = normalizeFavoriteOnlyQuery(route.query.favorite);
const currentPageQuery = normalizePageQuery(route.query.page);
if (
nextScholar === currentScholar
&& favoriteOnly.value === currentFavoriteOnly
&& currentPage.value === currentPageQuery
) {
return;
}
await router.replace({
query: {
...route.query,
scholar: nextScholar || undefined,
favorite: favoriteOnly.value ? "1" : undefined,
page: currentPage.value > 1 ? String(currentPage.value) : undefined,
},
});
}
// --- Sorting helpers ---
function publicationSortValue(item: PublicationItem, key: PublicationSortKey): number | string {
if (key === "title") return item.title;
if (key === "scholar") return item.scholar_label;
if (key === "year") return item.year ?? -1;
if (key === "citations") return item.citation_count;
if (key === "pdf_status") {
if (item.pdf_url || item.pdf_status === "resolved") return 4;
if (item.pdf_status === "running") return 3;
if (item.pdf_status === "queued") return 2;
if (item.pdf_status === "failed") return 0;
return 1;
}
const timestamp = Date.parse(item.first_seen_at);
return Number.isNaN(timestamp) ? 0 : timestamp;
}
// --- Computed ---
const selectedScholarName = computed(() => {
const selectedId = Number(selectedScholarFilter.value);
if (!Number.isInteger(selectedId) || selectedId <= 0) return "all scholars";
const profile = scholars.value.find((item) => item.id === selectedId);
return profile ? (profile.display_name || profile.scholar_id) : "the selected scholar";
});
const filteredPublications = computed(() => {
let stream = [...runStatus.livePublications];
if (favoriteOnly.value) stream = stream.filter((p) => p.is_favorite);
if (mode.value === "unread") stream = stream.filter((p) => !p.is_read);
const selectedScholarId = Number(selectedScholarFilter.value);
if (Number.isInteger(selectedScholarId) && selectedScholarId > 0) {
stream = stream.filter((p) => p.scholar_profile_id === selectedScholarId);
}
const base = listState.value?.publications ?? [];
const merged = [...stream, ...base];
const seenKeys = new Set<string>();
const deduped: typeof base = [];
for (const item of merged) {
const key = publicationKey(item);
if (!seenKeys.has(key)) { seenKeys.add(key); deduped.push(item); }
}
return deduped;
});
const sortedPublications = computed(() => {
const direction = sortDirection.value === "asc" ? 1 : -1;
return [...filteredPublications.value].sort((left, right) => {
const leftValue = publicationSortValue(left, sortKey.value);
const rightValue = publicationSortValue(right, sortKey.value);
let comparison = 0;
if (typeof leftValue === "string" && typeof rightValue === "string") {
comparison = textCollator.compare(leftValue, rightValue);
} else {
comparison = Number(leftValue) - Number(rightValue);
}
if (comparison !== 0) return comparison * direction;
return right.publication_id - left.publication_id;
});
});
const visibleUnreadKeys = computed(() => {
const keys = new Set<string>();
for (const item of sortedPublications.value) {
if (!item.is_read) keys.add(publicationKey(item));
}
return keys;
});
const pageSizeValue = computed(() => {
const parsed = Number(pageSize.value);
if (!Number.isInteger(parsed)) return 50;
return Math.max(10, Math.min(200, parsed));
});
const hasNextPage = computed(() => Boolean(listState.value?.has_next));
const hasPrevPage = computed(() => Boolean(listState.value?.has_prev));
const totalPages = computed(() => {
if (!listState.value) return 1;
return Math.max(1, Math.ceil(listState.value.total_count / Math.max(listState.value.page_size, 1)));
});
const totalCount = computed(() => listState.value?.total_count ?? 0);
const visibleCount = computed(() => sortedPublications.value.length);
const visibleUnreadCount = computed(() => visibleUnreadKeys.value.size);
const visibleFavoriteCount = computed(
() => sortedPublications.value.filter((item) => item.is_favorite).length,
);
// --- Data loading ---
async function loadScholarFilters(): Promise<void> {
try { scholars.value = await listScholars(); } catch { scholars.value = []; }
}
function selectedScholarId(): number | undefined {
const parsed = Number(selectedScholarFilter.value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
}
async function loadPublications(): Promise<void> {
loading.value = true;
errorMessage.value = null;
errorRequestId.value = null;
try {
listState.value = await listPublications({
mode: mode.value,
favoriteOnly: favoriteOnly.value,
scholarProfileId: selectedScholarId(),
search: searchQuery.value.trim() || undefined,
sortBy: sortKey.value,
sortDir: sortDirection.value,
page: currentPage.value,
pageSize: pageSizeValue.value,
snapshot: publicationSnapshot.value ?? undefined,
});
publicationSnapshot.value = listState.value.snapshot;
currentPage.value = listState.value.page;
pageSize.value = String(listState.value.page_size);
} catch (error) {
listState.value = null;
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to load publications.";
}
} finally {
loading.value = false;
}
}
function resetPageAndSnapshot(): void {
currentPage.value = 1;
publicationSnapshot.value = null;
}
async function toggleSort(nextKey: PublicationSortKey): Promise<void> {
if (sortKey.value === nextKey) {
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
} else {
sortKey.value = nextKey;
sortDirection.value = nextKey === "first_seen" || nextKey === "pdf_status" ? "desc" : "asc";
}
resetPageAndSnapshot();
await loadPublications();
}
function sortMarker(key: PublicationSortKey): string {
if (sortKey.value !== key) return "↕";
return sortDirection.value === "asc" ? "↑" : "↓";
}
function replacePublication(updated: PublicationItem): void {
if (!listState.value) return;
listState.value.publications = listState.value.publications.map((item) =>
publicationKey(item) !== publicationKey(updated) ? item : updated,
);
}
// --- Search debounce watcher setup ---
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
watch(searchQuery, () => {
if (searchDebounceTimer !== null) clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
resetPageAndSnapshot();
void loadPublications();
}, 300);
});
// --- Run-triggered refresh watcher ---
watch(
() => runStatus.latestRun,
async (nextRun, previousRun) => {
const nextRunId = nextRun && (nextRun.status === "running" || nextRun.status === "resolving") ? nextRun.id : null;
const previousRunId = previousRun && (previousRun.status === "running" || previousRun.status === "resolving") ? previousRun.id : null;
if (nextRunId === null || nextRunId === previousRunId) return;
resetPageAndSnapshot();
await loadPublications();
},
);
// --- Route watcher ---
watch(
() => [route.query.scholar, route.query.favorite, route.query.page],
async () => {
const previousScholar = selectedScholarFilter.value;
const previousFavorite = favoriteOnly.value;
const changed = syncFiltersFromRoute();
if (!changed) return;
if (selectedScholarFilter.value !== previousScholar || favoriteOnly.value !== previousFavorite) {
publicationSnapshot.value = null;
}
await loadPublications();
},
);
return {
loading,
mode,
favoriteOnly,
selectedScholarFilter,
searchQuery,
sortKey,
sortDirection,
currentPage,
pageSize,
scholars,
listState,
errorMessage,
errorRequestId,
successMessage,
selectedScholarName,
sortedPublications,
visibleUnreadKeys,
pageSizeValue,
hasNextPage,
hasPrevPage,
totalPages,
totalCount,
visibleCount,
visibleUnreadCount,
visibleFavoriteCount,
loadScholarFilters,
loadPublications,
resetPageAndSnapshot,
toggleSort,
sortMarker,
replacePublication,
syncFiltersFromRoute,
syncFiltersToRoute,
};
}

View file

@ -0,0 +1,76 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import ScholarBatchAdd from "./ScholarBatchAdd.vue";
const defaultProps = { saving: false, loading: false };
describe("ScholarBatchAdd", () => {
it("renders the heading and input", () => {
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
expect(wrapper.text()).toContain("Add Scholar Profiles");
expect(wrapper.find("textarea").exists()).toBe(true);
});
it("shows parsed ID count as 0 for empty input", () => {
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
expect(wrapper.text()).toContain("Parsed IDs:");
});
it("parses bare scholar IDs from textarea input", async () => {
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
await wrapper.find("textarea").setValue("A-UbBTPM15wL");
expect(wrapper.text()).toContain("1");
});
it("parses scholar IDs from Google Scholar URLs", async () => {
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
await wrapper.find("textarea").setValue(
"https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL",
);
expect(wrapper.text()).toContain("1");
});
it("deduplicates IDs from mixed input", async () => {
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
await wrapper.find("textarea").setValue(
"A-UbBTPM15wL\nhttps://scholar.google.com/citations?user=A-UbBTPM15wL",
);
expect(wrapper.text()).toContain("1");
});
it("parses multiple IDs separated by commas", async () => {
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
await wrapper.find("textarea").setValue("A-UbBTPM15wL, B-UbBTPM15wL");
expect(wrapper.text()).toContain("2");
});
it("emits add-scholars with parsed IDs on submit", async () => {
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
await wrapper.find("textarea").setValue("A-UbBTPM15wL");
await wrapper.find("form").trigger("submit");
const emitted = wrapper.emitted("add-scholars");
expect(emitted).toHaveLength(1);
expect(emitted![0][0]).toEqual(["A-UbBTPM15wL"]);
});
it("clears textarea after successful submit", async () => {
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
const textarea = wrapper.find("textarea");
await textarea.setValue("A-UbBTPM15wL");
await wrapper.find("form").trigger("submit");
expect((textarea.element as HTMLTextAreaElement).value).toBe("");
});
it("does not emit when input contains no valid IDs", async () => {
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
await wrapper.find("textarea").setValue("not-a-valid-id");
await wrapper.find("form").trigger("submit");
expect(wrapper.emitted("add-scholars")).toBeUndefined();
});
it("shows Adding... label when saving prop is true", () => {
const wrapper = mount(ScholarBatchAdd, { props: { ...defaultProps, saving: true } });
expect(wrapper.text()).toContain("Adding...");
});
});

View file

@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
defineProps<{
saving: boolean;
loading: boolean;
}>();
const emit = defineEmits<{
(e: "add-scholars", ids: string[]): void;
}>();
const scholarBatchInput = ref("");
const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/;
const URL_USER_PARAM_PATTERN = /(?:\?|&)user=([a-zA-Z0-9_-]{12})(?:&|#|$)/i;
function parseScholarIds(raw: string): string[] {
const ordered: string[] = [];
const seen = new Set<string>();
const tokens = raw.split(/[\s,;]+/).map((v) => v.trim()).filter((v) => v.length > 0);
for (const token of tokens) {
let candidate: string | null = null;
if (SCHOLAR_ID_PATTERN.test(token)) candidate = token;
if (!candidate) {
const directParamMatch = token.match(URL_USER_PARAM_PATTERN);
if (directParamMatch) candidate = directParamMatch[1];
}
if (!candidate && token.includes("scholar.google")) {
try {
const parsed = new URL(token);
const userParam = parsed.searchParams.get("user");
if (userParam && SCHOLAR_ID_PATTERN.test(userParam)) candidate = userParam;
} catch (_error) { /* Ignore non-URL tokens. */ }
}
if (!candidate || seen.has(candidate)) continue;
seen.add(candidate);
ordered.push(candidate);
}
return ordered;
}
const parsedBatchCount = computed(() => parseScholarIds(scholarBatchInput.value).length);
function onSubmit(): void {
const ids = parseScholarIds(scholarBatchInput.value);
if (ids.length > 0) {
emit("add-scholars", ids);
scholarBatchInput.value = "";
}
}
</script>
<template>
<AppCard class="space-y-4 xl:flex xl:min-h-0 xl:flex-col">
<div class="space-y-1">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Add Scholar Profiles</h2>
<AppHelpHint text="A scholar profile is a Google Scholar author page that Scholarr will monitor for publication changes." />
</div>
<p class="text-sm text-secondary">Paste one or more Scholar IDs or profile URLs and add them in one action.</p>
</div>
<form class="grid gap-3" @submit.prevent="onSubmit">
<label class="grid gap-2 text-sm font-medium text-ink-secondary" for="scholar-batch-input">
<span>Scholar IDs or profile URLs</span>
<textarea
id="scholar-batch-input"
v-model="scholarBatchInput"
rows="5"
placeholder="A-UbBTPM15wL&#10;https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL"
class="w-full rounded-lg border border-stroke-interactive bg-surface-input px-3 py-2 text-sm text-ink-primary outline-none ring-focus-ring transition placeholder:text-ink-muted focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-xs text-secondary">
Parsed IDs: <strong class="text-ink-primary">{{ parsedBatchCount }}</strong>
</p>
<AppButton type="submit" :disabled="saving || loading">
{{ saving ? "Adding..." : "Add scholars" }}
</AppButton>
</div>
</form>
</AppCard>
</template>

View file

@ -0,0 +1,43 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import ScholarNameSearch from "./ScholarNameSearch.vue";
const defaultProps = {
trackedScholarIds: new Set<string>(),
addingCandidateScholarId: null,
};
describe("ScholarNameSearch", () => {
it("renders the search heading", () => {
const wrapper = mount(ScholarNameSearch, {
props: defaultProps,
global: { stubs: { ScholarAvatar: true } },
});
expect(wrapper.text()).toContain("Search by Name");
});
it("shows WIP badge indicating the feature is incomplete", () => {
const wrapper = mount(ScholarNameSearch, {
props: defaultProps,
global: { stubs: { ScholarAvatar: true } },
});
expect(wrapper.text()).toContain("WIP");
});
it("renders the search input", () => {
const wrapper = mount(ScholarNameSearch, {
props: defaultProps,
global: { stubs: { ScholarAvatar: true } },
});
expect(wrapper.find("form").exists()).toBe(true);
});
it("shows warning about Google Scholar login challenges", () => {
const wrapper = mount(ScholarNameSearch, {
props: defaultProps,
global: { stubs: { ScholarAvatar: true } },
});
expect(wrapper.text()).toContain("login challenge");
});
});

View file

@ -0,0 +1,167 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
import AppAlert from "@/components/ui/AppAlert.vue";
import AppBadge from "@/components/ui/AppBadge.vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppInput from "@/components/ui/AppInput.vue";
import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue";
import type { ScholarSearchCandidate, ScholarSearchResult } from "@/features/scholars";
defineProps<{
trackedScholarIds: Set<string>;
addingCandidateScholarId: string | null;
}>();
const emit = defineEmits<{
(e: "add-candidate", candidate: ScholarSearchCandidate): void;
}>();
const nameSearchWip = true;
const searchingByName = ref(false);
const searchQuery = ref("");
const searchResult = ref<ScholarSearchResult | null>(null);
const searchErrorMessage = ref<string | null>(null);
const searchErrorRequestId = ref<string | null>(null);
const searchHasRun = computed(() => searchResult.value !== null);
const searchIsDegraded = computed(() => {
if (!searchResult.value) return false;
return searchResult.value.state === "blocked_or_captcha" || searchResult.value.state === "network_error";
});
const searchStateTone = computed(() => {
const result = searchResult.value;
if (!result) return "neutral" as const;
if (result.state === "ok") return "success" as const;
if (result.state === "blocked_or_captcha" || result.state === "network_error") return "warning" as const;
return "neutral" as const;
});
const emptySearchCandidates = computed(() => {
const result = searchResult.value;
if (!result) return false;
return result.candidates.length === 0;
});
</script>
<template>
<AppCard class="relative space-y-4 select-none xl:flex xl:min-h-0 xl:flex-col xl:overflow-hidden">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="space-y-1">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Search by Name</h2>
<AppHelpHint text="Google Scholar now challenges this endpoint with account login and anti-bot checks, so this workflow stays disabled." />
</div>
<p class="text-sm text-secondary">
This helper remains paused because Google Scholar currently requires account login for reliable name search access.
</p>
</div>
<AppHelpHint text="Name search is kept as WIP. Use direct Scholar ID or profile URL adds for production tracking.">
<template #trigger>
<AppBadge tone="warning">WIP</AppBadge>
</template>
</AppHelpHint>
</div>
<form class="pointer-events-none flex flex-wrap items-center gap-2 opacity-60">
<div class="min-w-0 flex-1">
<AppInput
id="scholar-search-name"
v-model="searchQuery"
placeholder="e.g. Geoffrey Hinton"
:disabled="nameSearchWip"
/>
</div>
<AppButton type="button" :disabled="nameSearchWip">
Search
</AppButton>
</form>
<p class="text-xs text-secondary">
Direct Scholar ID/URL adds above are the supported path until name search can run without login challenges.
</p>
<div class="min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
<RequestStateAlerts
:error-message="searchErrorMessage"
:error-request-id="searchErrorRequestId"
error-title="Search request failed"
/>
<AsyncStateGate :loading="searchingByName" :loading-lines="4" :show-empty="false">
<template v-if="searchResult">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm text-secondary">
{{ searchResult.candidates.length }} candidate{{ searchResult.candidates.length === 1 ? "" : "s" }}
for <strong class="text-ink-primary">{{ searchResult.query }}</strong>
</p>
<AppBadge :tone="searchStateTone">{{ searchResult.state }}</AppBadge>
</div>
<p v-if="searchResult.state !== 'ok' || searchResult.warnings.length > 0" class="text-xs text-secondary">
<span>State reason: <code>{{ searchResult.state_reason }}</code></span>
<span v-if="searchResult.action_hint">. {{ searchResult.action_hint }}</span>
<span v-if="searchResult.warnings.length > 0">. Warnings: {{ searchResult.warnings.join(", ") }}</span>
</p>
<AppAlert v-if="searchIsDegraded" tone="warning">
<template #title>Name search is degraded</template>
<p>This endpoint throttles aggressively to avoid blocks. Use Scholar URL/ID adds for dependable tracking.</p>
</AppAlert>
<AsyncStateGate
:loading="false"
:show-empty="true"
:empty="emptySearchCandidates"
empty-title="No scholar matches returned"
empty-body="Try another query later or add directly by Scholar URL/ID."
>
<ul class="grid gap-3">
<li
v-for="candidate in searchResult.candidates"
:key="candidate.scholar_id"
class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3"
>
<div class="flex items-start gap-3">
<ScholarAvatar
size="sm"
:label="candidate.display_name"
:scholar-id="candidate.scholar_id"
:image-url="candidate.profile_image_url"
/>
<div class="min-w-0 flex-1 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<strong class="text-sm text-ink-primary">{{ candidate.display_name }}</strong>
<code class="text-xs text-secondary">{{ candidate.scholar_id }}</code>
<AppBadge v-if="trackedScholarIds.has(candidate.scholar_id)" tone="success">Tracked</AppBadge>
</div>
<p class="truncate text-xs text-secondary">{{ candidate.affiliation || "No affiliation provided" }}</p>
<div class="flex flex-wrap items-center gap-2 text-xs text-secondary">
<span v-if="candidate.email_domain">Email: {{ candidate.email_domain }}</span>
<span v-if="candidate.cited_by_count !== null">Cited by: {{ candidate.cited_by_count }}</span>
</div>
</div>
<div class="grid shrink-0 gap-2">
<AppButton
:disabled="trackedScholarIds.has(candidate.scholar_id) || addingCandidateScholarId === candidate.scholar_id"
@click="emit('add-candidate', candidate)"
>
{{ addingCandidateScholarId === candidate.scholar_id ? "Adding..." : "Add" }}
</AppButton>
<a :href="candidate.profile_url" target="_blank" rel="noreferrer" class="link-inline text-xs text-center">
Open profile
</a>
</div>
</div>
</li>
</ul>
</AsyncStateGate>
</template>
<p v-else-if="!searchErrorMessage && !searchHasRun" class="text-sm text-secondary">
Name search remains disabled while login-gated responses are unresolved.
</p>
</AsyncStateGate>
</div>
</AppCard>
</template>

View file

@ -0,0 +1,92 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import ScholarSettingsModal from "./ScholarSettingsModal.vue";
import type { ScholarProfile } from "@/features/scholars";
function buildScholar(overrides: Partial<ScholarProfile> = {}): ScholarProfile {
return {
id: 1,
scholar_id: "abcDEF123456",
display_name: "Dr. Test Scholar",
profile_image_url: null,
profile_image_source: "none" as const,
is_enabled: true,
baseline_completed: true,
last_run_dt: null,
last_run_status: null,
...overrides,
};
}
const defaultProps = {
scholar: buildScholar(),
imageUrlDraft: "",
imageBusy: false,
imageSaving: false,
imageUploading: false,
saving: false,
};
describe("ScholarSettingsModal", () => {
it("renders scholar name and ID when scholar is provided", () => {
const wrapper = mount(ScholarSettingsModal, {
props: defaultProps,
global: { stubs: { AppModal: false, RouterLink: true, ScholarAvatar: true } },
});
expect(wrapper.text()).toContain("Dr. Test Scholar");
expect(wrapper.text()).toContain("abcDEF123456");
});
it("renders nothing when scholar is null", () => {
const wrapper = mount(ScholarSettingsModal, {
props: { ...defaultProps, scholar: null },
global: { stubs: { AppModal: false, RouterLink: true, ScholarAvatar: true } },
});
expect(wrapper.text()).not.toContain("Dr. Test Scholar");
});
it("emits close when close event fires", async () => {
const wrapper = mount(ScholarSettingsModal, {
props: defaultProps,
global: { stubs: { RouterLink: true, ScholarAvatar: true } },
});
const modal = wrapper.findComponent({ name: "AppModal" });
if (modal.exists()) {
modal.vm.$emit("close");
expect(wrapper.emitted("close")).toHaveLength(1);
}
});
it("emits delete when delete button is clicked", async () => {
const wrapper = mount(ScholarSettingsModal, {
props: defaultProps,
global: { stubs: { RouterLink: true, ScholarAvatar: true } },
});
const deleteButton = wrapper.findAll("button").find((b) => b.text().includes("Delete"));
if (deleteButton) {
await deleteButton.trigger("click");
expect(wrapper.emitted("delete")).toHaveLength(1);
}
});
it("emits toggle when enable/disable button is clicked", async () => {
const wrapper = mount(ScholarSettingsModal, {
props: defaultProps,
global: { stubs: { RouterLink: true, ScholarAvatar: true } },
});
const toggleButton = wrapper.findAll("button").find((b) => b.text().includes("Disable"));
if (toggleButton) {
await toggleButton.trigger("click");
expect(wrapper.emitted("toggle")).toHaveLength(1);
}
});
it("shows Enable button for disabled scholars", () => {
const wrapper = mount(ScholarSettingsModal, {
props: { ...defaultProps, scholar: buildScholar({ is_enabled: false }) },
global: { stubs: { RouterLink: true, ScholarAvatar: true } },
});
expect(wrapper.text()).toContain("Enable");
});
});

View file

@ -0,0 +1,113 @@
<script setup lang="ts">
import AppButton from "@/components/ui/AppButton.vue";
import AppInput from "@/components/ui/AppInput.vue";
import AppModal from "@/components/ui/AppModal.vue";
import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue";
import type { ScholarProfile } from "@/features/scholars";
const props = defineProps<{
scholar: ScholarProfile | null;
imageUrlDraft: string;
imageBusy: boolean;
imageSaving: boolean;
imageUploading: boolean;
saving: boolean;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "update:image-url-draft", value: string): void;
(e: "save-image-url"): void;
(e: "upload-image", event: Event): void;
(e: "reset-image"): void;
(e: "toggle"): void;
(e: "delete"): void;
}>();
function scholarLabel(profile: ScholarProfile): string {
return profile.display_name || profile.scholar_id;
}
function scholarProfileUrl(scholarId: string): string {
return `https://scholar.google.com/citations?hl=en&user=${encodeURIComponent(scholarId)}`;
}
function scholarPublicationsRoute(profile: ScholarProfile): { name: string; query: { scholar: string } } {
return { name: "publications", query: { scholar: String(profile.id) } };
}
</script>
<template>
<AppModal :open="scholar !== null" title="Scholar settings" @close="emit('close')">
<div v-if="scholar" class="grid gap-4">
<div class="flex items-start gap-3">
<ScholarAvatar
:label="scholar.display_name"
:scholar-id="scholar.scholar_id"
:image-url="scholar.profile_image_url"
/>
<div class="min-w-0 space-y-1">
<p class="truncate text-sm font-semibold text-ink-primary">{{ scholarLabel(scholar) }}</p>
<p class="text-xs text-secondary">ID: <code>{{ scholar.scholar_id }}</code></p>
<div class="flex flex-wrap items-center gap-3">
<RouterLink :to="scholarPublicationsRoute(scholar)" class="link-inline text-xs">
View publications
</RouterLink>
<a :href="scholarProfileUrl(scholar.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">
Open Google Scholar profile
</a>
</div>
</div>
</div>
<div class="grid gap-2">
<label class="text-sm font-medium text-ink-secondary" :for="`scholar-image-url-${scholar.id}`">
Profile image URL override
</label>
<div class="flex flex-wrap items-center gap-2">
<div class="min-w-0 flex-1">
<AppInput
:id="`scholar-image-url-${scholar.id}`"
:model-value="imageUrlDraft"
placeholder="https://example.com/avatar.jpg"
:disabled="imageBusy"
@update:model-value="emit('update:image-url-draft', $event as string)"
/>
</div>
<AppButton variant="secondary" :disabled="imageBusy" @click="emit('save-image-url')">
{{ imageSaving ? "Saving..." : "Save URL" }}
</AppButton>
</div>
<div class="flex flex-wrap items-center gap-2">
<label
:for="`scholar-image-upload-${scholar.id}`"
class="inline-flex min-h-10 cursor-pointer items-center justify-center rounded-lg border border-stroke-strong bg-action-secondary-bg px-3 py-2 text-sm font-semibold text-action-secondary-text transition hover:bg-action-secondary-hover-bg focus-within:outline-none focus-within:ring-2 focus-within:ring-focus-ring focus-within:ring-offset-2 focus-within:ring-offset-focus-offset"
:class="{ 'pointer-events-none opacity-60': imageBusy }"
>
{{ imageUploading ? "Uploading..." : "Upload image" }}
</label>
<input
:id="`scholar-image-upload-${scholar.id}`"
type="file"
class="sr-only"
accept="image/jpeg,image/png,image/webp,image/gif"
:disabled="imageBusy"
@change="emit('upload-image', $event)"
/>
<AppButton variant="ghost" :disabled="imageBusy" @click="emit('reset-image')">
Reset image
</AppButton>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-stroke-default pt-3">
<AppButton variant="secondary" :disabled="imageBusy || saving" @click="emit('toggle')">
{{ scholar.is_enabled ? "Disable scholar" : "Enable scholar" }}
</AppButton>
<AppButton variant="danger" :disabled="imageBusy || saving" @click="emit('delete')">
Delete scholar
</AppButton>
</div>
</div>
</AppModal>
</template>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,72 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import AdminIntegritySection from "./AdminIntegritySection.vue";
vi.mock("@/features/admin_dbops", () => ({
getAdminDbIntegrityReport: vi.fn(),
}));
import { getAdminDbIntegrityReport, type AdminDbIntegrityReport } from "@/features/admin_dbops";
const mockedGetReport = vi.mocked(getAdminDbIntegrityReport);
function buildReport(overrides: Partial<AdminDbIntegrityReport> = {}): AdminDbIntegrityReport {
return {
status: "ok",
warnings: [],
failures: [],
checked_at: "2025-01-15T12:00:00Z",
checks: [
{ name: "orphaned_publications", count: 0, severity: "metric", message: "No orphaned publications found." },
],
...overrides,
};
}
describe("AdminIntegritySection", () => {
beforeEach(() => {
mockedGetReport.mockReset();
});
it("renders the section heading", () => {
const wrapper = mount(AdminIntegritySection);
expect(wrapper.text()).toContain("Integrity Report");
});
it("exposes load function that fetches integrity report", async () => {
mockedGetReport.mockResolvedValueOnce(buildReport());
const wrapper = mount(AdminIntegritySection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(mockedGetReport).toHaveBeenCalled();
});
it("renders check results after loading", async () => {
mockedGetReport.mockResolvedValueOnce(buildReport());
const wrapper = mount(AdminIntegritySection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(wrapper.text()).toContain("orphaned_publications");
});
it("displays warning count when present", async () => {
mockedGetReport.mockResolvedValueOnce(buildReport({ warnings: ["w1", "w2", "w3"] }));
const wrapper = mount(AdminIntegritySection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(wrapper.text()).toContain("3");
});
it("renders status badge", async () => {
mockedGetReport.mockResolvedValueOnce(buildReport({ status: "failed" }));
const wrapper = mount(AdminIntegritySection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(wrapper.text()).toContain("failed");
});
});

View file

@ -0,0 +1,88 @@
<script setup lang="ts">
import { ref } from "vue";
import AppBadge from "@/components/ui/AppBadge.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
import AppTable from "@/components/ui/AppTable.vue";
import {
getAdminDbIntegrityReport,
type AdminDbIntegrityCheck,
type AdminDbIntegrityReport,
} from "@/features/admin_dbops";
const refreshingIntegrity = ref(false);
const integrityReport = ref<AdminDbIntegrityReport | null>(null);
function formatTimestamp(value: string | null): string {
if (!value) return "n/a";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function statusTone(status: string): "success" | "warning" | "danger" | "info" | "neutral" {
if (status === "ok" || status === "completed" || status === "resolved") return "success";
if (status === "warning" || status === "running" || status === "queued") return "warning";
if (status === "failed") return "danger";
return "info";
}
function checkTone(check: AdminDbIntegrityCheck): "warning" | "danger" | "neutral" | "info" {
if (check.severity === "metric") return "info";
if (check.count <= 0) return "neutral";
return check.severity === "failure" ? "danger" : "warning";
}
async function refreshIntegrity(): Promise<void> {
refreshingIntegrity.value = true;
try {
integrityReport.value = await getAdminDbIntegrityReport();
} finally {
refreshingIntegrity.value = false;
}
}
async function load(): Promise<void> {
await refreshIntegrity();
}
defineExpose({ load });
</script>
<template>
<AppCard class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Integrity Report</h2>
<AppHelpHint text="Read-only checks for known corruption patterns and data drift." />
</div>
<AppRefreshButton variant="secondary" :loading="refreshingIntegrity" title="Refresh integrity report" loading-title="Refreshing integrity report" @click="refreshIntegrity" />
</div>
<div v-if="integrityReport" class="flex flex-wrap items-center gap-2 text-xs">
<AppBadge :tone="statusTone(integrityReport.status)">Status: {{ integrityReport.status }}</AppBadge>
<AppBadge tone="warning">Warnings: {{ integrityReport.warnings.length }}</AppBadge>
<AppBadge tone="danger">Failures: {{ integrityReport.failures.length }}</AppBadge>
<span class="text-secondary">Checked: {{ formatTimestamp(integrityReport.checked_at) }}</span>
</div>
<AppTable v-if="integrityReport" label="Integrity checks">
<thead>
<tr>
<th scope="col">Check</th>
<th scope="col">Count</th>
<th scope="col">Severity</th>
<th scope="col">Message</th>
</tr>
</thead>
<tbody>
<tr v-for="check in integrityReport.checks" :key="check.name">
<td>{{ check.name }}</td>
<td>{{ check.count }}</td>
<td><AppBadge :tone="checkTone(check)">{{ check.severity }}</AppBadge></td>
<td>{{ check.message }}</td>
</tr>
</tbody>
</AppTable>
</AppCard>
</template>

View file

@ -0,0 +1,104 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import AdminPdfQueueSection from "./AdminPdfQueueSection.vue";
vi.mock("@/features/admin_dbops", () => ({
listAdminPdfQueue: vi.fn(),
requeueAdminPdfLookup: vi.fn(),
requeueAllAdminPdfLookups: vi.fn(),
}));
import { listAdminPdfQueue } from "@/features/admin_dbops";
const mockedListQueue = vi.mocked(listAdminPdfQueue);
function buildQueueItem(overrides: Record<string, unknown> = {}) {
return {
publication_id: 1,
title: "Test Paper",
display_identifier: null,
pdf_url: null,
status: "queued",
attempt_count: 0,
last_failure_reason: null,
last_failure_detail: null,
last_source: "unpaywall",
requested_by_user_id: null,
requested_by_email: null,
queued_at: "2025-01-15T12:00:00Z",
last_attempt_at: null,
resolved_at: null,
updated_at: "2025-01-15T12:00:00Z",
...overrides,
};
}
describe("AdminPdfQueueSection", () => {
beforeEach(() => {
mockedListQueue.mockReset();
});
it("renders the PDF queue heading", () => {
const wrapper = mount(AdminPdfQueueSection);
expect(wrapper.text()).toContain("PDF");
});
it("exposes load function that fetches PDF queue", async () => {
mockedListQueue.mockResolvedValueOnce({
items: [buildQueueItem()],
total_count: 1,
has_next: false,
has_prev: false,
page: 1,
page_size: 50,
});
const wrapper = mount(AdminPdfQueueSection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(mockedListQueue).toHaveBeenCalled();
});
it("renders queue items after loading", async () => {
mockedListQueue.mockResolvedValueOnce({
items: [buildQueueItem({ title: "My Paper" })],
total_count: 1,
has_next: false,
has_prev: false,
page: 1,
page_size: 50,
});
const wrapper = mount(AdminPdfQueueSection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(wrapper.text()).toContain("My Paper");
});
it("renders status filter dropdown", () => {
const wrapper = mount(AdminPdfQueueSection);
expect(wrapper.text()).toContain("Status");
});
it("renders queue all button", () => {
const wrapper = mount(AdminPdfQueueSection);
expect(wrapper.text()).toContain("Queue all");
});
it("shows pagination summary after loading", async () => {
mockedListQueue.mockResolvedValueOnce({
items: Array.from({ length: 3 }, (_, i) => buildQueueItem({ publication_id: i + 1 })),
total_count: 3,
has_next: false,
has_prev: false,
page: 1,
page_size: 50,
});
const wrapper = mount(AdminPdfQueueSection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(wrapper.text()).toContain("3");
});
});

View file

@ -0,0 +1,203 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import AppBadge from "@/components/ui/AppBadge.vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
import AppSelect from "@/components/ui/AppSelect.vue";
import AppTable from "@/components/ui/AppTable.vue";
import {
listAdminPdfQueue,
requeueAdminPdfLookup,
requeueAllAdminPdfLookups,
type AdminPdfQueueItem,
} from "@/features/admin_dbops";
import { useRequestState } from "@/composables/useRequestState";
const { clearAlerts, assignError, setSuccess } = useRequestState();
const refreshingPdfQueue = ref(false);
const requeueingPublicationId = ref<number | null>(null);
const requeueingAllPdfs = ref(false);
const pdfQueueItems = ref<AdminPdfQueueItem[]>([]);
const pdfQueueStatusFilter = ref("");
const pdfQueuePage = ref(1);
const pdfQueuePageSize = ref("50");
const pdfQueueTotalCount = ref(0);
const pdfQueueHasNext = ref(false);
const pdfQueueHasPrev = ref(false);
const pdfQueuePageSizeValue = computed(() => {
const parsed = Number(pdfQueuePageSize.value);
if (!Number.isFinite(parsed)) return 50;
return Math.max(1, Math.min(500, Math.trunc(parsed)));
});
const pdfQueueTotalPages = computed(() =>
Math.max(1, Math.ceil(pdfQueueTotalCount.value / Math.max(pdfQueuePageSizeValue.value, 1))),
);
const pdfQueueSummary = computed(() =>
`${pdfQueueTotalCount.value} item${pdfQueueTotalCount.value === 1 ? "" : "s"} total`,
);
function statusTone(status: string): "success" | "warning" | "danger" | "info" | "neutral" {
if (status === "ok" || status === "completed" || status === "resolved") return "success";
if (status === "warning" || status === "running" || status === "queued") return "warning";
if (status === "failed") return "danger";
return "info";
}
function formatTimestamp(value: string | null): string {
if (!value) return "n/a";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function canRequeuePdf(item: AdminPdfQueueItem): boolean {
return item.status !== "queued" && item.status !== "running";
}
async function refreshPdfQueue(): Promise<void> {
refreshingPdfQueue.value = true;
try {
const status = pdfQueueStatusFilter.value.trim() || null;
const page = await listAdminPdfQueue(pdfQueuePage.value, pdfQueuePageSizeValue.value, status);
pdfQueueItems.value = page.items;
pdfQueueTotalCount.value = page.total_count;
pdfQueueHasNext.value = page.has_next;
pdfQueueHasPrev.value = page.has_prev;
pdfQueuePage.value = page.page;
pdfQueuePageSize.value = String(page.page_size);
} finally {
refreshingPdfQueue.value = false;
}
}
async function onPdfQueueFilterChanged(): Promise<void> {
pdfQueuePage.value = 1;
await refreshPdfQueue();
}
async function onPdfQueuePageSizeChanged(): Promise<void> {
pdfQueuePage.value = 1;
await refreshPdfQueue();
}
async function onPdfQueuePrevPage(): Promise<void> {
if (!pdfQueueHasPrev.value || pdfQueuePage.value <= 1) return;
pdfQueuePage.value = Math.max(pdfQueuePage.value - 1, 1);
await refreshPdfQueue();
}
async function onPdfQueueNextPage(): Promise<void> {
if (!pdfQueueHasNext.value) return;
pdfQueuePage.value += 1;
await refreshPdfQueue();
}
async function onRequeuePdf(item: AdminPdfQueueItem): Promise<void> {
requeueingPublicationId.value = item.publication_id;
clearAlerts();
try {
const result = await requeueAdminPdfLookup(item.publication_id);
setSuccess(result.message);
await refreshPdfQueue();
} catch (error) {
assignError(error, "Unable to requeue PDF lookup.");
} finally {
requeueingPublicationId.value = null;
}
}
async function onRequeueAllPdfs(): Promise<void> {
requeueingAllPdfs.value = true;
clearAlerts();
try {
const result = await requeueAllAdminPdfLookups(5000);
setSuccess(result.message);
await refreshPdfQueue();
} catch (error) {
assignError(error, "Unable to queue missing PDF lookups.");
} finally {
requeueingAllPdfs.value = false;
}
}
async function load(): Promise<void> {
await refreshPdfQueue();
}
defineExpose({ load });
</script>
<template>
<AppCard class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">PDF Gathering Queue</h2>
<AppHelpHint text="Live queue and outcome history for PDF acquisition across all publications." />
</div>
<div class="flex items-center gap-2">
<AppSelect v-model="pdfQueueStatusFilter" class="min-w-[12rem] !py-1.5 !text-sm" @change="onPdfQueueFilterChanged">
<option value="">All statuses</option>
<option value="untracked">untracked</option>
<option value="queued">queued</option>
<option value="running">running</option>
<option value="failed">failed</option>
<option value="resolved">resolved</option>
</AppSelect>
<AppSelect v-model="pdfQueuePageSize" class="min-w-[10rem] !py-1.5 !text-sm" @change="onPdfQueuePageSizeChanged">
<option value="25">25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</AppSelect>
<AppButton variant="secondary" class="!min-h-8 whitespace-nowrap !px-2.5 !py-1 !text-xs" :disabled="requeueingAllPdfs" title="Queue all missing PDFs" @click="onRequeueAllPdfs">
{{ requeueingAllPdfs ? "Queueing..." : "Queue all" }}
</AppButton>
<AppRefreshButton variant="secondary" size="sm" :loading="refreshingPdfQueue" title="Refresh PDF queue" loading-title="Refreshing PDF queue" @click="refreshPdfQueue" />
</div>
</div>
<AppTable label="PDF queue table">
<thead>
<tr>
<th scope="col">Publication</th><th scope="col">Status</th><th scope="col">Attempts</th>
<th scope="col">Failure reason</th><th scope="col">Source</th><th scope="col">Requested by</th>
<th scope="col">Queued</th><th scope="col">Last attempt</th><th scope="col">Resolved</th><th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in pdfQueueItems" :key="item.publication_id">
<td>
<div class="grid gap-1">
<span class="font-medium text-ink-primary">{{ item.title }}</span>
<a v-if="item.display_identifier?.url" :href="item.display_identifier.url" target="_blank" rel="noreferrer" class="link-inline text-xs">{{ item.display_identifier.label }}</a>
</div>
</td>
<td><AppBadge :tone="statusTone(item.status)">{{ item.status }}</AppBadge></td>
<td>{{ item.attempt_count }}</td>
<td>{{ item.last_failure_reason || "n/a" }}</td>
<td>{{ item.last_source || "n/a" }}</td>
<td>{{ item.requested_by_email || "n/a" }}</td>
<td>{{ formatTimestamp(item.queued_at) }}</td>
<td>{{ formatTimestamp(item.last_attempt_at) }}</td>
<td>{{ formatTimestamp(item.resolved_at) }}</td>
<td>
<AppButton variant="ghost" :disabled="requeueingPublicationId === item.publication_id || !canRequeuePdf(item)" @click="onRequeuePdf(item)">
{{ requeueingPublicationId === item.publication_id ? "Requeueing..." : "Requeue" }}
</AppButton>
</td>
</tr>
</tbody>
</AppTable>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-secondary">
<span>{{ pdfQueueSummary }}</span>
<div class="flex items-center gap-2">
<span>Page {{ pdfQueuePage }} / {{ pdfQueueTotalPages }}</span>
<AppButton variant="ghost" :disabled="!pdfQueueHasPrev" @click="onPdfQueuePrevPage">Prev</AppButton>
<AppButton variant="ghost" :disabled="!pdfQueueHasNext" @click="onPdfQueueNextPage">Next</AppButton>
</div>
</div>
</AppCard>
</template>

View file

@ -0,0 +1,91 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import AdminRepairsSection from "./AdminRepairsSection.vue";
vi.mock("@/features/admin_dbops", () => ({
listAdminDbRepairJobs: vi.fn(),
triggerPublicationLinkRepair: vi.fn(),
triggerPublicationNearDuplicateRepair: vi.fn(),
dropAllPublications: vi.fn(),
}));
import { listAdminDbRepairJobs, dropAllPublications } from "@/features/admin_dbops";
const mockedListJobs = vi.mocked(listAdminDbRepairJobs);
const mockedDrop = vi.mocked(dropAllPublications);
function buildUser(overrides: Record<string, unknown> = {}) {
return {
id: 1,
email: "admin@test.com",
is_admin: true,
is_active: true,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
...overrides,
};
}
describe("AdminRepairsSection", () => {
beforeEach(() => {
mockedListJobs.mockReset();
mockedDrop.mockReset();
});
it("renders the repair sections", () => {
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
expect(wrapper.text()).toContain("Publication Link Repair");
expect(wrapper.text()).toContain("Near-Duplicate");
expect(wrapper.text()).toContain("Drop All Publications");
});
it("exposes load function that fetches repair jobs", async () => {
mockedListJobs.mockResolvedValueOnce([]);
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(mockedListJobs).toHaveBeenCalled();
});
it("renders repair job rows after loading", async () => {
mockedListJobs.mockResolvedValueOnce([
{
id: 1,
job_name: "publication_link_repair",
status: "completed",
dry_run: true,
requested_by: "admin@test.com",
scope: {},
summary: { links_in_scope: 100, links_deleted: 5 },
error_text: null,
started_at: "2025-01-15T12:00:00Z",
finished_at: "2025-01-15T12:01:00Z",
created_at: "2025-01-15T12:00:00Z",
updated_at: "2025-01-15T12:01:00Z",
},
]);
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(wrapper.text()).toContain("publication_link_repair");
});
it("has typed confirmation guard for drop all publications", () => {
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
const dropButton = wrapper.findAll("button").find((b) => b.text().includes("Drop all publications"));
expect(dropButton?.attributes("disabled")).toBeDefined();
});
it("renders dry-run toggle in repair form", () => {
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
expect(wrapper.text()).toContain("Dry-run");
});
it("renders scope mode selector", () => {
const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
expect(wrapper.text()).toContain("Scope");
});
});

View file

@ -0,0 +1,372 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import AppBadge from "@/components/ui/AppBadge.vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppInput from "@/components/ui/AppInput.vue";
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
import AppSelect from "@/components/ui/AppSelect.vue";
import AppTable from "@/components/ui/AppTable.vue";
import {
dropAllPublications,
listAdminDbRepairJobs,
triggerPublicationLinkRepair,
triggerPublicationNearDuplicateRepair,
type AdminDbRepairJob,
type NearDuplicateCluster,
type TriggerPublicationLinkRepairResult,
type TriggerPublicationNearDuplicateRepairResult,
} from "@/features/admin_dbops";
import type { AdminUser } from "@/features/admin_users";
import { useRequestState } from "@/composables/useRequestState";
const props = defineProps<{
users: AdminUser[];
}>();
const SCOPE_SINGLE_USER = "single_user";
const SCOPE_ALL_USERS = "all_users";
const APPLY_ALL_USERS_CONFIRM_TEXT = "REPAIR ALL USERS";
const APPLY_NEAR_DUPLICATES_CONFIRM_TEXT = "MERGE SELECTED DUPLICATES";
const DROP_PUBLICATIONS_CONFIRM_TEXT = "DROP ALL PUBLICATIONS";
type RepairScopeMode = typeof SCOPE_SINGLE_USER | typeof SCOPE_ALL_USERS;
const { clearAlerts, assignError, setSuccess } = useRequestState();
const refreshingJobs = ref(false);
const runningRepair = ref(false);
const repairJobs = ref<AdminDbRepairJob[]>([]);
const lastRepairResult = ref<TriggerPublicationLinkRepairResult | null>(null);
const repairScopeMode = ref<RepairScopeMode>(SCOPE_SINGLE_USER);
const repairUserId = ref("");
const repairScholarIds = ref("");
const repairRequestedBy = ref("");
const repairDryRun = ref(true);
const repairGcOrphans = ref(false);
const repairConfirmationText = ref("");
const runningNearDuplicateScan = ref(false);
const applyingNearDuplicateRepair = ref(false);
const nearDuplicateRequestedBy = ref("");
const nearDuplicateSimilarityThreshold = ref("0.78");
const nearDuplicateMinSharedTokens = ref("3");
const nearDuplicateMaxYearDelta = ref("1");
const nearDuplicateMaxClusters = ref("25");
const nearDuplicateConfirmationText = ref("");
const nearDuplicateSelectedClusterKeys = ref<Set<string>>(new Set());
const nearDuplicateClusters = ref<NearDuplicateCluster[]>([]);
const lastNearDuplicateResult = ref<TriggerPublicationNearDuplicateRepairResult | null>(null);
const droppingPublications = ref(false);
const dropConfirmationText = ref("");
const dropResult = ref<{ deleted_count: number; message: string } | null>(null);
const dropConfirmationValid = computed(() => dropConfirmationText.value.trim() === DROP_PUBLICATIONS_CONFIRM_TEXT);
const typedConfirmationRequired = computed(
() => repairScopeMode.value === SCOPE_ALL_USERS && !repairDryRun.value,
);
const nearDuplicateApplyEnabled = computed(() => nearDuplicateSelectedClusterKeys.value.size > 0);
function statusTone(status: string): "success" | "warning" | "danger" | "info" | "neutral" {
if (status === "ok" || status === "completed" || status === "resolved") return "success";
if (status === "warning" || status === "running" || status === "queued") return "warning";
if (status === "failed") return "danger";
return "info";
}
function formatTimestamp(value: string | null): string {
if (!value) return "n/a";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function parseScholarIds(raw: string): number[] {
const tokens = raw.split(/[,\s]+/).map((t) => t.trim()).filter((t) => t.length > 0);
const deduped = new Set<number>();
for (const token of tokens) {
const parsed = Number(token);
if (!Number.isInteger(parsed) || parsed < 1) throw new Error("Scholar profile IDs must be positive integers.");
deduped.add(parsed);
}
return [...deduped];
}
function parseRepairUserIdOrThrow(raw: string): number {
const parsed = Number((raw || "").trim());
if (!Number.isInteger(parsed) || parsed < 1) throw new Error("Select a valid target user.");
return parsed;
}
function validateTypedConfirmation(): string {
const normalized = repairConfirmationText.value.trim();
if (typedConfirmationRequired.value && normalized !== APPLY_ALL_USERS_CONFIRM_TEXT) {
throw new Error(`Type '${APPLY_ALL_USERS_CONFIRM_TEXT}' to apply repair for all users.`);
}
return normalized;
}
function parseBoundedNumber(raw: string, opts: { minimum: number; maximum: number; fallback: number }): number {
const parsed = Number(raw.trim());
if (!Number.isFinite(parsed)) return opts.fallback;
return Math.max(opts.minimum, Math.min(opts.maximum, parsed));
}
function nearDuplicatePayloadBase() {
return {
similarity_threshold: parseBoundedNumber(nearDuplicateSimilarityThreshold.value, { minimum: 0.5, maximum: 1.0, fallback: 0.78 }),
min_shared_tokens: Math.trunc(parseBoundedNumber(nearDuplicateMinSharedTokens.value, { minimum: 1, maximum: 8, fallback: 3 })),
max_year_delta: Math.trunc(parseBoundedNumber(nearDuplicateMaxYearDelta.value, { minimum: 0, maximum: 5, fallback: 1 })),
max_clusters: Math.trunc(parseBoundedNumber(nearDuplicateMaxClusters.value, { minimum: 1, maximum: 200, fallback: 25 })),
requested_by: nearDuplicateRequestedBy.value.trim() || undefined,
};
}
function checkboxEventChecked(event: Event): boolean {
return event.target instanceof HTMLInputElement ? event.target.checked : false;
}
function summaryCount(job: AdminDbRepairJob, key: string): string {
const value = job.summary[key];
return typeof value === "number" ? String(value) : "n/a";
}
function ensureRepairUserSelected(): void {
if (repairScopeMode.value !== SCOPE_SINGLE_USER || repairUserId.value || props.users.length === 0) return;
repairUserId.value = String(props.users[0].id);
}
async function refreshRepairJobs(): Promise<void> {
refreshingJobs.value = true;
try { repairJobs.value = await listAdminDbRepairJobs(30); } finally { refreshingJobs.value = false; }
}
async function onRunRepair(): Promise<void> {
runningRepair.value = true;
clearAlerts();
try {
const payload = {
scope_mode: repairScopeMode.value,
scholar_profile_ids: parseScholarIds(repairScholarIds.value),
dry_run: repairDryRun.value,
gc_orphan_publications: repairGcOrphans.value,
requested_by: repairRequestedBy.value.trim() || undefined,
confirmation_text: validateTypedConfirmation() || undefined,
user_id: repairScopeMode.value === SCOPE_SINGLE_USER ? parseRepairUserIdOrThrow(repairUserId.value) : undefined,
};
const result = await triggerPublicationLinkRepair(payload);
lastRepairResult.value = result;
setSuccess(`Repair job #${result.job_id} completed (${result.status}).`);
repairConfirmationText.value = "";
await refreshRepairJobs();
} catch (error) {
assignError(error, "Unable to run publication link repair.");
} finally {
runningRepair.value = false;
}
}
function onToggleNearDuplicateClusterSelection(clusterKey: string, checked: boolean): void {
const next = new Set(nearDuplicateSelectedClusterKeys.value);
if (checked) { next.add(clusterKey); } else { next.delete(clusterKey); }
nearDuplicateSelectedClusterKeys.value = next;
}
async function onRunNearDuplicateScan(): Promise<void> {
runningNearDuplicateScan.value = true;
clearAlerts();
try {
const result = await triggerPublicationNearDuplicateRepair({ dry_run: true, ...nearDuplicatePayloadBase() });
nearDuplicateClusters.value = result.clusters;
nearDuplicateSelectedClusterKeys.value = new Set();
nearDuplicateConfirmationText.value = "";
lastNearDuplicateResult.value = result;
setSuccess(`Near-duplicate scan completed (job #${result.job_id}).`);
await refreshRepairJobs();
} catch (error) {
assignError(error, "Unable to scan for near-duplicate publications.");
} finally {
runningNearDuplicateScan.value = false;
}
}
async function onApplyNearDuplicateRepair(): Promise<void> {
applyingNearDuplicateRepair.value = true;
clearAlerts();
try {
const selectedKeys = [...nearDuplicateSelectedClusterKeys.value].sort((a, b) => a.localeCompare(b));
if (selectedKeys.length === 0) throw new Error("Select at least one near-duplicate cluster before applying.");
if (nearDuplicateConfirmationText.value.trim() !== APPLY_NEAR_DUPLICATES_CONFIRM_TEXT) {
throw new Error(`Type '${APPLY_NEAR_DUPLICATES_CONFIRM_TEXT}' to confirm merge.`);
}
const result = await triggerPublicationNearDuplicateRepair({
dry_run: false,
selected_cluster_keys: selectedKeys,
confirmation_text: nearDuplicateConfirmationText.value.trim(),
...nearDuplicatePayloadBase(),
});
nearDuplicateClusters.value = result.clusters;
nearDuplicateSelectedClusterKeys.value = new Set();
nearDuplicateConfirmationText.value = "";
lastNearDuplicateResult.value = result;
setSuccess(`Merged selected near-duplicate clusters (job #${result.job_id}).`);
await refreshRepairJobs();
} catch (error) {
assignError(error, "Unable to apply near-duplicate merge.");
} finally {
applyingNearDuplicateRepair.value = false;
}
}
async function onDropAllPublications(): Promise<void> {
droppingPublications.value = true;
clearAlerts();
dropResult.value = null;
try {
const result = await dropAllPublications(dropConfirmationText.value.trim());
dropResult.value = result;
setSuccess(result.message);
dropConfirmationText.value = "";
} catch (error) {
assignError(error, "Unable to drop publications.");
} finally {
droppingPublications.value = false;
}
}
async function load(): Promise<void> {
await refreshRepairJobs();
ensureRepairUserSelected();
}
defineExpose({ load });
</script>
<template>
<section class="grid gap-4">
<AppCard class="space-y-3">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Publication Link Repair</h2>
<AppHelpHint text="Dry-run first. For all-users apply mode, typed confirmation is required." />
</div>
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onRunRepair">
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
<span>Scope</span>
<AppSelect v-model="repairScopeMode" @change="ensureRepairUserSelected">
<option :value="SCOPE_SINGLE_USER">Single user</option>
<option :value="SCOPE_ALL_USERS">All users</option>
</AppSelect>
</label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
<span>Target user</span>
<AppSelect v-model="repairUserId" :disabled="repairScopeMode === SCOPE_ALL_USERS || users.length === 0">
<option value="" disabled>Select user</option>
<option v-for="user in users" :key="user.id" :value="String(user.id)">{{ user.email }} (ID {{ user.id }})</option>
</AppSelect>
</label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
<span>Scholar profile IDs (optional)</span>
<AppInput v-model="repairScholarIds" placeholder="e.g. 12,13,14" />
</label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
<span>Requested by (optional)</span>
<AppInput v-model="repairRequestedBy" placeholder="email/name/ticket id" />
</label>
<div class="flex flex-wrap items-center gap-4 md:col-span-2">
<AppCheckbox id="repair-dry-run" v-model="repairDryRun" label="Dry-run (no writes)" />
<AppCheckbox id="repair-gc-orphans" v-model="repairGcOrphans" label="Delete orphan publications" />
</div>
<label v-if="typedConfirmationRequired" class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
<span>Type '{{ APPLY_ALL_USERS_CONFIRM_TEXT }}' to confirm</span>
<AppInput v-model="repairConfirmationText" :placeholder="APPLY_ALL_USERS_CONFIRM_TEXT" />
</label>
<p v-if="repairScopeMode === SCOPE_ALL_USERS" class="text-xs text-secondary md:col-span-2">All-users scope includes every scholar profile across all accounts.</p>
<div class="md:col-span-2">
<AppButton type="submit" :disabled="runningRepair">{{ runningRepair ? "Running..." : "Run repair job" }}</AppButton>
</div>
</form>
<div v-if="lastRepairResult" class="rounded-lg border border-stroke-default bg-surface-card-muted p-3 text-xs">
<div class="mb-2 flex flex-wrap items-center gap-2">
<AppBadge :tone="statusTone(lastRepairResult.status)">Job #{{ lastRepairResult.job_id }}</AppBadge>
<span class="text-secondary">Status: {{ lastRepairResult.status }}</span>
</div>
<pre class="overflow-x-auto text-secondary">{{ JSON.stringify(lastRepairResult.summary, null, 2) }}</pre>
</div>
</AppCard>
<AppCard class="space-y-3">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Near-Duplicate Publication Repair</h2>
<AppHelpHint text="Run a dry-run scan first, verify candidate clusters, then merge only selected clusters." />
</div>
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onRunNearDuplicateScan">
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Similarity threshold</span><AppInput v-model="nearDuplicateSimilarityThreshold" placeholder="0.78" /></label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Min shared tokens</span><AppInput v-model="nearDuplicateMinSharedTokens" placeholder="3" /></label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Max year delta</span><AppInput v-model="nearDuplicateMaxYearDelta" placeholder="1" /></label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Max preview clusters</span><AppInput v-model="nearDuplicateMaxClusters" placeholder="25" /></label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2"><span>Requested by (optional)</span><AppInput v-model="nearDuplicateRequestedBy" placeholder="email/name/ticket id" /></label>
<div class="md:col-span-2">
<AppButton type="submit" :disabled="runningNearDuplicateScan">{{ runningNearDuplicateScan ? "Scanning..." : "Scan near-duplicate clusters" }}</AppButton>
</div>
</form>
<div v-if="nearDuplicateClusters.length > 0" class="space-y-3">
<AppTable label="Near duplicate clusters table">
<thead><tr><th scope="col">Select</th><th scope="col">Cluster</th><th scope="col">Winner</th><th scope="col">Members</th><th scope="col">Similarity</th></tr></thead>
<tbody>
<tr v-for="cluster in nearDuplicateClusters" :key="cluster.cluster_key">
<td><input :id="`near-dup-${cluster.cluster_key}`" class="h-4 w-4 rounded border-stroke-default bg-surface-card" type="checkbox" :checked="nearDuplicateSelectedClusterKeys.has(cluster.cluster_key)" @change="onToggleNearDuplicateClusterSelection(cluster.cluster_key, checkboxEventChecked($event))" /></td>
<td class="font-mono text-xs">{{ cluster.cluster_key }}</td>
<td>#{{ cluster.winner_publication_id }}</td>
<td><div class="grid gap-1"><span v-for="member in cluster.members" :key="member.publication_id" class="text-xs text-secondary">#{{ member.publication_id }} · {{ member.title }}</span></div></td>
<td>{{ cluster.similarity_score.toFixed(2) }}</td>
</tr>
</tbody>
</AppTable>
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onApplyNearDuplicateRepair">
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2"><span>Type '{{ APPLY_NEAR_DUPLICATES_CONFIRM_TEXT }}' to merge selected clusters</span><AppInput v-model="nearDuplicateConfirmationText" :placeholder="APPLY_NEAR_DUPLICATES_CONFIRM_TEXT" /></label>
<div class="md:col-span-2"><AppButton type="submit" :disabled="applyingNearDuplicateRepair || !nearDuplicateApplyEnabled">{{ applyingNearDuplicateRepair ? "Merging..." : "Merge selected clusters" }}</AppButton></div>
</form>
</div>
<div v-if="lastNearDuplicateResult" class="rounded-lg border border-stroke-default bg-surface-card-muted p-3 text-xs">
<div class="mb-2 flex flex-wrap items-center gap-2"><AppBadge :tone="statusTone(lastNearDuplicateResult.status)">Job #{{ lastNearDuplicateResult.job_id }}</AppBadge><span class="text-secondary">Status: {{ lastNearDuplicateResult.status }}</span></div>
<pre class="overflow-x-auto text-secondary">{{ JSON.stringify(lastNearDuplicateResult.summary, null, 2) }}</pre>
</div>
</AppCard>
<AppCard class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-1"><h2 class="text-lg font-semibold text-ink-primary">Recent Repair Jobs</h2><AppHelpHint text="Audit history and summary counters for each repair job." /></div>
<AppRefreshButton variant="secondary" :loading="refreshingJobs" title="Refresh repair jobs" loading-title="Refreshing repair jobs" @click="refreshRepairJobs" />
</div>
<AppTable label="Repair jobs table">
<thead><tr><th scope="col">Job</th><th scope="col">Status</th><th scope="col">Mode</th><th scope="col">Requested by</th><th scope="col">Created</th><th scope="col">Links in scope</th><th scope="col">Links deleted</th></tr></thead>
<tbody>
<tr v-for="job in repairJobs" :key="job.id">
<td>#{{ job.id }} · {{ job.job_name }}</td>
<td><AppBadge :tone="statusTone(job.status)">{{ job.status }}</AppBadge></td>
<td>{{ job.dry_run ? "dry-run" : "apply" }}</td>
<td>{{ job.requested_by || "n/a" }}</td>
<td>{{ formatTimestamp(job.created_at) }}</td>
<td>{{ summaryCount(job, "links_in_scope") }}</td>
<td>{{ summaryCount(job, "links_deleted") }}</td>
</tr>
</tbody>
</AppTable>
</AppCard>
<AppCard class="space-y-3 border-state-danger-border">
<div class="flex items-center gap-1"><h2 class="text-lg font-semibold text-state-danger-text">Drop All Publications</h2><AppHelpHint text="Permanently delete ALL publications, links, identifiers, and PDF jobs. Scholar baselines will be reset so the next run re-discovers everything." /></div>
<p class="text-sm text-secondary">This action is <strong>irreversible</strong>. It deletes every publication record across all users. The next ingestion run will re-populate all data from scratch.</p>
<form class="grid gap-3" @submit.prevent="onDropAllPublications">
<label class="grid gap-1 text-sm font-medium text-ink-secondary"><span>Type '{{ DROP_PUBLICATIONS_CONFIRM_TEXT }}' to confirm</span><AppInput v-model="dropConfirmationText" :placeholder="DROP_PUBLICATIONS_CONFIRM_TEXT" autocomplete="off" /></label>
<div><AppButton type="submit" variant="danger" :disabled="droppingPublications || !dropConfirmationValid">{{ droppingPublications ? "Dropping..." : "Drop all publications" }}</AppButton></div>
</form>
<div v-if="dropResult" class="rounded-lg border border-stroke-default bg-surface-card-muted p-3 text-xs">
<div class="mb-1 flex items-center gap-2"><AppBadge tone="danger">Deleted: {{ dropResult.deleted_count }}</AppBadge></div>
<p class="text-secondary">{{ dropResult.message }}</p>
</div>
</AppCard>
</section>
</template>

View file

@ -0,0 +1,84 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import AdminUsersSection from "./AdminUsersSection.vue";
vi.mock("@/features/admin_users", () => ({
listAdminUsers: vi.fn(),
createAdminUser: vi.fn(),
setAdminUserActive: vi.fn(),
resetAdminUserPassword: vi.fn(),
}));
import { listAdminUsers, createAdminUser } from "@/features/admin_users";
const mockedListUsers = vi.mocked(listAdminUsers);
const mockedCreateUser = vi.mocked(createAdminUser);
function buildUser(overrides: Record<string, unknown> = {}) {
return {
id: 1,
email: "user@example.com",
is_admin: false,
is_active: true,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
...overrides,
};
}
describe("AdminUsersSection", () => {
beforeEach(() => {
mockedListUsers.mockReset();
mockedCreateUser.mockReset();
});
it("renders the create user form", () => {
const wrapper = mount(AdminUsersSection);
expect(wrapper.text()).toContain("Create user");
});
it("exposes load function that calls listAdminUsers", async () => {
mockedListUsers.mockResolvedValueOnce([buildUser()]);
const wrapper = mount(AdminUsersSection);
const vm = wrapper.vm as unknown as { load: () => Promise<void>; users: unknown[] };
await vm.load();
await flushPromises();
expect(mockedListUsers).toHaveBeenCalled();
expect(vm.users).toHaveLength(1);
});
it("renders user table after loading", async () => {
mockedListUsers.mockResolvedValueOnce([buildUser({ email: "alice@test.com" })]);
const wrapper = mount(AdminUsersSection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await vm.load();
await flushPromises();
expect(wrapper.text()).toContain("alice@test.com");
});
it("propagates error when load fails", async () => {
mockedListUsers.mockRejectedValueOnce(new Error("Network error"));
const wrapper = mount(AdminUsersSection);
const vm = wrapper.vm as unknown as { load: () => Promise<void> };
await expect(vm.load()).rejects.toThrow("Network error");
});
it("emits user creation via form submit", async () => {
mockedCreateUser.mockResolvedValueOnce(buildUser({ email: "new@test.com" }));
mockedListUsers.mockResolvedValue([]);
const wrapper = mount(AdminUsersSection);
const inputs = wrapper.findAll("input");
const emailInput = inputs.find((i) => (i.element as HTMLInputElement).type === "email");
const passwordInput = inputs.find((i) => (i.element as HTMLInputElement).type === "password");
if (emailInput && passwordInput) {
await emailInput.setValue("new@test.com");
await passwordInput.setValue("securepassword123");
await wrapper.find("form").trigger("submit");
await flushPromises();
expect(mockedCreateUser).toHaveBeenCalled();
}
});
});

View file

@ -0,0 +1,211 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppInput from "@/components/ui/AppInput.vue";
import AppModal from "@/components/ui/AppModal.vue";
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
import AppTable from "@/components/ui/AppTable.vue";
import {
createAdminUser,
listAdminUsers,
resetAdminUserPassword,
setAdminUserActive,
type AdminUser,
} from "@/features/admin_users";
import { useRequestState } from "@/composables/useRequestState";
const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError, setSuccess } = useRequestState();
const refreshingUsers = ref(false);
const creating = ref(false);
const togglingUserId = ref<number | null>(null);
const resettingPassword = ref(false);
const users = ref<AdminUser[]>([]);
const email = ref("");
const password = ref("");
const createIsAdmin = ref(false);
const activeUserId = ref<number | null>(null);
const resetPassword = ref("");
const activeUser = computed(() => users.value.find((u) => u.id === activeUserId.value) ?? null);
function userRoleLabel(user: AdminUser): string {
return user.is_admin ? "Admin" : "User";
}
function statusDotClass(user: AdminUser): string {
return user.is_active ? "bg-success-500 ring-success-200" : "bg-ink-muted/70 ring-stroke-default";
}
function formatTimestamp(value: string | null): string {
if (!value) return "n/a";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function openUserModal(user: AdminUser): void {
activeUserId.value = user.id;
resetPassword.value = "";
}
function closeUserModal(): void {
activeUserId.value = null;
resetPassword.value = "";
}
async function refreshUsers(): Promise<void> {
refreshingUsers.value = true;
try {
users.value = await listAdminUsers();
if (activeUserId.value !== null && !users.value.some((item) => item.id === activeUserId.value)) {
closeUserModal();
}
} finally {
refreshingUsers.value = false;
}
}
async function onCreateUser(): Promise<void> {
creating.value = true;
clearAlerts();
try {
if (!email.value.trim() || !password.value) throw new Error("Email and password are required.");
const created = await createAdminUser({ email: email.value.trim(), password: password.value, is_admin: createIsAdmin.value });
email.value = "";
password.value = "";
createIsAdmin.value = false;
setSuccess(`User created: ${created.email}`);
await refreshUsers();
} catch (error) {
assignError(error, "Unable to create user.");
} finally {
creating.value = false;
}
}
async function onToggleUser(user: AdminUser): Promise<void> {
togglingUserId.value = user.id;
clearAlerts();
try {
const updated = await setAdminUserActive(user.id, !user.is_active);
setSuccess(`${updated.email} is now ${updated.is_active ? "active" : "inactive"}.`);
await refreshUsers();
} catch (error) {
assignError(error, "Unable to update user.");
} finally {
togglingUserId.value = null;
}
}
async function onResetPassword(): Promise<void> {
const user = activeUser.value;
if (!user) return;
resettingPassword.value = true;
clearAlerts();
try {
const candidate = resetPassword.value.trim();
if (candidate.length < 12) throw new Error("New password must be at least 12 characters.");
const result = await resetAdminUserPassword(user.id, candidate);
resetPassword.value = "";
setSuccess(result.message || `Password reset for ${user.email}.`);
} catch (error) {
assignError(error, "Unable to reset password.");
} finally {
resettingPassword.value = false;
}
}
async function load(): Promise<void> {
await refreshUsers();
}
defineExpose({ load, errorMessage, errorRequestId, successMessage, users });
</script>
<template>
<AppCard class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Users</h2>
<AppHelpHint text="Create accounts, toggle active status, and reset passwords." />
</div>
<AppRefreshButton variant="secondary" :loading="refreshingUsers" title="Refresh users" loading-title="Refreshing users" @click="refreshUsers" />
</div>
<form class="grid gap-3 md:grid-cols-3" @submit.prevent="onCreateUser">
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
<span>Email</span>
<AppInput v-model="email" type="email" autocomplete="off" />
</label>
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
<span>Password</span>
<AppInput v-model="password" type="password" autocomplete="new-password" />
</label>
<div class="grid gap-2 text-sm font-medium text-ink-secondary">
<span>Role</span>
<div class="flex items-center gap-3">
<AppCheckbox id="admin-create-user-is-admin" v-model="createIsAdmin" label="Grant admin" />
<AppButton type="submit" :disabled="creating">{{ creating ? "Creating..." : "Create user" }}</AppButton>
</div>
</div>
</form>
<AppTable label="Users table">
<thead>
<tr>
<th scope="col">Email</th>
<th scope="col">Role</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td class="align-middle">
<button type="button" class="group inline-flex items-center gap-2 rounded-md px-1 py-0.5 text-left text-ink-primary transition hover:bg-surface-card-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset" @click="openUserModal(user)">
<span :class="statusDotClass(user)" class="h-2.5 w-2.5 rounded-full ring-2" :aria-label="user.is_active ? 'Active user' : 'Inactive user'" />
<span class="underline-offset-2 group-hover:underline">{{ user.email }}</span>
</button>
</td>
<td>{{ userRoleLabel(user) }}</td>
<td>{{ user.is_active ? "active" : "inactive" }}</td>
<td class="flex flex-wrap items-center gap-2">
<AppButton variant="secondary" :disabled="togglingUserId === user.id" @click="onToggleUser(user)">
{{ togglingUserId === user.id ? "Updating..." : user.is_active ? "Deactivate" : "Activate" }}
</AppButton>
<AppButton variant="ghost" @click="openUserModal(user)">Manage</AppButton>
</td>
</tr>
</tbody>
</AppTable>
</AppCard>
<AppModal :open="activeUser !== null" title="User settings" @close="closeUserModal">
<div v-if="activeUser" class="grid gap-4">
<div class="space-y-1">
<div class="flex items-center gap-2">
<span :class="statusDotClass(activeUser)" class="h-2.5 w-2.5 rounded-full ring-2" :aria-label="activeUser.is_active ? 'Active user' : 'Inactive user'" />
<p class="truncate text-sm font-semibold text-ink-primary">{{ activeUser.email }}</p>
</div>
<p class="text-sm text-secondary">Role: {{ userRoleLabel(activeUser) }}</p>
<p class="text-xs text-secondary">Last updated: {{ formatTimestamp(activeUser.updated_at) }}</p>
</div>
<div class="grid gap-2">
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
<span>New password</span>
<AppInput id="admin-reset-password" v-model="resetPassword" type="password" autocomplete="new-password" placeholder="At least 12 characters" />
</label>
<div class="flex flex-wrap gap-2">
<AppButton :disabled="resettingPassword" @click="onResetPassword">{{ resettingPassword ? "Resetting..." : "Reset password" }}</AppButton>
<AppButton variant="secondary" :disabled="togglingUserId === activeUser.id" @click="onToggleUser(activeUser)">
{{ togglingUserId === activeUser.id ? "Updating..." : activeUser.is_active ? "Deactivate user" : "Activate user" }}
</AppButton>
</div>
</div>
</div>
</AppModal>
</template>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff