add intelligence features; implement signals and predictions pages in admin panel
This commit is contained in:
parent
4ffd31c2ab
commit
bec6763191
26 changed files with 3265 additions and 3128 deletions
2601
admin.html
2601
admin.html
File diff suppressed because it is too large
Load diff
761
package-lock.json
generated
761
package-lock.json
generated
|
|
@ -11,26 +11,16 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@extractus/article-extractor": "^8.0.18",
|
"@extractus/article-extractor": "^8.0.18",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/static": "^9.1.3",
|
||||||
"@google-cloud/bigquery": "^8.1.1",
|
"@google-cloud/bigquery": "^8.1.1",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"fastify": "^5.6.1",
|
"fastify": "^5.6.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"playwright": "^1.59.1",
|
"playwright": "^1.59.1",
|
||||||
"rss-parser": "^3.13.0",
|
"rss-parser": "^3.13.0",
|
||||||
"sharp": "^0.34.5",
|
|
||||||
"sqlite-vec": "^0.1.9"
|
"sqlite-vec": "^0.1.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@extractus/article-extractor": {
|
"node_modules/@extractus/article-extractor": {
|
||||||
"version": "8.0.20",
|
"version": "8.0.20",
|
||||||
"resolved": "https://registry.npmjs.org/@extractus/article-extractor/-/article-extractor-8.0.20.tgz",
|
"resolved": "https://registry.npmjs.org/@extractus/article-extractor/-/article-extractor-8.0.20.tgz",
|
||||||
|
|
@ -47,6 +37,22 @@
|
||||||
"node": ">= 20"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/accept-negotiator": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@fastify/ajv-compiler": {
|
"node_modules/@fastify/ajv-compiler": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
|
||||||
|
|
@ -178,6 +184,53 @@
|
||||||
"ipaddr.js": "^2.1.0"
|
"ipaddr.js": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/send": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lukeed/ms": "^2.0.2",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"fast-decode-uri-component": "^1.0.1",
|
||||||
|
"http-errors": "^2.0.0",
|
||||||
|
"mime": "^3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/static": {
|
||||||
|
"version": "9.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz",
|
||||||
|
"integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/accept-negotiator": "^2.0.0",
|
||||||
|
"@fastify/send": "^4.0.0",
|
||||||
|
"content-disposition": "^1.0.1",
|
||||||
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"fastq": "^1.17.1",
|
||||||
|
"glob": "^13.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@google-cloud/bigquery": {
|
"node_modules/@google-cloud/bigquery": {
|
||||||
"version": "8.1.1",
|
"version": "8.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@google-cloud/bigquery/-/bigquery-8.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@google-cloud/bigquery/-/bigquery-8.1.1.tgz",
|
||||||
|
|
@ -276,469 +329,13 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/colour": {
|
"node_modules/@lukeed/ms": {
|
||||||
"version": "1.1.0",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=8"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-darwin-arm64": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-darwin-x64": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-arm": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-arm64": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-ppc64": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-riscv64": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-s390x": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-x64": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-wasm32": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
|
||||||
"cpu": [
|
|
||||||
"wasm32"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/runtime": "^1.7.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-win32-arm64": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-win32-ia32": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-win32-x64": {
|
|
||||||
"version": "0.34.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
|
||||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mozilla/readability": {
|
"node_modules/@mozilla/readability": {
|
||||||
|
|
@ -851,6 +448,15 @@
|
||||||
"fastq": "^1.17.1"
|
"fastq": "^1.17.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/balanced-match": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
|
@ -933,6 +539,18 @@
|
||||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/brace-expansion": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/buffer": {
|
"node_modules/buffer": {
|
||||||
"version": "5.7.1",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||||
|
|
@ -969,6 +587,19 @@
|
||||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/content-disposition": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
|
@ -1084,6 +715,15 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/depd": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dequal": {
|
"node_modules/dequal": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
|
|
@ -1199,6 +839,12 @@
|
||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/escape-string-regexp": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
|
|
@ -1472,6 +1118,23 @@
|
||||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/glob": {
|
||||||
|
"version": "13.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
||||||
|
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"minimatch": "^10.2.2",
|
||||||
|
"minipass": "^7.1.3",
|
||||||
|
"path-scurry": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/google-auth-library": {
|
"node_modules/google-auth-library": {
|
||||||
"version": "10.6.2",
|
"version": "10.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
|
||||||
|
|
@ -1551,6 +1214,26 @@
|
||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http-errors": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"inherits": "~2.0.4",
|
||||||
|
"setprototypeof": "~1.2.0",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"toidentifier": "~1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-proxy-agent": {
|
"node_modules/http-proxy-agent": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||||
|
|
@ -1743,6 +1426,27 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "11.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
|
||||||
|
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mimic-response": {
|
"node_modules/mimic-response": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||||
|
|
@ -1755,6 +1459,21 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimatch": {
|
||||||
|
"version": "10.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
|
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^5.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimist": {
|
"node_modules/minimist": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
|
@ -1764,6 +1483,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minipass": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mkdirp-classic": {
|
"node_modules/mkdirp-classic": {
|
||||||
"version": "0.5.3",
|
"version": "0.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||||
|
|
@ -1897,6 +1625,22 @@
|
||||||
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/path-scurry": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lru-cache": "^11.0.0",
|
||||||
|
"minipass": "^7.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|
@ -2288,49 +2032,11 @@
|
||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/sharp": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "0.34.5",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"hasInstallScript": true,
|
"license": "ISC"
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@img/colour": "^1.0.0",
|
|
||||||
"detect-libc": "^2.1.2",
|
|
||||||
"semver": "^7.7.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-darwin-arm64": "0.34.5",
|
|
||||||
"@img/sharp-darwin-x64": "0.34.5",
|
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
|
||||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
|
||||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
|
||||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
|
||||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
|
||||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
|
||||||
"@img/sharp-linux-arm": "0.34.5",
|
|
||||||
"@img/sharp-linux-arm64": "0.34.5",
|
|
||||||
"@img/sharp-linux-ppc64": "0.34.5",
|
|
||||||
"@img/sharp-linux-riscv64": "0.34.5",
|
|
||||||
"@img/sharp-linux-s390x": "0.34.5",
|
|
||||||
"@img/sharp-linux-x64": "0.34.5",
|
|
||||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
|
||||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
|
||||||
"@img/sharp-wasm32": "0.34.5",
|
|
||||||
"@img/sharp-win32-arm64": "0.34.5",
|
|
||||||
"@img/sharp-win32-ia32": "0.34.5",
|
|
||||||
"@img/sharp-win32-x64": "0.34.5"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/simple-concat": {
|
"node_modules/simple-concat": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
|
|
@ -2482,6 +2188,15 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/statuses": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stream-events": {
|
"node_modules/stream-events": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz",
|
||||||
|
|
@ -2603,19 +2318,21 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/toidentifier": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tr46": {
|
"node_modules/tr46": {
|
||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
|
||||||
"version": "2.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/tunnel-agent": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@extractus/article-extractor": "^8.0.18",
|
"@extractus/article-extractor": "^8.0.18",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/static": "^9.1.3",
|
||||||
"@google-cloud/bigquery": "^8.1.1",
|
"@google-cloud/bigquery": "^8.1.1",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"fastify": "^5.6.1",
|
"fastify": "^5.6.1",
|
||||||
|
|
|
||||||
117
public/admin/assets/css/base.css
Normal file
117
public/admin/assets/css/base.css
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
/* design tokens + resets + element defaults shared across every admin page */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #020817;
|
||||||
|
--bg-card: #0f172a;
|
||||||
|
--bg-subtle: #0b1120;
|
||||||
|
--border: #1e293b;
|
||||||
|
--border-light: #162032;
|
||||||
|
--foreground: #f8fafc;
|
||||||
|
--muted: #94a3b8;
|
||||||
|
--muted-dark: #475569;
|
||||||
|
--primary: #f8fafc;
|
||||||
|
--primary-bg: #1e293b;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-hover: #2563eb;
|
||||||
|
--destructive: #7f1d1d;
|
||||||
|
--destructive-fg: #fca5a5;
|
||||||
|
--radius: 6px;
|
||||||
|
--radius-lg: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── inputs / selects ── */
|
||||||
|
|
||||||
|
input[type="text"], input[type="date"], select {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--foreground);
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
min-width: 140px;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus, input[type="date"]:focus, select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, .15);
|
||||||
|
}
|
||||||
|
|
||||||
|
select option { background: #0f172a; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ── buttons ── */
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: var(--primary-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--foreground);
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background .15s, opacity .1s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover { background: #263347; }
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--foreground);
|
||||||
|
color: #0f172a;
|
||||||
|
border-color: transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover { background: #e2e8f0; }
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--destructive);
|
||||||
|
color: var(--destructive-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover { background: rgba(127, 29, 29, .3); }
|
||||||
|
|
||||||
|
button:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
|
||||||
|
/* textarea — shared across article modal and sql console */
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--foreground);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 13px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
min-height: 120px;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, .15);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.url-link { color: #60a5fa; text-decoration: none; }
|
||||||
|
.url-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
a { color: inherit; }
|
||||||
168
public/admin/assets/css/components.css
Normal file
168
public/admin/assets/css/components.css
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
/* reusable building blocks: tables, badges, pagination, dialogs, toasts */
|
||||||
|
|
||||||
|
|
||||||
|
/* ── table ── */
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--muted-dark);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
tr:hover td { background: rgba(255,255,255,.02); }
|
||||||
|
|
||||||
|
|
||||||
|
/* ── truncate ── */
|
||||||
|
|
||||||
|
.truncate {
|
||||||
|
max-width: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── badges ── */
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.ok { background: rgba(20, 83, 45, .5); color: #86efac; border: 1px solid rgba(134,239,172,.15); }
|
||||||
|
.badge.err { background: rgba(127, 29, 29, .5); color: #fca5a5; border: 1px solid rgba(252,165,165,.15); }
|
||||||
|
.badge.pending { background: rgba(30, 58, 95, .5); color: #93c5fd; border: 1px solid rgba(147,197,253,.15); }
|
||||||
|
.badge.null { background: rgba(30, 41, 59, .7); color: #64748b; border: 1px solid var(--border); }
|
||||||
|
|
||||||
|
|
||||||
|
/* ── pagination ── */
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
color: var(--muted-dark);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button { font-size: 12px; padding: 5px 12px; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ── overlay / dialog ── */
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(2, 8, 23, .75);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 100;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay.open { display: flex; }
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 28px;
|
||||||
|
width: 680px;
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 16px -28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field { margin-bottom: 14px; display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[type="text"],
|
||||||
|
.field textarea,
|
||||||
|
.field select { width: 100%; min-width: unset; }
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── toast ── */
|
||||||
|
|
||||||
|
#toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--foreground);
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: none;
|
||||||
|
z-index: 200;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toast.show { display: flex; }
|
||||||
|
#toast .toast-dot { width: 7px; height: 7px; border-radius: 50%; background: #22c55e; flex-shrink: 0; }
|
||||||
|
#toast.error .toast-dot { background: #ef4444; }
|
||||||
534
public/admin/assets/css/intel.css
Normal file
534
public/admin/assets/css/intel.css
Normal file
|
|
@ -0,0 +1,534 @@
|
||||||
|
/* intelligence-specific styling: stat cards, detail body, graph, signals */
|
||||||
|
|
||||||
|
|
||||||
|
/* ── intel stats ── */
|
||||||
|
|
||||||
|
.intel-stat-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 14px 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intel-stat-card .label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-dark);
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intel-stat-card .value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--foreground);
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── intel detail body (modal) ── */
|
||||||
|
|
||||||
|
.intel-body {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-family: "SF Mono", "Fira Code", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── graph view ── */
|
||||||
|
|
||||||
|
#intel-graph-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#intel-graph-svg-wrap {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
height: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#intel-graph-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
#intel-graph-svg:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
|
||||||
|
#graph-controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
#graph-search {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--foreground);
|
||||||
|
padding: 5px 9px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 170px;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
#graph-search:focus { border-color: var(--accent); }
|
||||||
|
#graph-search::placeholder { color: var(--muted-dark); }
|
||||||
|
|
||||||
|
#graph-chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-chip {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: color 120ms, background 120ms, border-color 120ms;
|
||||||
|
}
|
||||||
|
.graph-chip:hover { color: var(--foreground); }
|
||||||
|
.graph-chip.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#graph-legend {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 6px 10px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-legend-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#graph-info {
|
||||||
|
width: 270px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#graph-info-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#graph-info-sector {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 11px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--foreground);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-group-title {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: var(--muted);
|
||||||
|
padding-bottom: 3px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-conn-row {
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-conn-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-conn-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-conn-head:hover .graph-conn-label { color: var(--accent); }
|
||||||
|
|
||||||
|
.graph-conn-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-conn-toggle {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
font-size: 10px;
|
||||||
|
width: 10px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-conn-row.expanded .graph-conn-toggle { color: var(--accent); }
|
||||||
|
|
||||||
|
.graph-conn-sector {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-conn-body {
|
||||||
|
display: none;
|
||||||
|
padding: 4px 0 10px 10px;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-conn-row.expanded .graph-conn-body {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-evidence-loading,
|
||||||
|
.graph-evidence-error,
|
||||||
|
.graph-evidence-empty {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.graph-evidence-error { color: var(--destructive-fg); }
|
||||||
|
|
||||||
|
.graph-evidence-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 2px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-evidence-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-evidence-section {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--muted-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-evidence-fact {
|
||||||
|
padding: 5px 0;
|
||||||
|
border-bottom: 1px dashed var(--border-light);
|
||||||
|
}
|
||||||
|
.graph-evidence-fact:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.graph-evidence-claim {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--foreground);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-evidence-meta {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--muted-dark);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-evidence-event {
|
||||||
|
margin: 4px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-evidence-event > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
.graph-evidence-event > summary::-webkit-details-marker { display: none; }
|
||||||
|
.graph-evidence-event > summary:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
.graph-evidence-event-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-evidence-event-id {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-evidence-articles {
|
||||||
|
list-style: none;
|
||||||
|
padding: 4px 0 6px 10px;
|
||||||
|
margin: 0;
|
||||||
|
border-left: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-evidence-articles li {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-evidence-articles a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.graph-evidence-articles a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.graph-evidence-article-meta {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
font-size: 10px;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-empty-msg {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-node-label {
|
||||||
|
font-size: 11px;
|
||||||
|
fill: var(--foreground);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-node-label-untracked {
|
||||||
|
fill: var(--muted-dark);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ig-label-bg {
|
||||||
|
fill: var(--bg-subtle);
|
||||||
|
fill-opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-conn-row.untracked .graph-conn-head {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.graph-conn-row.untracked .graph-conn-head:hover .graph-conn-label {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.graph-conn-row.untracked .graph-conn-label {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.graph-conn-row.untracked .graph-conn-sector {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── signal cards ── */
|
||||||
|
|
||||||
|
.signal-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-card:hover { border-color: var(--muted-dark); }
|
||||||
|
|
||||||
|
.signal-card-glance {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-company {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--foreground);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-ticker {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-badge.BUY { background: rgba(20,83,45,.6); color: #4ade80; border: 1px solid rgba(74,222,128,.2); }
|
||||||
|
.signal-badge.HOLD { background: rgba(92,66,14,.6); color: #fbbf24; border: 1px solid rgba(251,191,36,.2); }
|
||||||
|
.signal-badge.SELL { background: rgba(127,29,29,.6); color: #f87171; border: 1px solid rgba(248,113,113,.2); }
|
||||||
|
|
||||||
|
.signal-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-tag {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-summary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-ts {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--muted-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-card-detail {
|
||||||
|
display: none;
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-card.expanded .signal-card-detail { display: block; }
|
||||||
|
|
||||||
|
.signal-detail-section {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-detail-label {
|
||||||
|
font-size: 10.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
color: var(--muted-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-detail-section ul {
|
||||||
|
padding-left: 16px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-detail-section p {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-regen-btn {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-regen-btn:hover { color: var(--foreground); background: var(--bg-subtle); }
|
||||||
|
|
||||||
|
.signal-empty {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 40px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── intel unavailable notice ── */
|
||||||
|
|
||||||
|
.intel-unavailable {
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
177
public/admin/assets/css/layout.css
Normal file
177
public/admin/assets/css/layout.css
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
/* page chrome — header, tabs, subnav, stats bar, content shell */
|
||||||
|
|
||||||
|
|
||||||
|
/* ── header ── */
|
||||||
|
|
||||||
|
header.app-header {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header.app-header h1 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--foreground);
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header.app-header h1 span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── primary tabs (underline style) ── */
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
height: 100%;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs a {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color .15s, border-color .15s;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs a:hover { color: var(--foreground); }
|
||||||
|
|
||||||
|
.tabs a.active {
|
||||||
|
color: var(--foreground);
|
||||||
|
border-bottom-color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── subnav (intelligence sub-sections) ── */
|
||||||
|
|
||||||
|
.subnav {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subnav a {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: color .15s, border-color .15s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subnav a:hover { color: var(--foreground); }
|
||||||
|
|
||||||
|
.subnav a.active {
|
||||||
|
color: var(--foreground);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── stats bar ── */
|
||||||
|
|
||||||
|
.stats-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 14px 28px 14px 0;
|
||||||
|
margin-right: 28px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding-right: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat:last-child { border-right: none; }
|
||||||
|
|
||||||
|
.stat .label {
|
||||||
|
color: var(--muted-dark);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat .value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--foreground);
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── content ── */
|
||||||
|
|
||||||
|
.content { padding: 24px; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ── filters ── */
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── section heading ── */
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
111
public/admin/assets/js/app.js
Normal file
111
public/admin/assets/js/app.js
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
// shared helpers + global chrome (stats bar) used by every admin page.
|
||||||
|
// each page-specific script depends on this loading first.
|
||||||
|
|
||||||
|
const PAGE = 50;
|
||||||
|
|
||||||
|
|
||||||
|
const api = (path, opts) => fetch(path, opts).then(r => {
|
||||||
|
if (!r.ok) throw new Error(r.status);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function toast(msg, err) {
|
||||||
|
const el = document.getElementById("toast");
|
||||||
|
if (!el) return;
|
||||||
|
document.getElementById("toast-msg").textContent = msg;
|
||||||
|
el.className = "show" + (err ? " error" : "");
|
||||||
|
clearTimeout(toast._t);
|
||||||
|
toast._t = setTimeout(() => el.className = "", 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function badgeHtml(status) {
|
||||||
|
const s = status || "null";
|
||||||
|
const cls = s === "ok" ? "ok" : s === "error" ? "err" : s === "pending" ? "pending" : "null";
|
||||||
|
return `<span class="badge ${cls}">${s}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s ?? "").replace(/[&<>"']/g, c => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── url query-param helpers ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// filters and sort state live in the url so reloads and shared links keep
|
||||||
|
// their shape. queryGet reads a single param, queryAll returns them all,
|
||||||
|
// queryWrite replaces the query string with the cleaned-up params (empty
|
||||||
|
// values removed). we use replaceState so each filter change doesnt spam
|
||||||
|
// history.
|
||||||
|
|
||||||
|
function queryGet(key, fallback = "") {
|
||||||
|
const v = new URLSearchParams(location.search).get(key);
|
||||||
|
return v == null ? fallback : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function queryAll() {
|
||||||
|
return Object.fromEntries(new URLSearchParams(location.search));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function queryWrite(params) {
|
||||||
|
const clean = {};
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v === "" || v == null) continue;
|
||||||
|
clean[k] = v;
|
||||||
|
}
|
||||||
|
const qs = new URLSearchParams(clean).toString();
|
||||||
|
const next = location.pathname + (qs ? "?" + qs : "");
|
||||||
|
history.replaceState(null, "", next);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// apply current query params onto form inputs — call on page init
|
||||||
|
function queryApplyToInputs(bindings) {
|
||||||
|
for (const [id, key] of Object.entries(bindings)) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) continue;
|
||||||
|
const v = queryGet(key, "");
|
||||||
|
if (v !== "") el.value = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// global stats bar — small counters shown on articles/events/stats pages
|
||||||
|
async function loadGlobalStats() {
|
||||||
|
const bar = document.getElementById("statsBar");
|
||||||
|
if (!bar) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api("/admin/api/stats");
|
||||||
|
const t = document.getElementById("s-total");
|
||||||
|
if (t) t.textContent = data.total.toLocaleString();
|
||||||
|
const c = document.getElementById("s-content");
|
||||||
|
if (c) c.textContent = data.withContent.toLocaleString();
|
||||||
|
const em = document.getElementById("s-embed");
|
||||||
|
if (em) em.textContent = data.withEmbedding.toLocaleString();
|
||||||
|
const ev = document.getElementById("s-events");
|
||||||
|
if (ev) ev.textContent = data.eventCount.toLocaleString();
|
||||||
|
} catch (_) { /* ignore — stats bar is best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// common overlay close-on-backdrop wiring
|
||||||
|
function wireOverlays() {
|
||||||
|
document.querySelectorAll(".overlay").forEach(ov => {
|
||||||
|
ov.addEventListener("click", e => {
|
||||||
|
if (e.target === ov) ov.classList.remove("open");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
wireOverlays();
|
||||||
|
loadGlobalStats();
|
||||||
|
});
|
||||||
145
public/admin/assets/js/articles.js
Normal file
145
public/admin/assets/js/articles.js
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
// articles page — list + keyword/status/source filtering + edit modal
|
||||||
|
// depends on: app.js (api, toast, escapeHtml, PAGE, badgeHtml)
|
||||||
|
|
||||||
|
let articleOffset = 0;
|
||||||
|
let currentArticle = null;
|
||||||
|
|
||||||
|
|
||||||
|
async function loadSources() {
|
||||||
|
const sources = await api("/admin/api/sources");
|
||||||
|
const sel = document.getElementById("f-source");
|
||||||
|
sources.forEach(s => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = s;
|
||||||
|
opt.textContent = s;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getFilters() {
|
||||||
|
return {
|
||||||
|
keyword: document.getElementById("f-keyword").value.trim(),
|
||||||
|
source: document.getElementById("f-source").value,
|
||||||
|
content_status: document.getElementById("f-status").value,
|
||||||
|
from: document.getElementById("f-from").value,
|
||||||
|
to: document.getElementById("f-to").value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function loadArticles() {
|
||||||
|
const f = getFilters();
|
||||||
|
const params = new URLSearchParams({ limit: PAGE, offset: articleOffset });
|
||||||
|
if (f.keyword) params.set("keyword", f.keyword);
|
||||||
|
if (f.source) params.set("source", f.source);
|
||||||
|
if (f.content_status) params.set("content_status", f.content_status);
|
||||||
|
if (f.from) params.set("from", f.from + "T00:00:00");
|
||||||
|
if (f.to) params.set("to", f.to + "T23:59:59");
|
||||||
|
|
||||||
|
const data = await api(`/admin/api/articles?${params}`);
|
||||||
|
const tbody = document.getElementById("articleTable");
|
||||||
|
|
||||||
|
tbody.innerHTML = data.rows.map(r => `
|
||||||
|
<tr>
|
||||||
|
<td style="color:var(--muted-dark); font-size:12px">${r.id}</td>
|
||||||
|
<td>
|
||||||
|
<span class="truncate" title="${escapeHtml(r.title)}">${escapeHtml(r.title)}</span>
|
||||||
|
<a class="url-link" href="${r.url}" target="_blank" style="font-size:11px; display:block; margin-top:3px; color:var(--muted-dark)">↗ ${new URL(r.url).hostname}</a>
|
||||||
|
</td>
|
||||||
|
<td style="color:var(--muted)">${r.source}</td>
|
||||||
|
<td>${badgeHtml(r.content_status)}</td>
|
||||||
|
<td style="color:var(--muted-dark); white-space:nowrap; font-size:12px">${r.ingested_at ? r.ingested_at.slice(0, 16) : "—"}</td>
|
||||||
|
<td><button onclick="openArticle(${r.id})">Edit</button></td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
const total = data.total;
|
||||||
|
document.getElementById("pageInfo").textContent =
|
||||||
|
`${articleOffset + 1}–${Math.min(articleOffset + PAGE, total)} of ${total.toLocaleString()}`;
|
||||||
|
|
||||||
|
document.getElementById("prevBtn").disabled = articleOffset === 0;
|
||||||
|
document.getElementById("nextBtn").disabled = articleOffset + PAGE >= total;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function openArticle(id) {
|
||||||
|
currentArticle = await api(`/admin/api/articles/${id}`);
|
||||||
|
const a = currentArticle;
|
||||||
|
|
||||||
|
document.getElementById("modalTitle").textContent = `Article #${a.id}`;
|
||||||
|
|
||||||
|
document.getElementById("modalMeta").innerHTML = [
|
||||||
|
a.source && `<span>source: <b>${escapeHtml(a.source)}</b></span>`,
|
||||||
|
a.pub_date && `<span>pub: ${a.pub_date.slice(0, 16)}</span>`,
|
||||||
|
`<span>has_embedding: ${a.has_embedding ? "yes" : "no"}</span>`,
|
||||||
|
a.content_error && `<span style="color:#fca5a5">error: ${escapeHtml(a.content_error.slice(0, 80))}</span>`,
|
||||||
|
].filter(Boolean).join("");
|
||||||
|
|
||||||
|
document.getElementById("m-title").value = a.title || "";
|
||||||
|
document.getElementById("m-desc").value = a.description || "";
|
||||||
|
document.getElementById("m-content").value = a.content || "";
|
||||||
|
document.getElementById("m-status").value = a.content_status || "";
|
||||||
|
document.getElementById("m-lang").value = a.language || "";
|
||||||
|
document.getElementById("m-pubdate").value = a.pub_date || "";
|
||||||
|
document.getElementById("m-indexpage").value = String(a.is_index_page || 0);
|
||||||
|
|
||||||
|
document.getElementById("articleOverlay").classList.add("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
|
||||||
|
document.getElementById("searchBtn").onclick = () => { articleOffset = 0; loadArticles(); };
|
||||||
|
document.getElementById("prevBtn").onclick = () => { articleOffset = Math.max(0, articleOffset - PAGE); loadArticles(); };
|
||||||
|
document.getElementById("nextBtn").onclick = () => { articleOffset += PAGE; loadArticles(); };
|
||||||
|
|
||||||
|
document.querySelector(".filters").addEventListener("keydown", e => {
|
||||||
|
if (e.key === "Enter") { articleOffset = 0; loadArticles(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
document.getElementById("cancelBtn").onclick = () =>
|
||||||
|
document.getElementById("articleOverlay").classList.remove("open");
|
||||||
|
|
||||||
|
document.getElementById("saveBtn").onclick = async () => {
|
||||||
|
if (!currentArticle) return;
|
||||||
|
try {
|
||||||
|
await api(`/admin/api/articles/${currentArticle.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: document.getElementById("m-title").value,
|
||||||
|
description: document.getElementById("m-desc").value,
|
||||||
|
content: document.getElementById("m-content").value,
|
||||||
|
content_status: document.getElementById("m-status").value || null,
|
||||||
|
language: document.getElementById("m-lang").value || null,
|
||||||
|
pub_date: document.getElementById("m-pubdate").value || null,
|
||||||
|
is_index_page: parseInt(document.getElementById("m-indexpage").value, 10),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
document.getElementById("articleOverlay").classList.remove("open");
|
||||||
|
toast("Saved");
|
||||||
|
loadArticles();
|
||||||
|
} catch (e) {
|
||||||
|
toast("Save failed", true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("deleteBtn").onclick = async () => {
|
||||||
|
if (!currentArticle) return;
|
||||||
|
if (!confirm(`Delete article #${currentArticle.id}? This cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/admin/api/articles/${currentArticle.id}`, { method: "DELETE" });
|
||||||
|
document.getElementById("articleOverlay").classList.remove("open");
|
||||||
|
toast("Deleted");
|
||||||
|
loadArticles();
|
||||||
|
loadGlobalStats();
|
||||||
|
} catch (e) {
|
||||||
|
toast("Delete failed", true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSources();
|
||||||
|
loadArticles();
|
||||||
|
});
|
||||||
73
public/admin/assets/js/events.js
Normal file
73
public/admin/assets/js/events.js
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
// events page — list + title edit + detach-and-delete
|
||||||
|
// depends on: app.js
|
||||||
|
|
||||||
|
let eventOffset = 0;
|
||||||
|
let currentEvent = null;
|
||||||
|
|
||||||
|
|
||||||
|
async function loadEvents() {
|
||||||
|
const data = await api(`/admin/api/events?limit=${PAGE}&offset=${eventOffset}`);
|
||||||
|
const tbody = document.getElementById("eventTable");
|
||||||
|
|
||||||
|
tbody.innerHTML = data.rows.map(r => `
|
||||||
|
<tr>
|
||||||
|
<td style="color:var(--muted-dark); font-size:12px">${r.id}</td>
|
||||||
|
<td><span class="truncate" title="${escapeHtml(r.title)}">${escapeHtml(r.title)}</span></td>
|
||||||
|
<td>${r.article_count}</td>
|
||||||
|
<td style="color:var(--muted-dark); white-space:nowrap; font-size:12px">${r.created_at ? r.created_at.slice(0, 16) : "—"}</td>
|
||||||
|
<td><button onclick='openEvent(${r.id}, ${JSON.stringify(r.title)})'>Edit</button></td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
const total = data.total;
|
||||||
|
document.getElementById("ePageInfo").textContent =
|
||||||
|
`${eventOffset + 1}–${Math.min(eventOffset + PAGE, total)} of ${total.toLocaleString()}`;
|
||||||
|
document.getElementById("ePrevBtn").disabled = eventOffset === 0;
|
||||||
|
document.getElementById("eNextBtn").disabled = eventOffset + PAGE >= total;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function openEvent(id, title) {
|
||||||
|
currentEvent = { id, title };
|
||||||
|
document.getElementById("em-title").value = title;
|
||||||
|
document.getElementById("eventOverlay").classList.add("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
|
||||||
|
document.getElementById("ePrevBtn").onclick = () => { eventOffset = Math.max(0, eventOffset - PAGE); loadEvents(); };
|
||||||
|
document.getElementById("eNextBtn").onclick = () => { eventOffset += PAGE; loadEvents(); };
|
||||||
|
|
||||||
|
document.getElementById("eCancelBtn").onclick = () =>
|
||||||
|
document.getElementById("eventOverlay").classList.remove("open");
|
||||||
|
|
||||||
|
document.getElementById("eSaveBtn").onclick = async () => {
|
||||||
|
if (!currentEvent) return;
|
||||||
|
try {
|
||||||
|
await api(`/admin/api/events/${currentEvent.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: document.getElementById("em-title").value }),
|
||||||
|
});
|
||||||
|
document.getElementById("eventOverlay").classList.remove("open");
|
||||||
|
toast("Saved");
|
||||||
|
loadEvents();
|
||||||
|
} catch (e) { toast("Save failed", true); }
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("eDeleteBtn").onclick = async () => {
|
||||||
|
if (!currentEvent) return;
|
||||||
|
if (!confirm(`Delete event #${currentEvent.id}? Articles will be detached but not deleted.`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/admin/api/events/${currentEvent.id}`, { method: "DELETE" });
|
||||||
|
document.getElementById("eventOverlay").classList.remove("open");
|
||||||
|
toast("Event deleted");
|
||||||
|
loadEvents();
|
||||||
|
loadGlobalStats();
|
||||||
|
} catch (e) { toast("Delete failed", true); }
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
loadEvents();
|
||||||
|
});
|
||||||
567
public/admin/assets/js/intel-graph.js
Normal file
567
public/admin/assets/js/intel-graph.js
Normal file
|
|
@ -0,0 +1,567 @@
|
||||||
|
// intelligence → graph (d3-force relationship network)
|
||||||
|
// depends on: app.js, intel-shared.js, d3.v7
|
||||||
|
|
||||||
|
const SECTOR_COLOR = {
|
||||||
|
AI: "#378ADD",
|
||||||
|
Tech: "#534AB7",
|
||||||
|
Semiconductor: "#BA7517",
|
||||||
|
Storage: "#888780",
|
||||||
|
Media: "#D4537E",
|
||||||
|
Auto: "#1D9E75",
|
||||||
|
Finance: "#639922",
|
||||||
|
Defense: "#6B7280",
|
||||||
|
Space: "#534AB7",
|
||||||
|
Telecom: "#1D9E75",
|
||||||
|
Cloud: "#D85A30",
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const EDGE_COLOR = {
|
||||||
|
competitor: "#E24B4A",
|
||||||
|
customer: "#639922",
|
||||||
|
supplier: "#BA7517",
|
||||||
|
investor: "#378ADD",
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// sector lookup for tickers we track — new tickers fall through to inferSector
|
||||||
|
const SECTOR_BY_TICKER = {
|
||||||
|
NVDA: "Semiconductor", AMD: "Semiconductor", INTC: "Semiconductor",
|
||||||
|
TSM: "Semiconductor", "2330.TW": "Semiconductor", ASML: "Semiconductor",
|
||||||
|
QCOM: "Semiconductor", AVGO: "Semiconductor", MRVL: "Semiconductor",
|
||||||
|
"005930.KS": "Semiconductor", "000660.KS": "Semiconductor",
|
||||||
|
KLAC: "Semiconductor", AMAT: "Semiconductor", LRCX: "Semiconductor",
|
||||||
|
TXN: "Semiconductor", ADI: "Semiconductor", ON: "Semiconductor",
|
||||||
|
|
||||||
|
AAPL: "Tech", GOOGL: "Tech", GOOG: "Tech", META: "Tech",
|
||||||
|
MSFT: "Tech", AMZN: "Tech", NFLX: "Tech",
|
||||||
|
|
||||||
|
OPENAI: "AI", ANTHROPIC: "AI", XAI: "AI",
|
||||||
|
|
||||||
|
MU: "Storage", WDC: "Storage", STX: "Storage",
|
||||||
|
|
||||||
|
DIS: "Media", CMCSA: "Media", WBD: "Media", SPOT: "Media", PARA: "Media",
|
||||||
|
|
||||||
|
TSLA: "Auto", F: "Auto", GM: "Auto", TM: "Auto", HMC: "Auto",
|
||||||
|
STLA: "Auto", RIVN: "Auto", LCID: "Auto",
|
||||||
|
|
||||||
|
JPM: "Finance", GS: "Finance", MS: "Finance", BAC: "Finance",
|
||||||
|
C: "Finance", BLK: "Finance", "BRK.B": "Finance", V: "Finance",
|
||||||
|
MA: "Finance", AXP: "Finance",
|
||||||
|
|
||||||
|
LMT: "Defense", RTX: "Defense", NOC: "Defense", GD: "Defense", HII: "Defense",
|
||||||
|
|
||||||
|
SPCX: "Space", RKLB: "Space",
|
||||||
|
|
||||||
|
VZ: "Telecom", T: "Telecom", TMUS: "Telecom",
|
||||||
|
|
||||||
|
ORCL: "Cloud", CRM: "Cloud", NOW: "Cloud", SNOW: "Cloud",
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function inferSector(ticker, name) {
|
||||||
|
if (ticker && SECTOR_BY_TICKER[ticker.toUpperCase()]) {
|
||||||
|
return SECTOR_BY_TICKER[ticker.toUpperCase()];
|
||||||
|
}
|
||||||
|
if (name) {
|
||||||
|
const upper = name.toUpperCase();
|
||||||
|
if (SECTOR_BY_TICKER[upper]) return SECTOR_BY_TICKER[upper];
|
||||||
|
const low = name.toLowerCase();
|
||||||
|
if (/bank|capital|asset|fund|invest/.test(low)) return "Finance";
|
||||||
|
if (/semi|chip/.test(low)) return "Semiconductor";
|
||||||
|
if (/media|studio|entertain/.test(low)) return "Media";
|
||||||
|
if (/telecom|wireless|mobile/.test(low)) return "Telecom";
|
||||||
|
if (/cloud/.test(low)) return "Cloud";
|
||||||
|
if (/motor|automot/.test(low)) return "Auto";
|
||||||
|
}
|
||||||
|
return "Tech"; // sensible default
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let graphNodes = [];
|
||||||
|
let graphEdges = [];
|
||||||
|
let graphDegree = new Map();
|
||||||
|
let graphFilterType = "all";
|
||||||
|
let graphSearchTerm = "";
|
||||||
|
let graphSelectedId = null;
|
||||||
|
|
||||||
|
|
||||||
|
async function loadIntelGraph() {
|
||||||
|
const data = await api("/admin/api/intelligence/graph");
|
||||||
|
|
||||||
|
if (!data.nodes || data.nodes.length === 0) {
|
||||||
|
document.getElementById("graph-empty").style.display = "block";
|
||||||
|
graphNodes = []; graphEdges = [];
|
||||||
|
document.getElementById("intel-graph-svg").innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("graph-empty").style.display = "none";
|
||||||
|
|
||||||
|
const idByCompanyId = new Map();
|
||||||
|
const untrackedIds = new Set();
|
||||||
|
graphNodes = [];
|
||||||
|
|
||||||
|
for (const n of data.nodes) {
|
||||||
|
if (n.tracked) {
|
||||||
|
const nodeId = n.ticker || `C${n.id}`;
|
||||||
|
idByCompanyId.set(n.id, nodeId);
|
||||||
|
graphNodes.push({
|
||||||
|
id: nodeId,
|
||||||
|
companyId: n.id,
|
||||||
|
label: n.name,
|
||||||
|
sector: inferSector(n.ticker, n.name),
|
||||||
|
tracked: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const nodeId = `U:${n.name}`;
|
||||||
|
if (untrackedIds.has(nodeId)) continue;
|
||||||
|
untrackedIds.add(nodeId);
|
||||||
|
graphNodes.push({
|
||||||
|
id: nodeId,
|
||||||
|
companyId: null,
|
||||||
|
label: n.name,
|
||||||
|
sector: null,
|
||||||
|
tracked: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
graphEdges = [];
|
||||||
|
|
||||||
|
for (const e of data.edges) {
|
||||||
|
let type = (e.relationship_type || "").toLowerCase();
|
||||||
|
let src = idByCompanyId.get(e.from_company_id);
|
||||||
|
let tgt;
|
||||||
|
|
||||||
|
if (e.to_company_id) {
|
||||||
|
tgt = idByCompanyId.get(e.to_company_id);
|
||||||
|
} else if (e.to_entity && untrackedIds.has(`U:${e.to_entity}`)) {
|
||||||
|
tgt = `U:${e.to_entity}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!src || !tgt) continue;
|
||||||
|
|
||||||
|
// dependency is the reciprocal of investor — flip direction and normalize
|
||||||
|
if (type === "dependency" && e.to_company_id) {
|
||||||
|
type = "investor";
|
||||||
|
[src, tgt] = [tgt, src];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!EDGE_COLOR[type]) continue;
|
||||||
|
|
||||||
|
const key = `${src}|${tgt}|${type}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
|
graphEdges.push({ source: src, target: tgt, type });
|
||||||
|
}
|
||||||
|
|
||||||
|
graphDegree = new Map();
|
||||||
|
for (const n of graphNodes) graphDegree.set(n.id, 0);
|
||||||
|
for (const e of graphEdges) {
|
||||||
|
graphDegree.set(e.source, (graphDegree.get(e.source) || 0) + 1);
|
||||||
|
graphDegree.set(e.target, (graphDegree.get(e.target) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
graphSelectedId = null;
|
||||||
|
clearGraphInfo();
|
||||||
|
|
||||||
|
renderIntelGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function graphNodeRadius(d) {
|
||||||
|
return 5 + Math.min((graphDegree.get(d.id) || 0) * 0.8, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderIntelGraph() {
|
||||||
|
const svgEl = document.getElementById("intel-graph-svg");
|
||||||
|
svgEl.innerHTML = "";
|
||||||
|
if (graphNodes.length === 0) return;
|
||||||
|
|
||||||
|
const width = svgEl.clientWidth || 900;
|
||||||
|
const height = svgEl.clientHeight || 600;
|
||||||
|
|
||||||
|
const svg = d3.select(svgEl).attr("viewBox", [0, 0, width, height]);
|
||||||
|
const root = svg.append("g");
|
||||||
|
|
||||||
|
const zoom = d3.zoom()
|
||||||
|
.scaleExtent([0.25, 4])
|
||||||
|
.on("zoom", ev => root.attr("transform", ev.transform));
|
||||||
|
svg.call(zoom);
|
||||||
|
|
||||||
|
svg.on("click", ev => {
|
||||||
|
if (ev.target === svg.node()) clearGraphSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkLayer = root.append("g").attr("class", "ig-links");
|
||||||
|
const nodeLayer = root.append("g").attr("class", "ig-nodes");
|
||||||
|
|
||||||
|
const nodesCopy = graphNodes.map(n => ({ ...n }));
|
||||||
|
const edgesCopy = graphEdges.map(e => ({ ...e }));
|
||||||
|
|
||||||
|
|
||||||
|
const linkSel = linkLayer.selectAll("line")
|
||||||
|
.data(edgesCopy)
|
||||||
|
.join("line")
|
||||||
|
.attr("stroke", d => EDGE_COLOR[d.type] || "#888")
|
||||||
|
.attr("stroke-width", 1.5)
|
||||||
|
.attr("stroke-opacity", 0.55);
|
||||||
|
|
||||||
|
const nodeSel = nodeLayer.selectAll("g.ig-node")
|
||||||
|
.data(nodesCopy, d => d.id)
|
||||||
|
.join("g")
|
||||||
|
.attr("class", "ig-node")
|
||||||
|
.style("cursor", d => d.tracked ? "pointer" : "default")
|
||||||
|
.call(d3.drag()
|
||||||
|
.on("start", dragStart)
|
||||||
|
.on("drag", dragMove)
|
||||||
|
.on("end", dragEnd));
|
||||||
|
|
||||||
|
nodeSel.append("circle")
|
||||||
|
.attr("r", graphNodeRadius)
|
||||||
|
.attr("fill", d => d.tracked ? (SECTOR_COLOR[d.sector] || "#888") : "none")
|
||||||
|
.attr("stroke", d => d.tracked ? "transparent" : "#6b7280")
|
||||||
|
.attr("stroke-width", d => d.tracked ? 2 : 1.5)
|
||||||
|
.on("mouseenter", function (ev, d) {
|
||||||
|
if (!d.tracked) {
|
||||||
|
d3.select(this).attr("stroke", "#94a3b8");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (d.id !== graphSelectedId) {
|
||||||
|
d3.select(this).attr("stroke", "rgba(255,255,255,0.6)");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("mouseleave", function (ev, d) {
|
||||||
|
if (!d.tracked) {
|
||||||
|
d3.select(this).attr("stroke", "#6b7280");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (d.id !== graphSelectedId) {
|
||||||
|
d3.select(this).attr("stroke", "transparent");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("click", (ev, d) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (!d.tracked) return;
|
||||||
|
selectGraphNode(d);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodeSel.append("text")
|
||||||
|
.attr("class", d => d.tracked ? "graph-node-label" : "graph-node-label graph-node-label-untracked")
|
||||||
|
.attr("text-anchor", "middle")
|
||||||
|
.attr("y", d => graphNodeRadius(d) + 13)
|
||||||
|
.text(d => d.label);
|
||||||
|
|
||||||
|
nodeSel.insert("rect", "text.graph-node-label")
|
||||||
|
.attr("class", "ig-label-bg")
|
||||||
|
.attr("rx", 2).attr("ry", 2);
|
||||||
|
|
||||||
|
nodeSel.each(function () {
|
||||||
|
const g = d3.select(this);
|
||||||
|
const t = g.select("text.graph-node-label").node();
|
||||||
|
if (!t) return;
|
||||||
|
const bb = t.getBBox();
|
||||||
|
g.select("rect.ig-label-bg")
|
||||||
|
.attr("x", bb.x - 3)
|
||||||
|
.attr("y", bb.y - 1)
|
||||||
|
.attr("width", bb.width + 6)
|
||||||
|
.attr("height", bb.height + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const sim = d3.forceSimulation(nodesCopy)
|
||||||
|
.force("link", d3.forceLink(edgesCopy).id(d => d.id).distance(80))
|
||||||
|
.force("charge", d3.forceManyBody().strength(-300))
|
||||||
|
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||||
|
.force("x", d3.forceX(width / 2).strength(0.07))
|
||||||
|
.force("y", d3.forceY(height / 2).strength(0.07))
|
||||||
|
.force("collide", d3.forceCollide(d => graphNodeRadius(d) + 4));
|
||||||
|
|
||||||
|
sim.on("tick", () => {
|
||||||
|
linkSel
|
||||||
|
.attr("x1", d => d.source.x)
|
||||||
|
.attr("y1", d => d.source.y)
|
||||||
|
.attr("x2", d => d.target.x)
|
||||||
|
.attr("y2", d => d.target.y);
|
||||||
|
|
||||||
|
nodeSel.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
window._igSim = sim;
|
||||||
|
window._igLinkSel = linkSel;
|
||||||
|
window._igNodeSel = nodeSel;
|
||||||
|
|
||||||
|
applyGraphFilters();
|
||||||
|
|
||||||
|
|
||||||
|
function dragStart(ev, d) {
|
||||||
|
if (!ev.active) sim.alphaTarget(0.3).restart();
|
||||||
|
d.fx = d.x; d.fy = d.y;
|
||||||
|
}
|
||||||
|
function dragMove(ev, d) {
|
||||||
|
d.fx = ev.x; d.fy = ev.y;
|
||||||
|
}
|
||||||
|
function dragEnd(ev, d) {
|
||||||
|
if (!ev.active) sim.alphaTarget(0);
|
||||||
|
d.fx = null; d.fy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function applyGraphFilters() {
|
||||||
|
const linkSel = window._igLinkSel;
|
||||||
|
const nodeSel = window._igNodeSel;
|
||||||
|
if (!linkSel || !nodeSel) return;
|
||||||
|
|
||||||
|
const term = graphSearchTerm;
|
||||||
|
const filter = graphFilterType;
|
||||||
|
|
||||||
|
let visibleIds;
|
||||||
|
if (term) {
|
||||||
|
const direct = graphNodes.filter(n =>
|
||||||
|
n.label.toLowerCase().includes(term) ||
|
||||||
|
n.id.toLowerCase().includes(term)
|
||||||
|
).map(n => n.id);
|
||||||
|
visibleIds = new Set(direct);
|
||||||
|
|
||||||
|
for (const e of graphEdges) {
|
||||||
|
if (visibleIds.has(e.source)) visibleIds.add(e.target);
|
||||||
|
if (visibleIds.has(e.target)) visibleIds.add(e.source);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
visibleIds = new Set(graphNodes.map(n => n.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const lit = graphSelectedId ? neighborsOfNode(graphSelectedId) : null;
|
||||||
|
|
||||||
|
nodeSel
|
||||||
|
.style("display", d => visibleIds.has(d.id) ? null : "none")
|
||||||
|
.style("opacity", d => (lit && !lit.has(d.id)) ? 0.15 : 1);
|
||||||
|
|
||||||
|
nodeSel.select("circle")
|
||||||
|
.attr("stroke", d => {
|
||||||
|
if (!d.tracked) return "#6b7280";
|
||||||
|
return d.id === graphSelectedId ? "#ffffff" : "transparent";
|
||||||
|
})
|
||||||
|
.attr("stroke-width", d => {
|
||||||
|
if (!d.tracked) return 1.5;
|
||||||
|
return d.id === graphSelectedId ? 2.5 : 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
linkSel
|
||||||
|
.style("display", e => {
|
||||||
|
if (filter !== "all" && e.type !== filter) return "none";
|
||||||
|
const s = typeof e.source === "object" ? e.source.id : e.source;
|
||||||
|
const t = typeof e.target === "object" ? e.target.id : e.target;
|
||||||
|
if (!visibleIds.has(s) || !visibleIds.has(t)) return "none";
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.attr("stroke-opacity", e => {
|
||||||
|
if (!lit) return 0.55;
|
||||||
|
const s = typeof e.source === "object" ? e.source.id : e.source;
|
||||||
|
const t = typeof e.target === "object" ? e.target.id : e.target;
|
||||||
|
return (s !== graphSelectedId && t !== graphSelectedId) ? 0.08 : 0.55;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function neighborsOfNode(id) {
|
||||||
|
const set = new Set([id]);
|
||||||
|
for (const e of graphEdges) {
|
||||||
|
const s = typeof e.source === "object" ? e.source.id : e.source;
|
||||||
|
const t = typeof e.target === "object" ? e.target.id : e.target;
|
||||||
|
if (s === id) set.add(t);
|
||||||
|
if (t === id) set.add(s);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function selectGraphNode(d) {
|
||||||
|
graphSelectedId = d.id;
|
||||||
|
applyGraphFilters();
|
||||||
|
renderGraphInfo(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function clearGraphSelection() {
|
||||||
|
graphSelectedId = null;
|
||||||
|
applyGraphFilters();
|
||||||
|
clearGraphInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function clearGraphInfo() {
|
||||||
|
document.getElementById("graph-info").innerHTML =
|
||||||
|
'<p class="graph-empty-msg">Click a node to see its connections.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderGraphInfo(node) {
|
||||||
|
const groups = { competitor: [], customer: [], supplier: [], investor: [] };
|
||||||
|
const seenPer = { competitor: new Set(), customer: new Set(), supplier: new Set(), investor: new Set() };
|
||||||
|
|
||||||
|
for (const e of graphEdges) {
|
||||||
|
let otherId = null;
|
||||||
|
if (e.source === node.id) otherId = e.target;
|
||||||
|
else if (e.target === node.id) otherId = e.source;
|
||||||
|
if (!otherId) continue;
|
||||||
|
if (seenPer[e.type].has(otherId)) continue;
|
||||||
|
seenPer[e.type].add(otherId);
|
||||||
|
const other = graphNodes.find(n => n.id === otherId);
|
||||||
|
if (other) groups[e.type].push(other);
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `<div id="graph-info-title">${escapeHtml(node.label)}</div>`;
|
||||||
|
html += `<span id="graph-info-sector">${escapeHtml(node.sector)}</span>`;
|
||||||
|
|
||||||
|
let any = false;
|
||||||
|
for (const type of ["competitor", "customer", "supplier", "investor"]) {
|
||||||
|
const list = groups[type];
|
||||||
|
if (!list.length) continue;
|
||||||
|
any = true;
|
||||||
|
html += `<div class="graph-group-title">${type} (${list.length})</div>`;
|
||||||
|
for (const o of list) {
|
||||||
|
const canExpand = !!o.companyId;
|
||||||
|
if (canExpand) {
|
||||||
|
html += `
|
||||||
|
<div class="graph-conn-row" data-from="${node.companyId}" data-to="${o.companyId}" data-type="${type}">
|
||||||
|
<div class="graph-conn-head">
|
||||||
|
<span class="graph-conn-label">${escapeHtml(o.label)}</span>
|
||||||
|
<span class="graph-conn-right">
|
||||||
|
<span class="graph-conn-sector">${escapeHtml(o.sector)}</span>
|
||||||
|
<span class="graph-conn-toggle">▸</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="graph-conn-body"></div>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
html += `
|
||||||
|
<div class="graph-conn-row untracked">
|
||||||
|
<div class="graph-conn-head">
|
||||||
|
<span class="graph-conn-label">${escapeHtml(o.label)}</span>
|
||||||
|
<span class="graph-conn-right">
|
||||||
|
<span class="graph-conn-sector">untracked</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!any) {
|
||||||
|
html += `<p class="graph-empty-msg" style="margin-top:12px">No relationships recorded.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("graph-info").innerHTML = html;
|
||||||
|
|
||||||
|
document.querySelectorAll("#graph-info .graph-conn-row").forEach(row => {
|
||||||
|
if (row.classList.contains("untracked")) return;
|
||||||
|
row.querySelector(".graph-conn-head").addEventListener("click", () => toggleEvidenceRow(row));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function toggleEvidenceRow(row) {
|
||||||
|
const body = row.querySelector(".graph-conn-body");
|
||||||
|
const toggle = row.querySelector(".graph-conn-toggle");
|
||||||
|
|
||||||
|
const expanded = row.classList.toggle("expanded");
|
||||||
|
toggle.textContent = expanded ? "▾" : "▸";
|
||||||
|
|
||||||
|
if (!expanded) return;
|
||||||
|
if (row.dataset.loaded) return;
|
||||||
|
|
||||||
|
body.innerHTML = '<div class="graph-evidence-loading">Loading evidence…</div>';
|
||||||
|
|
||||||
|
const fromId = row.dataset.from;
|
||||||
|
const toId = row.dataset.to;
|
||||||
|
const type = row.dataset.type;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api(`/admin/api/intelligence/edge-evidence?from_id=${fromId}&to_id=${toId}&type=${encodeURIComponent(type)}`);
|
||||||
|
|
||||||
|
let html = "";
|
||||||
|
|
||||||
|
if (data.edge) {
|
||||||
|
html += `<div class="graph-evidence-stats">
|
||||||
|
<span class="graph-evidence-badge">${escapeHtml(data.edge.confidence || "low")}</span>
|
||||||
|
<span>×${data.edge.confirmation_count || 1} confirmations</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.facts && data.facts.length) {
|
||||||
|
html += '<div class="graph-evidence-section">Backing facts</div>';
|
||||||
|
for (const f of data.facts) {
|
||||||
|
html += `<div class="graph-evidence-fact">
|
||||||
|
<div class="graph-evidence-claim">${escapeHtml(f.claim)}</div>
|
||||||
|
<div class="graph-evidence-meta">${escapeHtml(f.confidence || "low")} · ×${f.confirmation_count || 1}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.events && data.events.length) {
|
||||||
|
html += `<div class="graph-evidence-section">Source events (${data.events.length})</div>`;
|
||||||
|
for (const ev of data.events) {
|
||||||
|
const title = ev.title || `Event #${ev.id}`;
|
||||||
|
html += `<details class="graph-evidence-event">
|
||||||
|
<summary>
|
||||||
|
<span class="graph-evidence-event-title">${escapeHtml(title)}</span>
|
||||||
|
<span class="graph-evidence-event-id">#${ev.id}</span>
|
||||||
|
</summary>`;
|
||||||
|
|
||||||
|
if (ev.articles && ev.articles.length) {
|
||||||
|
html += '<ul class="graph-evidence-articles">';
|
||||||
|
for (const a of ev.articles) {
|
||||||
|
const date = a.pub_date ? String(a.pub_date).slice(0, 10) : "";
|
||||||
|
html += `<li>
|
||||||
|
<a href="${escapeHtml(a.url || "#")}" target="_blank" rel="noopener">${escapeHtml(a.title || a.url || "untitled")}</a>
|
||||||
|
<div class="graph-evidence-article-meta">${escapeHtml(a.source || "")}${date ? " · " + date : ""}</div>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
html += "</ul>";
|
||||||
|
} else {
|
||||||
|
html += '<div class="graph-evidence-empty">No articles.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += "</details>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!html) {
|
||||||
|
html = '<div class="graph-evidence-empty">No backing evidence recorded.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = html;
|
||||||
|
row.dataset.loaded = "1";
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
body.innerHTML = '<div class="graph-evidence-error">Failed to load evidence.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
|
document.getElementById("graph-search").addEventListener("input", ev => {
|
||||||
|
graphSearchTerm = ev.target.value.trim().toLowerCase();
|
||||||
|
applyGraphFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("graph-chips").addEventListener("click", ev => {
|
||||||
|
const btn = ev.target.closest(".graph-chip");
|
||||||
|
if (!btn) return;
|
||||||
|
document.querySelectorAll(".graph-chip").forEach(c => c.classList.remove("active"));
|
||||||
|
btn.classList.add("active");
|
||||||
|
graphFilterType = btn.dataset.type;
|
||||||
|
applyGraphFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await loadIntelStatsRow();
|
||||||
|
if (!ok) return;
|
||||||
|
loadIntelGraph();
|
||||||
|
});
|
||||||
63
public/admin/assets/js/intel-knowledge.js
Normal file
63
public/admin/assets/js/intel-knowledge.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
// intelligence → knowledge table
|
||||||
|
// depends on: app.js, intel-shared.js
|
||||||
|
|
||||||
|
let knowledgeOffset = 0;
|
||||||
|
|
||||||
|
|
||||||
|
async function loadKnowledge() {
|
||||||
|
const companyId = document.getElementById("i-company").value;
|
||||||
|
const type = document.getElementById("i-type").value;
|
||||||
|
const sort = document.getElementById("i-sort").value;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ limit: PAGE, offset: knowledgeOffset });
|
||||||
|
if (companyId) params.set("company_id", companyId);
|
||||||
|
if (type) params.set("type", type);
|
||||||
|
if (sort) params.set("sort", sort);
|
||||||
|
|
||||||
|
const data = await api(`/admin/api/intelligence/knowledge?${params}`);
|
||||||
|
intelRows = data.rows;
|
||||||
|
|
||||||
|
document.getElementById("intel-thead").innerHTML = `
|
||||||
|
<tr><th>ID</th><th>Company</th><th>Event</th><th>Type</th><th>Data</th><th>Event date</th></tr>`;
|
||||||
|
|
||||||
|
document.getElementById("intel-tbody").innerHTML = data.rows.map(r => {
|
||||||
|
let parsed = {};
|
||||||
|
try { parsed = JSON.parse(r.data); } catch (_) {}
|
||||||
|
const summary = Object.values(parsed).filter(v => typeof v === "string").join(" · ").slice(0, 120);
|
||||||
|
return `<tr style="cursor:pointer" onclick="openIntelDetail(${r.id}, 'knowledge')">
|
||||||
|
<td style="color:var(--muted-dark); font-size:12px">${r.id}</td>
|
||||||
|
<td style="white-space:nowrap">${escapeHtml(r.company_name)}</td>
|
||||||
|
<td style="color:var(--muted-dark); font-size:12px">${r.event_id}</td>
|
||||||
|
<td><span class="badge null">${escapeHtml(r.type)}</span></td>
|
||||||
|
<td><span class="truncate" style="max-width:360px">${escapeHtml(summary)}</span></td>
|
||||||
|
<td style="color:var(--muted-dark); white-space:nowrap; font-size:12px">${r.event_date ? r.event_date.slice(0,10) : "—"}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const total = data.total;
|
||||||
|
document.getElementById("iPageInfo").textContent =
|
||||||
|
`${knowledgeOffset + 1}–${Math.min(knowledgeOffset + PAGE, total)} of ${total.toLocaleString()}`;
|
||||||
|
document.getElementById("iPrevBtn").disabled = knowledgeOffset === 0;
|
||||||
|
document.getElementById("iNextBtn").disabled = knowledgeOffset + PAGE >= total;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
|
document.getElementById("iPrevBtn").onclick = () => {
|
||||||
|
knowledgeOffset = Math.max(0, knowledgeOffset - PAGE);
|
||||||
|
loadKnowledge();
|
||||||
|
};
|
||||||
|
document.getElementById("iNextBtn").onclick = () => {
|
||||||
|
knowledgeOffset += PAGE;
|
||||||
|
loadKnowledge();
|
||||||
|
};
|
||||||
|
document.getElementById("i-filter-btn").onclick = () => {
|
||||||
|
knowledgeOffset = 0;
|
||||||
|
loadKnowledge();
|
||||||
|
};
|
||||||
|
|
||||||
|
const ok = await loadIntelStatsRow();
|
||||||
|
if (!ok) return;
|
||||||
|
await loadIntelCompanies();
|
||||||
|
loadKnowledge();
|
||||||
|
});
|
||||||
61
public/admin/assets/js/intel-predictions.js
Normal file
61
public/admin/assets/js/intel-predictions.js
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// intelligence → predictions table
|
||||||
|
// depends on: app.js, intel-shared.js
|
||||||
|
|
||||||
|
let predictionsOffset = 0;
|
||||||
|
|
||||||
|
|
||||||
|
async function loadPredictions() {
|
||||||
|
const companyId = document.getElementById("i-company").value;
|
||||||
|
const sort = document.getElementById("i-sort").value;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ limit: PAGE, offset: predictionsOffset });
|
||||||
|
if (companyId) params.set("company_id", companyId);
|
||||||
|
if (sort) params.set("sort", sort);
|
||||||
|
|
||||||
|
const data = await api(`/admin/api/intelligence/predictions?${params}`);
|
||||||
|
intelRows = data.rows;
|
||||||
|
|
||||||
|
document.getElementById("intel-thead").innerHTML = `
|
||||||
|
<tr><th>ID</th><th>Company</th><th>Event</th><th>Type</th><th>Direction</th><th>Magnitude</th><th>Timeframe</th><th>Rationale</th><th>Event date</th></tr>`;
|
||||||
|
|
||||||
|
document.getElementById("intel-tbody").innerHTML = data.rows.map(r => `
|
||||||
|
<tr style="cursor:pointer" onclick="openIntelDetail(${r.id}, 'predictions')">
|
||||||
|
<td style="color:var(--muted-dark); font-size:12px">${r.id}</td>
|
||||||
|
<td style="white-space:nowrap">${escapeHtml(r.company_name)}</td>
|
||||||
|
<td style="color:var(--muted-dark); font-size:12px">${r.event_id}</td>
|
||||||
|
<td><span class="badge null">${escapeHtml(r.type)}</span></td>
|
||||||
|
<td>${escapeHtml(r.direction || "—")}</td>
|
||||||
|
<td>${escapeHtml(r.magnitude || "—")}</td>
|
||||||
|
<td>${escapeHtml(r.timeframe || "—")}</td>
|
||||||
|
<td><span class="truncate" style="max-width:300px">${escapeHtml(r.rationale || "—")}</span></td>
|
||||||
|
<td style="color:var(--muted-dark); white-space:nowrap; font-size:12px">${r.event_date ? r.event_date.slice(0,10) : "—"}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
const total = data.total;
|
||||||
|
document.getElementById("iPageInfo").textContent =
|
||||||
|
`${predictionsOffset + 1}–${Math.min(predictionsOffset + PAGE, total)} of ${total.toLocaleString()}`;
|
||||||
|
document.getElementById("iPrevBtn").disabled = predictionsOffset === 0;
|
||||||
|
document.getElementById("iNextBtn").disabled = predictionsOffset + PAGE >= total;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
|
document.getElementById("iPrevBtn").onclick = () => {
|
||||||
|
predictionsOffset = Math.max(0, predictionsOffset - PAGE);
|
||||||
|
loadPredictions();
|
||||||
|
};
|
||||||
|
document.getElementById("iNextBtn").onclick = () => {
|
||||||
|
predictionsOffset += PAGE;
|
||||||
|
loadPredictions();
|
||||||
|
};
|
||||||
|
document.getElementById("i-filter-btn").onclick = () => {
|
||||||
|
predictionsOffset = 0;
|
||||||
|
loadPredictions();
|
||||||
|
};
|
||||||
|
|
||||||
|
const ok = await loadIntelStatsRow();
|
||||||
|
if (!ok) return;
|
||||||
|
await loadIntelCompanies();
|
||||||
|
loadPredictions();
|
||||||
|
});
|
||||||
97
public/admin/assets/js/intel-shared.js
Normal file
97
public/admin/assets/js/intel-shared.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// helpers shared across all intelligence sub-pages:
|
||||||
|
// stats-row loader, company dropdown, detail modal opener.
|
||||||
|
// depends on: app.js
|
||||||
|
|
||||||
|
async function loadIntelStatsRow() {
|
||||||
|
const row = document.getElementById("intel-stats-row");
|
||||||
|
if (!row) return true;
|
||||||
|
|
||||||
|
const data = await api("/admin/api/intelligence/stats");
|
||||||
|
if (!data.available) {
|
||||||
|
const notice = document.getElementById("intel-unavailable");
|
||||||
|
const content = document.getElementById("intel-content");
|
||||||
|
if (notice) notice.style.display = "";
|
||||||
|
if (content) content.style.display = "none";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notice = document.getElementById("intel-unavailable");
|
||||||
|
const content = document.getElementById("intel-content");
|
||||||
|
if (notice) notice.style.display = "none";
|
||||||
|
if (content) content.style.display = "";
|
||||||
|
|
||||||
|
const queueMap = {};
|
||||||
|
(data.queue || []).forEach(r => queueMap[r.status] = r.n);
|
||||||
|
|
||||||
|
row.innerHTML = [
|
||||||
|
["Queue pending", (queueMap.pending || 0).toLocaleString()],
|
||||||
|
["Processed", (queueMap.processed || 0).toLocaleString()],
|
||||||
|
["Skipped", (queueMap.skipped || 0).toLocaleString()],
|
||||||
|
["Knowledge rows", data.knowledge.toLocaleString()],
|
||||||
|
["Predictions", data.predictions.toLocaleString()],
|
||||||
|
["Companies", `${data.embeddings}/${data.companies} embedded`],
|
||||||
|
].map(([label, value]) => `
|
||||||
|
<div class="intel-stat-card">
|
||||||
|
<span class="label">${label}</span>
|
||||||
|
<span class="value">${value}</span>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function loadIntelCompanies() {
|
||||||
|
const sel = document.getElementById("i-company");
|
||||||
|
if (!sel) return;
|
||||||
|
|
||||||
|
const companies = await api("/admin/api/intelligence/companies");
|
||||||
|
sel.innerHTML = '<option value="">All companies</option>';
|
||||||
|
companies.forEach(c => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = c.id;
|
||||||
|
opt.textContent = `${c.name} (${c.ticker})`;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let intelRows = [];
|
||||||
|
|
||||||
|
function openIntelDetail(id, view) {
|
||||||
|
const row = intelRows.find(r => r.id === id);
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
document.getElementById("intel-modal-title").textContent =
|
||||||
|
`${row.company_name} — Event ${row.event_id}`;
|
||||||
|
|
||||||
|
const meta = [`type: ${row.type || view}`, `created: ${row.created_at ? row.created_at.slice(0,16) : "—"}`];
|
||||||
|
document.getElementById("intel-modal-meta").innerHTML = meta.map(m => `<span>${escapeHtml(m)}</span>`).join("");
|
||||||
|
|
||||||
|
let body = "";
|
||||||
|
if (view === "knowledge") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.data);
|
||||||
|
body = Object.entries(parsed).map(([k, v]) => `${k}: ${v}`).join("\n");
|
||||||
|
} catch (_) { body = row.data; }
|
||||||
|
} else {
|
||||||
|
body = [
|
||||||
|
row.rationale,
|
||||||
|
"",
|
||||||
|
`direction: ${row.direction || "—"}`,
|
||||||
|
`magnitude: ${row.magnitude || "—"}`,
|
||||||
|
`timeframe: ${row.timeframe || "—"}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("intel-modal-body").textContent = body;
|
||||||
|
document.getElementById("intelOverlay").classList.add("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const closeBtn = document.getElementById("intelCloseBtn");
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.onclick = () => document.getElementById("intelOverlay").classList.remove("open");
|
||||||
|
}
|
||||||
|
});
|
||||||
103
public/admin/assets/js/intel-signals.js
Normal file
103
public/admin/assets/js/intel-signals.js
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
// intelligence → signals (trade signal cards)
|
||||||
|
// depends on: app.js, intel-shared.js
|
||||||
|
|
||||||
|
async function loadSignals() {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await api("/admin/api/intelligence/signals");
|
||||||
|
} catch (_) {
|
||||||
|
data = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = document.getElementById("intel-signals-grid");
|
||||||
|
const empty = document.getElementById("intel-signals-empty");
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
grid.innerHTML = "";
|
||||||
|
empty.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
empty.style.display = "none";
|
||||||
|
|
||||||
|
grid.innerHTML = data.map(s => {
|
||||||
|
const firstSentence = (s.summary || "").split(/\.\s+/)[0].replace(/\.$/, "") + ".";
|
||||||
|
const ts = s.generated_at ? s.generated_at.slice(0, 16).replace("T", " ") : "—";
|
||||||
|
|
||||||
|
let drivers = [];
|
||||||
|
let risks = [];
|
||||||
|
let predIds = [];
|
||||||
|
try { drivers = JSON.parse(s.key_drivers || "[]"); } catch (_) {}
|
||||||
|
try { risks = JSON.parse(s.risk_factors || "[]"); } catch (_) {}
|
||||||
|
try { predIds = JSON.parse(s.supporting_prediction_ids || "[]"); } catch (_) {}
|
||||||
|
|
||||||
|
const driverItems = drivers.map(d => `<li>${escapeHtml(d)}</li>`).join("");
|
||||||
|
const riskItems = risks.map(r => `<li>${escapeHtml(r)}</li>`).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="signal-card" id="signal-card-${s.company_id}">
|
||||||
|
<div class="signal-card-glance" onclick="toggleSignalCard(${s.company_id})">
|
||||||
|
<div class="signal-card-header">
|
||||||
|
<div>
|
||||||
|
<div class="signal-company">${escapeHtml(s.company_name)}</div>
|
||||||
|
<div class="signal-ticker">${escapeHtml(s.ticker)}</div>
|
||||||
|
</div>
|
||||||
|
<span class="signal-badge ${s.signal}">${s.signal}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-tags">
|
||||||
|
<span class="signal-tag">conf: ${escapeHtml(s.confidence)}</span>
|
||||||
|
<span class="signal-tag">risk: ${escapeHtml(s.risk_level)}</span>
|
||||||
|
<span class="signal-tag">${escapeHtml(s.timeframe)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-summary">${escapeHtml(firstSentence)}</div>
|
||||||
|
<div class="signal-ts">Generated ${ts}</div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-card-detail">
|
||||||
|
<div class="signal-detail-section">
|
||||||
|
<div class="signal-detail-label">Summary</div>
|
||||||
|
<p>${escapeHtml(s.summary || "—")}</p>
|
||||||
|
</div>
|
||||||
|
${driverItems ? `<div class="signal-detail-section"><div class="signal-detail-label">Key drivers</div><ul>${driverItems}</ul></div>` : ""}
|
||||||
|
${riskItems ? `<div class="signal-detail-section"><div class="signal-detail-label">Risk factors</div><ul>${riskItems}</ul></div>` : ""}
|
||||||
|
<div class="signal-detail-section">
|
||||||
|
<div class="signal-detail-label">Data used</div>
|
||||||
|
<p style="color:var(--muted-dark)">${predIds.length} predictions · window: 90 days</p>
|
||||||
|
</div>
|
||||||
|
<button class="signal-regen-btn" onclick="regenerateSignal(event, ${s.company_id})">Regenerate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function toggleSignalCard(companyId) {
|
||||||
|
const card = document.getElementById(`signal-card-${companyId}`);
|
||||||
|
if (card) card.classList.toggle("expanded");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function regenerateSignal(ev, companyId) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api(`/admin/api/intelligence/signals/${companyId}`, { method: "DELETE" });
|
||||||
|
toast("Signal cleared — worker will regenerate on next cycle");
|
||||||
|
const card = document.getElementById(`signal-card-${companyId}`);
|
||||||
|
if (card) card.remove();
|
||||||
|
|
||||||
|
const grid = document.getElementById("intel-signals-grid");
|
||||||
|
if (!grid.children.length) {
|
||||||
|
document.getElementById("intel-signals-empty").style.display = "";
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
toast("Failed to clear signal", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
|
const ok = await loadIntelStatsRow();
|
||||||
|
if (!ok) return;
|
||||||
|
loadSignals();
|
||||||
|
});
|
||||||
71
public/admin/assets/js/sql.js
Normal file
71
public/admin/assets/js/sql.js
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
// sql console — run ad-hoc queries against archive or intelligence db
|
||||||
|
// depends on: app.js
|
||||||
|
|
||||||
|
async function runSql() {
|
||||||
|
const sql = document.getElementById("sql-input").value.trim();
|
||||||
|
if (!sql) return;
|
||||||
|
|
||||||
|
const database = document.getElementById("sql-db").value;
|
||||||
|
const errEl = document.getElementById("sql-error");
|
||||||
|
const resultsEl = document.getElementById("sql-results");
|
||||||
|
const elapsedEl = document.getElementById("sql-elapsed");
|
||||||
|
|
||||||
|
errEl.style.display = "none";
|
||||||
|
resultsEl.innerHTML = "";
|
||||||
|
elapsedEl.textContent = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetch("/admin/api/sql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ sql, database }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
errEl.textContent = data.error;
|
||||||
|
errEl.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsedEl.textContent = `${data.elapsed}ms`;
|
||||||
|
|
||||||
|
const blocks = [];
|
||||||
|
for (const r of (data.results || [])) {
|
||||||
|
if (r.error) {
|
||||||
|
blocks.push(`<div style="color:#fca5a5; font-size:13px; margin-bottom:12px"><span style="color:var(--muted-dark); font-size:11px; display:block; margin-bottom:4px; font-family:'SF Mono','Fira Code',monospace">${escapeHtml(r.sql.slice(0, 120))}</span>${escapeHtml(r.error)}</div>`);
|
||||||
|
} else if (r.rows && r.rows.length > 0) {
|
||||||
|
const cols = Object.keys(r.rows[0]);
|
||||||
|
blocks.push(`
|
||||||
|
<div style="margin-bottom:16px">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr>${cols.map(c => `<th>${escapeHtml(c)}</th>`).join("")}</tr></thead>
|
||||||
|
<tbody>${r.rows.map(row =>
|
||||||
|
`<tr>${cols.map(c => `<td><span class="truncate" style="max-width:300px" title="${escapeHtml(String(row[c] ?? ""))}">${row[c] == null ? '<span style="color:var(--muted-dark)">NULL</span>' : escapeHtml(row[c])}</span></td>`).join("")}</tr>`
|
||||||
|
).join("")}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="color:var(--muted-dark); font-size:12px; margin-top:6px">${r.rows.length} row${r.rows.length !== 1 ? "s" : ""}</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
} else if (r.rows) {
|
||||||
|
blocks.push(`<div style="color:var(--muted); font-size:13px; margin-bottom:12px">No rows returned.</div>`);
|
||||||
|
} else {
|
||||||
|
blocks.push(`<div style="color:#86efac; font-size:13px; margin-bottom:12px">${r.changes} row${r.changes !== 1 ? "s" : ""} affected.</div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resultsEl.innerHTML = blocks.join("");
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = e.message;
|
||||||
|
errEl.style.display = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
document.getElementById("sql-run-btn").onclick = runSql;
|
||||||
|
|
||||||
|
document.getElementById("sql-input").addEventListener("keydown", e => {
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) runSql();
|
||||||
|
});
|
||||||
|
});
|
||||||
19
public/admin/assets/js/stats.js
Normal file
19
public/admin/assets/js/stats.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
// stats page — pipeline throughput + per-source/per-status breakdowns
|
||||||
|
// depends on: app.js
|
||||||
|
|
||||||
|
async function loadStatsPage() {
|
||||||
|
const data = await api("/admin/api/stats");
|
||||||
|
|
||||||
|
document.getElementById("sourceTable").innerHTML = data.bySource
|
||||||
|
.map(r => `<tr><td>${escapeHtml(r.source)}</td><td style="text-align:right; padding-left:24px">${r.n.toLocaleString()}</td></tr>`).join("");
|
||||||
|
|
||||||
|
document.getElementById("statusTable").innerHTML = data.byStatus
|
||||||
|
.map(r => `<tr><td>${badgeHtml(r.status === "null" ? null : r.status)}</td><td style="text-align:right; padding-left:24px">${r.n.toLocaleString()}</td></tr>`).join("");
|
||||||
|
|
||||||
|
document.getElementById("rate-ingested").textContent = (data.ingestedPerHour || 0).toLocaleString();
|
||||||
|
document.getElementById("rate-content").textContent = (data.contentPerHour || 0).toLocaleString();
|
||||||
|
document.getElementById("rate-embeddings").textContent = (data.embeddingsPerHour || 0).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", loadStatsPage);
|
||||||
136
public/admin/pages/articles.html
Normal file
136
public/admin/pages/articles.html
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Duriin Admin — Articles</title>
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/base.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/layout.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/components.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="app-header">
|
||||||
|
<h1>Duriin <span>Admin</span></h1>
|
||||||
|
<nav class="tabs">
|
||||||
|
<a href="/admin/articles" class="active">Articles</a>
|
||||||
|
<a href="/admin/events">Events</a>
|
||||||
|
<a href="/admin/stats">Stats</a>
|
||||||
|
<a href="/admin/intelligence">Intelligence</a>
|
||||||
|
<a href="/admin/sql">SQL</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stats-bar" id="statsBar">
|
||||||
|
<div class="stat"><span class="label">Total articles</span><span class="value" id="s-total">—</span></div>
|
||||||
|
<div class="stat"><span class="label">With content</span><span class="value" id="s-content">—</span></div>
|
||||||
|
<div class="stat"><span class="label">With embedding</span><span class="value" id="s-embed">—</span></div>
|
||||||
|
<div class="stat"><span class="label">Events</span><span class="value" id="s-events">—</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<label>Keyword <input type="text" id="f-keyword" placeholder="search..." /></label>
|
||||||
|
<label>Source <select id="f-source"><option value="">All sources</option></select></label>
|
||||||
|
<label>Status
|
||||||
|
<select id="f-status">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="ok">ok</option>
|
||||||
|
<option value="error">error</option>
|
||||||
|
<option value="pending">pending</option>
|
||||||
|
<option value="null">no status</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>From <input type="date" id="f-from" /></label>
|
||||||
|
<label>To <input type="date" id="f-to" /></label>
|
||||||
|
<button class="primary" id="searchBtn" style="align-self:flex-end">Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:44px">ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Ingested</th>
|
||||||
|
<th style="width:80px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="articleTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<button id="prevBtn">← Prev</button>
|
||||||
|
<span id="pageInfo"></span>
|
||||||
|
<button id="nextBtn">Next →</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- article edit modal -->
|
||||||
|
<div class="overlay" id="articleOverlay">
|
||||||
|
<div class="modal">
|
||||||
|
<h2 id="modalTitle">Article</h2>
|
||||||
|
<div id="modalMeta" style="font-size:12px; color:var(--muted-dark); margin-top:4px; display:flex; gap:14px; flex-wrap:wrap"></div>
|
||||||
|
<div class="modal-divider"></div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Title</label>
|
||||||
|
<input type="text" id="m-title" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea id="m-desc" style="min-height:70px"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Content</label>
|
||||||
|
<textarea id="m-content" style="min-height:200px"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:12px; flex-wrap:wrap">
|
||||||
|
<div class="field" style="flex:1; min-width:140px">
|
||||||
|
<label>Content status</label>
|
||||||
|
<select id="m-status">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
<option value="ok">ok</option>
|
||||||
|
<option value="error">error</option>
|
||||||
|
<option value="pending">pending</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="flex:1; min-width:140px">
|
||||||
|
<label>Language</label>
|
||||||
|
<input type="text" id="m-lang" placeholder="en" />
|
||||||
|
</div>
|
||||||
|
<div class="field" style="flex:1; min-width:140px">
|
||||||
|
<label>Pub date</label>
|
||||||
|
<input type="text" id="m-pubdate" />
|
||||||
|
</div>
|
||||||
|
<div class="field" style="flex:1; min-width:140px">
|
||||||
|
<label>Is index page</label>
|
||||||
|
<select id="m-indexpage">
|
||||||
|
<option value="0">No</option>
|
||||||
|
<option value="1">Yes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="danger" id="deleteBtn">Delete</button>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button id="cancelBtn">Cancel</button>
|
||||||
|
<button class="primary" id="saveBtn">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
|
||||||
|
|
||||||
|
<script src="/admin/assets/js/app.js"></script>
|
||||||
|
<script src="/admin/assets/js/articles.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
80
public/admin/pages/events.html
Normal file
80
public/admin/pages/events.html
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Duriin Admin — Events</title>
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/base.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/layout.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/components.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="app-header">
|
||||||
|
<h1>Duriin <span>Admin</span></h1>
|
||||||
|
<nav class="tabs">
|
||||||
|
<a href="/admin/articles">Articles</a>
|
||||||
|
<a href="/admin/events" class="active">Events</a>
|
||||||
|
<a href="/admin/stats">Stats</a>
|
||||||
|
<a href="/admin/intelligence">Intelligence</a>
|
||||||
|
<a href="/admin/sql">SQL</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stats-bar" id="statsBar">
|
||||||
|
<div class="stat"><span class="label">Total articles</span><span class="value" id="s-total">—</span></div>
|
||||||
|
<div class="stat"><span class="label">With content</span><span class="value" id="s-content">—</span></div>
|
||||||
|
<div class="stat"><span class="label">With embedding</span><span class="value" id="s-embed">—</span></div>
|
||||||
|
<div class="stat"><span class="label">Events</span><span class="value" id="s-events">—</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:44px">ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th style="width:100px">Articles</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th style="width:80px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="eventTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<button id="ePrevBtn">← Prev</button>
|
||||||
|
<span id="ePageInfo"></span>
|
||||||
|
<button id="eNextBtn">Next →</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- event edit modal -->
|
||||||
|
<div class="overlay" id="eventOverlay">
|
||||||
|
<div class="modal" style="width:480px">
|
||||||
|
<h2>Edit Event</h2>
|
||||||
|
<div class="modal-divider"></div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Title</label>
|
||||||
|
<input type="text" id="em-title" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="danger" id="eDeleteBtn">Delete event</button>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button id="eCancelBtn">Cancel</button>
|
||||||
|
<button class="primary" id="eSaveBtn">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
|
||||||
|
|
||||||
|
<script src="/admin/assets/js/app.js"></script>
|
||||||
|
<script src="/admin/assets/js/events.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
81
public/admin/pages/intelligence/graph.html
Normal file
81
public/admin/pages/intelligence/graph.html
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Duriin Admin — Intelligence / Graph</title>
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/base.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/layout.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/components.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/intel.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="app-header">
|
||||||
|
<h1>Duriin <span>Admin</span></h1>
|
||||||
|
<nav class="tabs">
|
||||||
|
<a href="/admin/articles">Articles</a>
|
||||||
|
<a href="/admin/events">Events</a>
|
||||||
|
<a href="/admin/stats">Stats</a>
|
||||||
|
<a href="/admin/intelligence" class="active">Intelligence</a>
|
||||||
|
<a href="/admin/sql">SQL</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="subnav">
|
||||||
|
<a href="/admin/intelligence/knowledge">Knowledge</a>
|
||||||
|
<a href="/admin/intelligence/predictions">Predictions</a>
|
||||||
|
<a href="/admin/intelligence/signals">Signals</a>
|
||||||
|
<a href="/admin/intelligence/graph" class="active">Graph</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
|
||||||
|
<div id="intel-unavailable" class="intel-unavailable" style="display:none">
|
||||||
|
intelligence.sqlite not found — is the intelligence worker running?
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="intel-content">
|
||||||
|
<div style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom:24px" id="intel-stats-row"></div>
|
||||||
|
|
||||||
|
<div id="intel-graph-layout">
|
||||||
|
<div id="intel-graph-svg-wrap">
|
||||||
|
<svg id="intel-graph-svg"></svg>
|
||||||
|
|
||||||
|
<div id="graph-empty" style="display:none; position:absolute; inset:0; color:var(--muted); font-size:13px; text-align:center; padding-top:120px">No relationship data yet</div>
|
||||||
|
|
||||||
|
<div id="graph-controls">
|
||||||
|
<input id="graph-search" placeholder="Search companies..." autocomplete="off" spellcheck="false" />
|
||||||
|
<div id="graph-chips">
|
||||||
|
<button class="graph-chip active" data-type="all">All</button>
|
||||||
|
<button class="graph-chip" data-type="competitor">Competitor</button>
|
||||||
|
<button class="graph-chip" data-type="customer">Customer</button>
|
||||||
|
<button class="graph-chip" data-type="supplier">Supplier</button>
|
||||||
|
<button class="graph-chip" data-type="investor">Investor</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="graph-legend">
|
||||||
|
<span><span class="graph-legend-dot" style="background:#E24B4A"></span>Competitor</span>
|
||||||
|
<span><span class="graph-legend-dot" style="background:#639922"></span>Customer</span>
|
||||||
|
<span><span class="graph-legend-dot" style="background:#BA7517"></span>Supplier</span>
|
||||||
|
<span><span class="graph-legend-dot" style="background:#378ADD"></span>Investor</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside id="graph-info">
|
||||||
|
<p class="graph-empty-msg">Click a node to see its connections.</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
||||||
|
<script src="/admin/assets/js/app.js"></script>
|
||||||
|
<script src="/admin/assets/js/intel-shared.js"></script>
|
||||||
|
<script src="/admin/assets/js/intel-graph.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
98
public/admin/pages/intelligence/knowledge.html
Normal file
98
public/admin/pages/intelligence/knowledge.html
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Duriin Admin — Intelligence / Knowledge</title>
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/base.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/layout.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/components.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/intel.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="app-header">
|
||||||
|
<h1>Duriin <span>Admin</span></h1>
|
||||||
|
<nav class="tabs">
|
||||||
|
<a href="/admin/articles">Articles</a>
|
||||||
|
<a href="/admin/events">Events</a>
|
||||||
|
<a href="/admin/stats">Stats</a>
|
||||||
|
<a href="/admin/intelligence" class="active">Intelligence</a>
|
||||||
|
<a href="/admin/sql">SQL</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="subnav">
|
||||||
|
<a href="/admin/intelligence/knowledge" class="active">Knowledge</a>
|
||||||
|
<a href="/admin/intelligence/predictions">Predictions</a>
|
||||||
|
<a href="/admin/intelligence/signals">Signals</a>
|
||||||
|
<a href="/admin/intelligence/graph">Graph</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
|
||||||
|
<div id="intel-unavailable" class="intel-unavailable" style="display:none">
|
||||||
|
intelligence.sqlite not found — is the intelligence worker running?
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="intel-content">
|
||||||
|
<div style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom:24px" id="intel-stats-row"></div>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<label>Company
|
||||||
|
<select id="i-company"><option value="">All companies</option></select>
|
||||||
|
</label>
|
||||||
|
<label>Type
|
||||||
|
<select id="i-type">
|
||||||
|
<option value="">All types</option>
|
||||||
|
<option value="relationship">Relationship</option>
|
||||||
|
<option value="theme">Theme</option>
|
||||||
|
<option value="factor">Factor</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Sort
|
||||||
|
<select id="i-sort">
|
||||||
|
<option value="id">Recent first</option>
|
||||||
|
<option value="event_date">By event date</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="primary" id="i-filter-btn" style="align-self:flex-end">Filter</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead id="intel-thead"></thead>
|
||||||
|
<tbody id="intel-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<button id="iPrevBtn">← Prev</button>
|
||||||
|
<span id="iPageInfo"></span>
|
||||||
|
<button id="iNextBtn">Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- intelligence detail modal -->
|
||||||
|
<div class="overlay" id="intelOverlay">
|
||||||
|
<div class="modal" style="width:740px">
|
||||||
|
<h2 id="intel-modal-title">Detail</h2>
|
||||||
|
<div id="intel-modal-meta" style="font-size:12px; color:var(--muted-dark); margin-top:4px; display:flex; gap:12px; flex-wrap:wrap"></div>
|
||||||
|
<div class="modal-divider"></div>
|
||||||
|
<div id="intel-modal-body" class="intel-body"></div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="intelCloseBtn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
|
||||||
|
|
||||||
|
<script src="/admin/assets/js/app.js"></script>
|
||||||
|
<script src="/admin/assets/js/intel-shared.js"></script>
|
||||||
|
<script src="/admin/assets/js/intel-knowledge.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
89
public/admin/pages/intelligence/predictions.html
Normal file
89
public/admin/pages/intelligence/predictions.html
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Duriin Admin — Intelligence / Predictions</title>
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/base.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/layout.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/components.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/intel.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="app-header">
|
||||||
|
<h1>Duriin <span>Admin</span></h1>
|
||||||
|
<nav class="tabs">
|
||||||
|
<a href="/admin/articles">Articles</a>
|
||||||
|
<a href="/admin/events">Events</a>
|
||||||
|
<a href="/admin/stats">Stats</a>
|
||||||
|
<a href="/admin/intelligence" class="active">Intelligence</a>
|
||||||
|
<a href="/admin/sql">SQL</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="subnav">
|
||||||
|
<a href="/admin/intelligence/knowledge">Knowledge</a>
|
||||||
|
<a href="/admin/intelligence/predictions" class="active">Predictions</a>
|
||||||
|
<a href="/admin/intelligence/signals">Signals</a>
|
||||||
|
<a href="/admin/intelligence/graph">Graph</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
|
||||||
|
<div id="intel-unavailable" class="intel-unavailable" style="display:none">
|
||||||
|
intelligence.sqlite not found — is the intelligence worker running?
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="intel-content">
|
||||||
|
<div style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom:24px" id="intel-stats-row"></div>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<label>Company
|
||||||
|
<select id="i-company"><option value="">All companies</option></select>
|
||||||
|
</label>
|
||||||
|
<label>Sort
|
||||||
|
<select id="i-sort">
|
||||||
|
<option value="id">Recent first</option>
|
||||||
|
<option value="event_date">By event date</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="primary" id="i-filter-btn" style="align-self:flex-end">Filter</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead id="intel-thead"></thead>
|
||||||
|
<tbody id="intel-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<button id="iPrevBtn">← Prev</button>
|
||||||
|
<span id="iPageInfo"></span>
|
||||||
|
<button id="iNextBtn">Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="overlay" id="intelOverlay">
|
||||||
|
<div class="modal" style="width:740px">
|
||||||
|
<h2 id="intel-modal-title">Detail</h2>
|
||||||
|
<div id="intel-modal-meta" style="font-size:12px; color:var(--muted-dark); margin-top:4px; display:flex; gap:12px; flex-wrap:wrap"></div>
|
||||||
|
<div class="modal-divider"></div>
|
||||||
|
<div id="intel-modal-body" class="intel-body"></div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="intelCloseBtn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
|
||||||
|
|
||||||
|
<script src="/admin/assets/js/app.js"></script>
|
||||||
|
<script src="/admin/assets/js/intel-shared.js"></script>
|
||||||
|
<script src="/admin/assets/js/intel-predictions.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
53
public/admin/pages/intelligence/signals.html
Normal file
53
public/admin/pages/intelligence/signals.html
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Duriin Admin — Intelligence / Signals</title>
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/base.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/layout.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/components.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/intel.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="app-header">
|
||||||
|
<h1>Duriin <span>Admin</span></h1>
|
||||||
|
<nav class="tabs">
|
||||||
|
<a href="/admin/articles">Articles</a>
|
||||||
|
<a href="/admin/events">Events</a>
|
||||||
|
<a href="/admin/stats">Stats</a>
|
||||||
|
<a href="/admin/intelligence" class="active">Intelligence</a>
|
||||||
|
<a href="/admin/sql">SQL</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="subnav">
|
||||||
|
<a href="/admin/intelligence/knowledge">Knowledge</a>
|
||||||
|
<a href="/admin/intelligence/predictions">Predictions</a>
|
||||||
|
<a href="/admin/intelligence/signals" class="active">Signals</a>
|
||||||
|
<a href="/admin/intelligence/graph">Graph</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
|
||||||
|
<div id="intel-unavailable" class="intel-unavailable" style="display:none">
|
||||||
|
intelligence.sqlite not found — is the intelligence worker running?
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="intel-content">
|
||||||
|
<div style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom:24px" id="intel-stats-row"></div>
|
||||||
|
|
||||||
|
<div id="intel-signals-grid" class="signal-grid"></div>
|
||||||
|
<div id="intel-signals-empty" class="signal-empty" style="display:none">No signals generated yet — waiting for the signal worker to run.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
|
||||||
|
|
||||||
|
<script src="/admin/assets/js/app.js"></script>
|
||||||
|
<script src="/admin/assets/js/intel-shared.js"></script>
|
||||||
|
<script src="/admin/assets/js/intel-signals.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
52
public/admin/pages/sql.html
Normal file
52
public/admin/pages/sql.html
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Duriin Admin — SQL</title>
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/base.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/layout.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/components.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="app-header">
|
||||||
|
<h1>Duriin <span>Admin</span></h1>
|
||||||
|
<nav class="tabs">
|
||||||
|
<a href="/admin/articles">Articles</a>
|
||||||
|
<a href="/admin/events">Events</a>
|
||||||
|
<a href="/admin/stats">Stats</a>
|
||||||
|
<a href="/admin/intelligence">Intelligence</a>
|
||||||
|
<a href="/admin/sql" class="active">SQL</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stats-bar" id="statsBar">
|
||||||
|
<div class="stat"><span class="label">Total articles</span><span class="value" id="s-total">—</span></div>
|
||||||
|
<div class="stat"><span class="label">With content</span><span class="value" id="s-content">—</span></div>
|
||||||
|
<div class="stat"><span class="label">With embedding</span><span class="value" id="s-embed">—</span></div>
|
||||||
|
<div class="stat"><span class="label">Events</span><span class="value" id="s-events">—</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
|
||||||
|
<div style="display:flex; gap:10px; margin-bottom:12px; align-items:center">
|
||||||
|
<select id="sql-db" style="min-width:160px">
|
||||||
|
<option value="archive">archive.sqlite</option>
|
||||||
|
<option value="intelligence">intelligence.sqlite</option>
|
||||||
|
</select>
|
||||||
|
<button class="primary" id="sql-run-btn">Run</button>
|
||||||
|
<span id="sql-elapsed" style="color:var(--muted-dark); font-size:12px"></span>
|
||||||
|
</div>
|
||||||
|
<textarea id="sql-input" style="width:100%; min-height:120px; font-family:'SF Mono','Fira Code',monospace; font-size:13px; margin-bottom:12px" placeholder="SELECT ..."></textarea>
|
||||||
|
<div id="sql-error" style="color:#fca5a5; font-size:13px; margin-bottom:10px; display:none"></div>
|
||||||
|
<div id="sql-results" style="overflow-x:auto"></div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
|
||||||
|
|
||||||
|
<script src="/admin/assets/js/app.js"></script>
|
||||||
|
<script src="/admin/assets/js/sql.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
81
public/admin/pages/stats.html
Normal file
81
public/admin/pages/stats.html
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Duriin Admin — Stats</title>
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/base.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/layout.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/components.css">
|
||||||
|
<link rel="stylesheet" href="/admin/assets/css/intel.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="app-header">
|
||||||
|
<h1>Duriin <span>Admin</span></h1>
|
||||||
|
<nav class="tabs">
|
||||||
|
<a href="/admin/articles">Articles</a>
|
||||||
|
<a href="/admin/events">Events</a>
|
||||||
|
<a href="/admin/stats" class="active">Stats</a>
|
||||||
|
<a href="/admin/intelligence">Intelligence</a>
|
||||||
|
<a href="/admin/sql">SQL</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stats-bar" id="statsBar">
|
||||||
|
<div class="stat"><span class="label">Total articles</span><span class="value" id="s-total">—</span></div>
|
||||||
|
<div class="stat"><span class="label">With content</span><span class="value" id="s-content">—</span></div>
|
||||||
|
<div class="stat"><span class="label">With embedding</span><span class="value" id="s-embed">—</span></div>
|
||||||
|
<div class="stat"><span class="label">Events</span><span class="value" id="s-events">—</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
|
||||||
|
<div style="margin-bottom:28px">
|
||||||
|
<div class="section-heading">Pipeline throughput — last 1 hour</div>
|
||||||
|
<div style="display:flex; gap:12px; flex-wrap:wrap; margin-top:10px">
|
||||||
|
<div class="intel-stat-card" style="min-width:180px">
|
||||||
|
<span class="label">Articles ingested</span>
|
||||||
|
<span class="value" id="rate-ingested">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="intel-stat-card" style="min-width:180px">
|
||||||
|
<span class="label">Content fetched</span>
|
||||||
|
<span class="value" id="rate-content">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="intel-stat-card" style="min-width:180px">
|
||||||
|
<span class="label">Embeddings generated</span>
|
||||||
|
<span class="value" id="rate-embeddings">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:32px; flex-wrap:wrap; padding-top:4px">
|
||||||
|
<div>
|
||||||
|
<div class="section-heading">By source</div>
|
||||||
|
<div class="table-wrap" style="width:auto; min-width:220px">
|
||||||
|
<table style="width:auto">
|
||||||
|
<thead><tr><th>Source</th><th style="text-align:right">Count</th></tr></thead>
|
||||||
|
<tbody id="sourceTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="section-heading">By content status</div>
|
||||||
|
<div class="table-wrap" style="width:auto; min-width:180px">
|
||||||
|
<table style="width:auto">
|
||||||
|
<thead><tr><th>Status</th><th style="text-align:right">Count</th></tr></thead>
|
||||||
|
<tbody id="statusTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
|
||||||
|
|
||||||
|
<script src="/admin/assets/js/app.js"></script>
|
||||||
|
<script src="/admin/assets/js/stats.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fastifyStatic = require('@fastify/static');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
|
|
@ -46,16 +47,59 @@ function checkAuth(request, reply) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const htmlPath = path.join(__dirname, '..', '..', 'admin.html');
|
const publicDir = path.join(__dirname, '..', '..', 'public', 'admin');
|
||||||
|
const assetsDir = path.join(publicDir, 'assets');
|
||||||
|
const pagesDir = path.join(publicDir, 'pages');
|
||||||
|
|
||||||
|
// map pretty url → page html file. keep these close to the routes so its
|
||||||
|
// obvious when a page gets added or renamed.
|
||||||
|
const pageMap = {
|
||||||
|
'/admin/articles': path.join(pagesDir, 'articles.html'),
|
||||||
|
'/admin/events': path.join(pagesDir, 'events.html'),
|
||||||
|
'/admin/stats': path.join(pagesDir, 'stats.html'),
|
||||||
|
'/admin/sql': path.join(pagesDir, 'sql.html'),
|
||||||
|
'/admin/intelligence/knowledge': path.join(pagesDir, 'intelligence', 'knowledge.html'),
|
||||||
|
'/admin/intelligence/predictions': path.join(pagesDir, 'intelligence', 'predictions.html'),
|
||||||
|
'/admin/intelligence/signals': path.join(pagesDir, 'intelligence', 'signals.html'),
|
||||||
|
'/admin/intelligence/graph': path.join(pagesDir, 'intelligence', 'graph.html'),
|
||||||
|
};
|
||||||
|
|
||||||
|
function sendPage(reply, filePath) {
|
||||||
|
reply.type('text/html');
|
||||||
|
reply.header('Cache-Control', 'no-cache');
|
||||||
|
return fs.createReadStream(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
async function adminRoutes(fastify) {
|
async function adminRoutes(fastify) {
|
||||||
|
|
||||||
fastify.get('/admin', async (request, reply) => {
|
// gate every request under /admin/* behind basic auth (covers pages, api, and assets)
|
||||||
if (!checkAuth(request, reply)) return;
|
fastify.addHook('onRequest', async (request, reply) => {
|
||||||
reply.type('text/html');
|
if (!checkAuth(request, reply)) return reply;
|
||||||
return fs.createReadStream(htmlPath);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// static assets (css + js) under /admin/assets/*
|
||||||
|
fastify.register(fastifyStatic, {
|
||||||
|
root: assetsDir,
|
||||||
|
prefix: '/admin/assets/',
|
||||||
|
decorateReply: false,
|
||||||
|
cacheControl: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// top-level entry — redirect to articles
|
||||||
|
fastify.get('/admin', async (request, reply) => {
|
||||||
|
reply.redirect('/admin/articles');
|
||||||
|
});
|
||||||
|
|
||||||
|
// intelligence root — redirect to the knowledge subsection
|
||||||
|
fastify.get('/admin/intelligence', async (request, reply) => {
|
||||||
|
reply.redirect('/admin/intelligence/knowledge');
|
||||||
|
});
|
||||||
|
|
||||||
|
// wire up each pretty page path
|
||||||
|
for (const [route, filePath] of Object.entries(pageMap)) {
|
||||||
|
fastify.get(route, async (request, reply) => sendPage(reply, filePath));
|
||||||
|
}
|
||||||
|
|
||||||
// list articles — all of them, not just the ones with embeddings
|
// list articles — all of them, not just the ones with embeddings
|
||||||
fastify.get('/admin/api/articles', async (request, reply) => {
|
fastify.get('/admin/api/articles', async (request, reply) => {
|
||||||
if (!checkAuth(request, reply)) return;
|
if (!checkAuth(request, reply)) return;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue