From bec6763191a0af94e4c1e838021a50ac3f7b7566 Mon Sep 17 00:00:00 2001 From: ImBenji Date: Thu, 23 Apr 2026 22:58:19 +0100 Subject: [PATCH] add intelligence features; implement signals and predictions pages in admin panel --- admin.html | 2601 ----------------- package-lock.json | 761 ++--- package.json | 1 + public/admin/assets/css/base.css | 117 + public/admin/assets/css/components.css | 168 ++ public/admin/assets/css/intel.css | 534 ++++ public/admin/assets/css/layout.css | 177 ++ public/admin/assets/js/app.js | 111 + public/admin/assets/js/articles.js | 145 + public/admin/assets/js/events.js | 73 + public/admin/assets/js/intel-graph.js | 567 ++++ public/admin/assets/js/intel-knowledge.js | 63 + public/admin/assets/js/intel-predictions.js | 61 + public/admin/assets/js/intel-shared.js | 97 + public/admin/assets/js/intel-signals.js | 103 + public/admin/assets/js/sql.js | 71 + public/admin/assets/js/stats.js | 19 + public/admin/pages/articles.html | 136 + public/admin/pages/events.html | 80 + public/admin/pages/intelligence/graph.html | 81 + .../admin/pages/intelligence/knowledge.html | 98 + .../admin/pages/intelligence/predictions.html | 89 + public/admin/pages/intelligence/signals.html | 53 + public/admin/pages/sql.html | 52 + public/admin/pages/stats.html | 81 + src/routes/admin.js | 54 +- 26 files changed, 3265 insertions(+), 3128 deletions(-) delete mode 100644 admin.html create mode 100644 public/admin/assets/css/base.css create mode 100644 public/admin/assets/css/components.css create mode 100644 public/admin/assets/css/intel.css create mode 100644 public/admin/assets/css/layout.css create mode 100644 public/admin/assets/js/app.js create mode 100644 public/admin/assets/js/articles.js create mode 100644 public/admin/assets/js/events.js create mode 100644 public/admin/assets/js/intel-graph.js create mode 100644 public/admin/assets/js/intel-knowledge.js create mode 100644 public/admin/assets/js/intel-predictions.js create mode 100644 public/admin/assets/js/intel-shared.js create mode 100644 public/admin/assets/js/intel-signals.js create mode 100644 public/admin/assets/js/sql.js create mode 100644 public/admin/assets/js/stats.js create mode 100644 public/admin/pages/articles.html create mode 100644 public/admin/pages/events.html create mode 100644 public/admin/pages/intelligence/graph.html create mode 100644 public/admin/pages/intelligence/knowledge.html create mode 100644 public/admin/pages/intelligence/predictions.html create mode 100644 public/admin/pages/intelligence/signals.html create mode 100644 public/admin/pages/sql.html create mode 100644 public/admin/pages/stats.html diff --git a/admin.html b/admin.html deleted file mode 100644 index 1a18488..0000000 --- a/admin.html +++ /dev/null @@ -1,2601 +0,0 @@ - - - - - -Duriin Admin - - - - - -
-

Duriin Admin

-
- - - - - -
-
- -
-
Total articles
-
With content
-
With embedding
-
Events
-
- -
- - -
-
- - - - - - -
- -
- - - - - - - - - - - - -
IDTitleSourceStatusIngested
-
- - -
- - - - - - - - - - - - - -
- - -
- -
- - -
- -
- - -
- -
- -
- - - - diff --git a/package-lock.json b/package-lock.json index d7872bc..3cb4949 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,26 +11,16 @@ "dependencies": { "@extractus/article-extractor": "^8.0.18", "@fastify/cors": "^11.2.0", + "@fastify/static": "^9.1.3", "@google-cloud/bigquery": "^8.1.1", "better-sqlite3": "^12.4.1", "fastify": "^5.6.1", "node-cron": "^4.2.1", "playwright": "^1.59.1", "rss-parser": "^3.13.0", - "sharp": "^0.34.5", "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": { "version": "8.0.20", "resolved": "https://registry.npmjs.org/@extractus/article-extractor/-/article-extractor-8.0.20.tgz", @@ -47,6 +37,22 @@ "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": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", @@ -178,6 +184,53 @@ "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": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@google-cloud/bigquery/-/bigquery-8.1.1.tgz", @@ -276,469 +329,13 @@ "node": ">=18" } }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", "license": "MIT", "engines": { - "node": ">=18" - } - }, - "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": ">=8" } }, "node_modules/@mozilla/readability": { @@ -851,6 +448,15 @@ "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": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -933,6 +539,18 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "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": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -969,6 +587,19 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -1084,6 +715,15 @@ "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": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1199,6 +839,12 @@ "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": { "version": "4.0.0", "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==", "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": { "version": "10.6.2", "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" } }, + "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": { "version": "7.0.2", "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -1755,6 +1459,21 @@ "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": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -1764,6 +1483,15 @@ "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": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -1897,6 +1625,22 @@ "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2288,49 +2032,11 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "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/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" }, "node_modules/simple-concat": { "version": "1.0.1", @@ -2482,6 +2188,15 @@ "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": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", @@ -2603,19 +2318,21 @@ "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": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "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": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index e2dce5c..c8180e2 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@extractus/article-extractor": "^8.0.18", "@fastify/cors": "^11.2.0", + "@fastify/static": "^9.1.3", "@google-cloud/bigquery": "^8.1.1", "better-sqlite3": "^12.4.1", "fastify": "^5.6.1", diff --git a/public/admin/assets/css/base.css b/public/admin/assets/css/base.css new file mode 100644 index 0000000..028a135 --- /dev/null +++ b/public/admin/assets/css/base.css @@ -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; } diff --git a/public/admin/assets/css/components.css b/public/admin/assets/css/components.css new file mode 100644 index 0000000..ff35cef --- /dev/null +++ b/public/admin/assets/css/components.css @@ -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; } diff --git a/public/admin/assets/css/intel.css b/public/admin/assets/css/intel.css new file mode 100644 index 0000000..2b2454b --- /dev/null +++ b/public/admin/assets/css/intel.css @@ -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; +} diff --git a/public/admin/assets/css/layout.css b/public/admin/assets/css/layout.css new file mode 100644 index 0000000..13b07d8 --- /dev/null +++ b/public/admin/assets/css/layout.css @@ -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; +} diff --git a/public/admin/assets/js/app.js b/public/admin/assets/js/app.js new file mode 100644 index 0000000..b4b57a9 --- /dev/null +++ b/public/admin/assets/js/app.js @@ -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 `${s}`; +} + + +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(); +}); diff --git a/public/admin/assets/js/articles.js b/public/admin/assets/js/articles.js new file mode 100644 index 0000000..9925b57 --- /dev/null +++ b/public/admin/assets/js/articles.js @@ -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 => ` + + ${r.id} + + ${escapeHtml(r.title)} + ↗ ${new URL(r.url).hostname} + + ${r.source} + ${badgeHtml(r.content_status)} + ${r.ingested_at ? r.ingested_at.slice(0, 16) : "—"} + + + `).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 && `source: ${escapeHtml(a.source)}`, + a.pub_date && `pub: ${a.pub_date.slice(0, 16)}`, + `has_embedding: ${a.has_embedding ? "yes" : "no"}`, + a.content_error && `error: ${escapeHtml(a.content_error.slice(0, 80))}`, + ].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(); +}); diff --git a/public/admin/assets/js/events.js b/public/admin/assets/js/events.js new file mode 100644 index 0000000..47980ae --- /dev/null +++ b/public/admin/assets/js/events.js @@ -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 => ` + + ${r.id} + ${escapeHtml(r.title)} + ${r.article_count} + ${r.created_at ? r.created_at.slice(0, 16) : "—"} + + + `).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(); +}); diff --git a/public/admin/assets/js/intel-graph.js b/public/admin/assets/js/intel-graph.js new file mode 100644 index 0000000..065b4a9 --- /dev/null +++ b/public/admin/assets/js/intel-graph.js @@ -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 = + '

Click a node to see its connections.

'; +} + + +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 = `
${escapeHtml(node.label)}
`; + html += `${escapeHtml(node.sector)}`; + + let any = false; + for (const type of ["competitor", "customer", "supplier", "investor"]) { + const list = groups[type]; + if (!list.length) continue; + any = true; + html += `
${type} (${list.length})
`; + for (const o of list) { + const canExpand = !!o.companyId; + if (canExpand) { + html += ` +
+
+ ${escapeHtml(o.label)} + + ${escapeHtml(o.sector)} + + +
+
+
`; + } else { + html += ` +
+
+ ${escapeHtml(o.label)} + + untracked + +
+
`; + } + } + } + + if (!any) { + html += `

No relationships recorded.

`; + } + + 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 = '
Loading evidence…
'; + + 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 += `
+ ${escapeHtml(data.edge.confidence || "low")} + ×${data.edge.confirmation_count || 1} confirmations +
`; + } + + if (data.facts && data.facts.length) { + html += '
Backing facts
'; + for (const f of data.facts) { + html += `
+
${escapeHtml(f.claim)}
+
${escapeHtml(f.confidence || "low")} · ×${f.confirmation_count || 1}
+
`; + } + } + + if (data.events && data.events.length) { + html += `
Source events (${data.events.length})
`; + for (const ev of data.events) { + const title = ev.title || `Event #${ev.id}`; + html += `
+ + ${escapeHtml(title)} + #${ev.id} + `; + + if (ev.articles && ev.articles.length) { + html += '"; + } else { + html += '
No articles.
'; + } + + html += "
"; + } + } + + if (!html) { + html = '
No backing evidence recorded.
'; + } + + body.innerHTML = html; + row.dataset.loaded = "1"; + + } catch (err) { + body.innerHTML = '
Failed to load evidence.
'; + } +} + + +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(); +}); diff --git a/public/admin/assets/js/intel-knowledge.js b/public/admin/assets/js/intel-knowledge.js new file mode 100644 index 0000000..33643b9 --- /dev/null +++ b/public/admin/assets/js/intel-knowledge.js @@ -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 = ` + IDCompanyEventTypeDataEvent date`; + + 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 ` + ${r.id} + ${escapeHtml(r.company_name)} + ${r.event_id} + ${escapeHtml(r.type)} + ${escapeHtml(summary)} + ${r.event_date ? r.event_date.slice(0,10) : "—"} + `; + }).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(); +}); diff --git a/public/admin/assets/js/intel-predictions.js b/public/admin/assets/js/intel-predictions.js new file mode 100644 index 0000000..d51d6f8 --- /dev/null +++ b/public/admin/assets/js/intel-predictions.js @@ -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 = ` + IDCompanyEventTypeDirectionMagnitudeTimeframeRationaleEvent date`; + + document.getElementById("intel-tbody").innerHTML = data.rows.map(r => ` + + ${r.id} + ${escapeHtml(r.company_name)} + ${r.event_id} + ${escapeHtml(r.type)} + ${escapeHtml(r.direction || "—")} + ${escapeHtml(r.magnitude || "—")} + ${escapeHtml(r.timeframe || "—")} + ${escapeHtml(r.rationale || "—")} + ${r.event_date ? r.event_date.slice(0,10) : "—"} + + `).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(); +}); diff --git a/public/admin/assets/js/intel-shared.js b/public/admin/assets/js/intel-shared.js new file mode 100644 index 0000000..aa9b09b --- /dev/null +++ b/public/admin/assets/js/intel-shared.js @@ -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]) => ` +
+ ${label} + ${value} +
+ `).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 = ''; + 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 => `${escapeHtml(m)}`).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"); + } +}); diff --git a/public/admin/assets/js/intel-signals.js b/public/admin/assets/js/intel-signals.js new file mode 100644 index 0000000..d9851d2 --- /dev/null +++ b/public/admin/assets/js/intel-signals.js @@ -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 => `
  • ${escapeHtml(d)}
  • `).join(""); + const riskItems = risks.map(r => `
  • ${escapeHtml(r)}
  • `).join(""); + + return ` +
    +
    +
    +
    +
    ${escapeHtml(s.company_name)}
    +
    ${escapeHtml(s.ticker)}
    +
    + ${s.signal} +
    +
    + conf: ${escapeHtml(s.confidence)} + risk: ${escapeHtml(s.risk_level)} + ${escapeHtml(s.timeframe)} +
    +
    ${escapeHtml(firstSentence)}
    +
    Generated ${ts}
    +
    +
    +
    +
    Summary
    +

    ${escapeHtml(s.summary || "—")}

    +
    + ${driverItems ? `
    Key drivers
      ${driverItems}
    ` : ""} + ${riskItems ? `
    Risk factors
      ${riskItems}
    ` : ""} +
    +
    Data used
    +

    ${predIds.length} predictions  ·  window: 90 days

    +
    + +
    +
    + `; + }).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(); +}); diff --git a/public/admin/assets/js/sql.js b/public/admin/assets/js/sql.js new file mode 100644 index 0000000..46718af --- /dev/null +++ b/public/admin/assets/js/sql.js @@ -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(`
    ${escapeHtml(r.sql.slice(0, 120))}${escapeHtml(r.error)}
    `); + } else if (r.rows && r.rows.length > 0) { + const cols = Object.keys(r.rows[0]); + blocks.push(` +
    +
    + + ${cols.map(c => ``).join("")} + ${r.rows.map(row => + `${cols.map(c => ``).join("")}` + ).join("")} +
    ${escapeHtml(c)}
    ${row[c] == null ? 'NULL' : escapeHtml(row[c])}
    +
    +
    ${r.rows.length} row${r.rows.length !== 1 ? "s" : ""}
    +
    + `); + } else if (r.rows) { + blocks.push(`
    No rows returned.
    `); + } else { + blocks.push(`
    ${r.changes} row${r.changes !== 1 ? "s" : ""} affected.
    `); + } + } + 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(); + }); +}); diff --git a/public/admin/assets/js/stats.js b/public/admin/assets/js/stats.js new file mode 100644 index 0000000..64c3640 --- /dev/null +++ b/public/admin/assets/js/stats.js @@ -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 => `${escapeHtml(r.source)}${r.n.toLocaleString()}`).join(""); + + document.getElementById("statusTable").innerHTML = data.byStatus + .map(r => `${badgeHtml(r.status === "null" ? null : r.status)}${r.n.toLocaleString()}`).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); diff --git a/public/admin/pages/articles.html b/public/admin/pages/articles.html new file mode 100644 index 0000000..3b33f15 --- /dev/null +++ b/public/admin/pages/articles.html @@ -0,0 +1,136 @@ + + + + + +Duriin Admin — Articles + + + + + + +
    +

    Duriin Admin

    + +
    + +
    +
    Total articles
    +
    With content
    +
    With embedding
    +
    Events
    +
    + +
    + +
    + + + + + + +
    + +
    + + + + + + + + + + + + +
    IDTitleSourceStatusIngested
    +
    + + + +
    + + + +
    + +
    + +
    + + + + + diff --git a/public/admin/pages/events.html b/public/admin/pages/events.html new file mode 100644 index 0000000..bf6a672 --- /dev/null +++ b/public/admin/pages/events.html @@ -0,0 +1,80 @@ + + + + + +Duriin Admin — Events + + + + + + +
    +

    Duriin Admin

    + +
    + +
    +
    Total articles
    +
    With content
    +
    With embedding
    +
    Events
    +
    + +
    + +
    + + + + + + + + + + + +
    IDTitleArticlesCreated
    +
    + + + +
    + + + +
    + +
    + +
    + + + + + diff --git a/public/admin/pages/intelligence/graph.html b/public/admin/pages/intelligence/graph.html new file mode 100644 index 0000000..7536a01 --- /dev/null +++ b/public/admin/pages/intelligence/graph.html @@ -0,0 +1,81 @@ + + + + + +Duriin Admin — Intelligence / Graph + + + + + + + +
    +

    Duriin Admin

    + +
    + + + +
    + + + +
    +
    + +
    +
    + + + + +
    + +
    + + + + + +
    +
    + +
    + Competitor + Customer + Supplier + Investor +
    +
    + + +
    +
    + +
    + +
    + + + + + + + diff --git a/public/admin/pages/intelligence/knowledge.html b/public/admin/pages/intelligence/knowledge.html new file mode 100644 index 0000000..1a9aaeb --- /dev/null +++ b/public/admin/pages/intelligence/knowledge.html @@ -0,0 +1,98 @@ + + + + + +Duriin Admin — Intelligence / Knowledge + + + + + + + +
    +

    Duriin Admin

    + +
    + + + +
    + + + +
    +
    + +
    + + + + +
    + +
    + + + +
    +
    + + +
    + +
    + + + +
    + +
    + +
    + + + + + + diff --git a/public/admin/pages/intelligence/predictions.html b/public/admin/pages/intelligence/predictions.html new file mode 100644 index 0000000..21dfa20 --- /dev/null +++ b/public/admin/pages/intelligence/predictions.html @@ -0,0 +1,89 @@ + + + + + +Duriin Admin — Intelligence / Predictions + + + + + + + +
    +

    Duriin Admin

    + +
    + + + +
    + + + +
    +
    + +
    + + + +
    + +
    + + + +
    +
    + + +
    + +
    + + +
    + +
    + +
    + + + + + + diff --git a/public/admin/pages/intelligence/signals.html b/public/admin/pages/intelligence/signals.html new file mode 100644 index 0000000..1587873 --- /dev/null +++ b/public/admin/pages/intelligence/signals.html @@ -0,0 +1,53 @@ + + + + + +Duriin Admin — Intelligence / Signals + + + + + + + +
    +

    Duriin Admin

    + +
    + + + +
    + + + +
    +
    + +
    + +
    + +
    + +
    + + + + + + diff --git a/public/admin/pages/sql.html b/public/admin/pages/sql.html new file mode 100644 index 0000000..b41c719 --- /dev/null +++ b/public/admin/pages/sql.html @@ -0,0 +1,52 @@ + + + + + +Duriin Admin — SQL + + + + + + +
    +

    Duriin Admin

    + +
    + +
    +
    Total articles
    +
    With content
    +
    With embedding
    +
    Events
    +
    + +
    + +
    + + + +
    + + +
    + +
    + +
    + + + + + diff --git a/public/admin/pages/stats.html b/public/admin/pages/stats.html new file mode 100644 index 0000000..31afafe --- /dev/null +++ b/public/admin/pages/stats.html @@ -0,0 +1,81 @@ + + + + + +Duriin Admin — Stats + + + + + + + +
    +

    Duriin Admin

    + +
    + +
    +
    Total articles
    +
    With content
    +
    With embedding
    +
    Events
    +
    + +
    + +
    +
    Pipeline throughput — last 1 hour
    +
    +
    + Articles ingested + +
    +
    + Content fetched + +
    +
    + Embeddings generated + +
    +
    +
    + +
    +
    +
    By source
    +
    + + + +
    SourceCount
    +
    +
    + +
    +
    By content status
    +
    + + + +
    StatusCount
    +
    +
    +
    + +
    + +
    + + + + + diff --git a/src/routes/admin.js b/src/routes/admin.js index 795ad00..3cffe21 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1,5 +1,6 @@ const fs = require('fs'); const path = require('path'); +const fastifyStatic = require('@fastify/static'); const db = require('../db'); const config = require('../config'); const Database = require('better-sqlite3'); @@ -46,16 +47,59 @@ function checkAuth(request, reply) { 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) { - fastify.get('/admin', async (request, reply) => { - if (!checkAuth(request, reply)) return; - reply.type('text/html'); - return fs.createReadStream(htmlPath); + // gate every request under /admin/* behind basic auth (covers pages, api, and assets) + fastify.addHook('onRequest', async (request, reply) => { + if (!checkAuth(request, reply)) return reply; }); + // 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 fastify.get('/admin/api/articles', async (request, reply) => { if (!checkAuth(request, reply)) return;