Compare commits

..

54 Commits

Author SHA1 Message Date
52f7e68f25 chore: release v1.9.8
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m15s
Monorepo Pipeline / 🧹 Lint (push) Successful in 4m17s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m15s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 37s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 41s
Monorepo Pipeline / 🚀 Release (push) Successful in 1m44s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 2m31s
2026-03-03 11:52:29 +01:00
217ac33675 chore: release v1.9.8
Some checks are pending
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m11s
Monorepo Pipeline / 🧹 Lint (push) Successful in 4m7s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m19s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 37s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 41s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 2m32s
Monorepo Pipeline / 🚀 Release (push) Has started running
2026-03-03 11:44:54 +01:00
f2b8b136af chore: release v1.9.7
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m15s
Monorepo Pipeline / 🧹 Lint (push) Successful in 4m6s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m19s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 38s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 43s
Monorepo Pipeline / 🚀 Release (push) Successful in 1m54s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 2m33s
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 4s
2026-03-02 21:16:51 +01:00
2e07b213d1 chore: remove unused 3d dependencies in gatekeeper to fix lint 2026-03-02 21:16:49 +01:00
a2c1eaefba chore: release v1.9.6
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m18s
Monorepo Pipeline / 🧹 Lint (push) Failing after 3m32s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m3s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-03-02 21:08:34 +01:00
80ff266f9c fix: allow vitest to pass with no tests in seo-engine
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 54s
Monorepo Pipeline / 🧹 Lint (push) Failing after 2m7s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m13s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-03-02 21:04:14 +01:00
6b1c5b7e30 chore: release @mintel/payload-ai
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Failing after 47s
Monorepo Pipeline / 🧹 Lint (push) Failing after 2m3s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m10s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-03-02 21:00:51 +01:00
80eefad5ea feat: extract reusable @mintel/payload-ai package 2026-03-02 21:00:09 +01:00
72556af24c fix(mcp): handle gitea api envelope responses and add safety checks
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Failing after 54s
Monorepo Pipeline / 🧹 Lint (push) Failing after 2m36s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m27s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-03-02 12:53:11 +01:00
2a5466c6c0 feat(gitea-mcp): add custom Gitea MCP server for Antigravity compatibility
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧪 Test (push) Failing after 50s
Monorepo Pipeline / 🧹 Lint (push) Failing after 3m6s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m13s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-03-02 12:36:57 +01:00
2d36a4ec71 ci(qa): refactor QA suite into granular jobs and fix NPM_TOKEN auth
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Failing after 47s
Monorepo Pipeline / 🧹 Lint (push) Failing after 3m3s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m41s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-03-02 11:51:12 +01:00
ded9da7d32 feat(seo-engine): implement competitor scraper, MDX draft editor, and strategy report generator
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Failing after 51s
Monorepo Pipeline / 🧹 Lint (push) Failing after 2m25s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m28s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-03-02 10:16:11 +01:00
36ed26ad79 feat(gatekeeper): major UI upgrade - high-fidelity light theme, iridescent mouse-reactive form, and enhanced background animation
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m10s
Monorepo Pipeline / 🧹 Lint (push) Failing after 3m15s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m53s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 4s
2026-02-28 21:48:03 +01:00
4e72a0baac fix(pipeline): remove image-processor build job and cms-infra from gatekeeper dockerfile
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 46s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m48s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m48s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 4s
2026-02-27 23:08:39 +01:00
8ca7eb3f49 chore: release v1.9.5
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 59s
Monorepo Pipeline / 🧹 Lint (push) Successful in 3m33s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m4s
Monorepo Pipeline / 🐳 Build Image Processor (push) Failing after 24s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Failing after 37s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Failing after 14s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 41s
Monorepo Pipeline / 🚀 Release (push) Successful in 1m41s
2026-02-27 22:27:30 +01:00
32d3ff010a feat(release): introduce dedicated release script to replace flawed git push hook
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 7s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m4s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m50s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m21s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-27 21:03:54 +01:00
cb68e1fb5c chore: sync versions to v1.9.0
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m12s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m51s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m57s
Monorepo Pipeline / 🐳 Build Image Processor (push) Failing after 32s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Failing after 33s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 40s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 31s
Monorepo Pipeline / 🚀 Release (push) Successful in 1m36s
2026-02-27 21:01:52 +01:00
1bd7c6aba5 fix(husky): avoid echo syntax error under sh
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m5s
Monorepo Pipeline / 🧹 Lint (push) Successful in 3m11s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m46s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-27 19:41:16 +01:00
8b0e130b08 chore: sync versions to v1.9.4
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 13s
Monorepo Pipeline / 🧪 Test (push) Successful in 58s
Monorepo Pipeline / 🧹 Lint (push) Successful in 3m18s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m5s
Monorepo Pipeline / 🐳 Build Image Processor (push) Failing after 25s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Failing after 33s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 45s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 34s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m1s
2026-02-27 19:40:30 +01:00
bd1d33a157 fix(husky): aggressively intercept tag push and silence expected error 2026-02-27 19:40:28 +01:00
b70a89ec86 chore: sync versions to v1.9.3
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m15s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m55s
Monorepo Pipeline / 🧹 Lint (push) Successful in 4m20s
Monorepo Pipeline / 🐳 Build Image Processor (push) Failing after 35s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Failing after 45s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 52s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m9s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 52s
2026-02-27 19:37:34 +01:00
da28305c2d fix(husky): simplify pre-push hook to let native git push modified tag
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m3s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m54s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m44s
Monorepo Pipeline / 🐳 Build Image Processor (push) Failing after 28s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Failing after 35s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 42s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 36s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m2s
2026-02-27 19:37:30 +01:00
fecb5c50ea chore: sync versions to v1.9.2
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 6s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m6s
Monorepo Pipeline / 🧹 Lint (push) Successful in 3m30s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m12s
Monorepo Pipeline / 🐳 Build Image Processor (push) Failing after 31s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Failing after 33s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 39s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 43s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m54s
2026-02-27 19:37:02 +01:00
b4b81a8315 fix(husky): auto-push current branch to keep synced after version bump 2026-02-27 19:37:00 +01:00
98fb6e363f chore: sync versions to v1.9.1
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m13s
Monorepo Pipeline / 🧹 Lint (push) Successful in 3m6s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m18s
Monorepo Pipeline / 🐳 Build Image Processor (push) Failing after 55s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Failing after 43s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 42s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 38s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m50s
2026-02-27 19:36:15 +01:00
a3061b501a fix(husky): correct pre-push exit code to avoid duplicate pushes 2026-02-27 19:36:13 +01:00
ed271e260e fix(ci): add commitlint and globals to depcheck ignore list
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 47s
Monorepo Pipeline / 🧹 Lint (push) Has started running
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
2026-02-27 19:34:58 +01:00
f275b8c9f6 refactor: drop legacy image-processor and directus from pipeline
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 12s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m3s
Monorepo Pipeline / 🧹 Lint (push) Failing after 1m31s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m4s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-27 19:26:19 +01:00
526db11104 chore: sync versions to v1.9.0
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m10s
Monorepo Pipeline / 🧹 Lint (push) Failing after 1m53s
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
2026-02-27 19:22:57 +01:00
a9d89aa25a chore: remove unused dependencies across workspace and add depcheck to CI
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m1s
Monorepo Pipeline / 🧹 Lint (push) Failing after 1m29s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m1s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-27 19:15:42 +01:00
7702310a9c chore: remove Directus CMS and related dependencies
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m19s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m5s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m26s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-27 19:06:06 +01:00
fbf2153430 ci: require pnpm install success before running QA checks
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Failing after 53s
Monorepo Pipeline / 🧪 Test (push) Failing after 57s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m1s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-27 18:37:08 +01:00
a43d96dd0e fix(pdf): decouple 6 distinct PDFs, fix layout issues and DataForSEO event loop
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Failing after 1m1s
Monorepo Pipeline / 🧪 Test (push) Failing after 1m7s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m10s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-27 18:26:00 +01:00
60a2709999 ci: add depcheck step to nightly qa template
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Failing after 1m11s
Monorepo Pipeline / 🧪 Test (push) Failing after 1m16s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m20s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-27 18:08:54 +01:00
7ff15a34fc ci: add reusable core smoke tests composite action
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Failing after 54s
Monorepo Pipeline / 🧪 Test (push) Failing after 58s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m1s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-27 15:32:37 +01:00
8ea2ba8dbf ci: add reusable nightly qa template
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Failing after 1m4s
Monorepo Pipeline / 🧹 Lint (push) Failing after 1m12s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m13s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-27 15:30:11 +01:00
6ba240db0f chore(workspace): add gitea repository url to all packages
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Failing after 49s
Monorepo Pipeline / 🧪 Test (push) Failing after 53s
Monorepo Pipeline / 🏗️ Build (push) Failing after 56s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 4s
This ensures packages published to the registry link back to the at-mintel
repository in the Gitea UI packages tab.
2026-02-27 02:55:23 +01:00
10aa12f359 fix(gatekeeper): fix missing mintel logos on login page
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧹 Lint (push) Failing after 54s
Monorepo Pipeline / 🧪 Test (push) Failing after 57s
Monorepo Pipeline / 🏗️ Build (push) Failing after 49s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
- explicitly use /gatekeeper/ prefix for basePath routing
- make image unoptimized so it bypasses _next/image which can fail under traefik
2026-02-27 02:47:17 +01:00
863fe469d6 fix(gatekeeper): use GATEKEEPER_ORIGIN for login redirect URL in ForwardAuth verify
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Failing after 52s
Monorepo Pipeline / 🧪 Test (push) Failing after 57s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m1s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-27 02:02:48 +01:00
4fdf79b1bb fix: only scope @mintel to Gitea Packages, keep npmjs.org as default
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Failing after 53s
Monorepo Pipeline / 🧪 Test (push) Failing after 57s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m0s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-27 00:23:25 +01:00
5da88356a8 feat: migrate npm registry from Verdaccio to Gitea Packages
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Failing after 35s
Monorepo Pipeline / 🧪 Test (push) Failing after 35s
Monorepo Pipeline / 🏗️ Build (push) Failing after 12s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-27 00:12:00 +01:00
efd1341762 fix: add canvas build deps for gatekeeper x86 build
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Failing after 8s
Monorepo Pipeline / 🧪 Test (push) Failing after 7s
Monorepo Pipeline / 🏗️ Build (push) Failing after 7s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-26 23:10:47 +01:00
36a952db56 chore: sync versions to v1.8.21
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Failing after 9s
Monorepo Pipeline / 🧪 Test (push) Failing after 7s
Monorepo Pipeline / 🏗️ Build (push) Failing after 7s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-26 19:39:04 +01:00
8c637f0220 chore: trigger x86 ci build
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Failing after 1m31s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m27s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m30s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-26 19:05:19 +01:00
6dd97e7a6b chore: trigger x86 build for mb-grid and mintel.me
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Failing after 1m17s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m22s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m25s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-26 18:45:47 +01:00
9f426470bb fix(ci): update build platform from arm64 to amd64 for the new x86 server
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 50s
Monorepo Pipeline / 🏗️ Build (push) Failing after 56s
Monorepo Pipeline / 🧹 Lint (push) Failing after 1m8s
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-26 17:42:57 +01:00
960914ebb8 feat: content engine usw 2026-02-25 12:43:57 +01:00
a55a5bb834 fix: prevent .env changes during tagging and improve pre-push hook feedback
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 6s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m8s
Monorepo Pipeline / 🧪 Test (push) Successful in 4m16s
Monorepo Pipeline / 🧹 Lint (push) Successful in 5m34s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 4s
2026-02-23 14:10:04 +01:00
0aaf858f5b chore: sync versions to v1.8.20
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m3s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m57s
Monorepo Pipeline / 🏗️ Build (push) Successful in 4m38s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 1m12s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 1m42s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 1m31s
Monorepo Pipeline / 🐳 Build Image Processor (push) Successful in 2m50s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 8m14s
Monorepo Pipeline / 🚀 Release (push) Successful in 9m13s
2026-02-23 14:03:27 +01:00
ec562c1b2c fix: imgproxy issues 2026-02-23 14:03:17 +01:00
02e15c3f4a chore: sync versions to v1.8.19
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 3m54s
Monorepo Pipeline / 🧹 Lint (push) Successful in 4m12s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m42s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 1m7s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 1m43s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 1m37s
Monorepo Pipeline / 🐳 Build Image Processor (push) Successful in 2m47s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 6m39s
Monorepo Pipeline / 🚀 Release (push) Successful in 7m18s
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 8s
2026-02-23 00:52:35 +01:00
cd4c2193ce feat: implement legacy imgproxy compatibility and URL mapping
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m56s
Monorepo Pipeline / 🏗️ Build (push) Successful in 4m32s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-23 00:14:13 +01:00
df7a464e03 fix(ci): sync lockfile and remove deleted model scripts
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 5m26s
Monorepo Pipeline / 🏗️ Build (push) Successful in 7m18s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m5s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 1m8s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 1m43s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 1m27s
Monorepo Pipeline / 🐳 Build Image Processor (push) Successful in 2m38s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 5m49s
Monorepo Pipeline / 🚀 Release (push) Successful in 6m24s
2026-02-22 23:40:30 +01:00
e2e0653de6 chore(image-processor): use Gemini 3 Flash Preview
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🏗️ Build (push) Failing after 23s
Monorepo Pipeline / 🧹 Lint (push) Failing after 8s
Monorepo Pipeline / 🧪 Test (push) Failing after 21s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-22 23:31:44 +01:00
217 changed files with 12196 additions and 14785 deletions

View File

@@ -1,7 +0,0 @@
---
"@mintel/monorepo": patch
"acquisition-manager": patch
"feedback-commander": patch
---
fix: make directus extension build scripts more resilient

View File

@@ -24,5 +24,3 @@ coverage
**/.pnpm-store
.gitea
**/.gitea
models
**/models

8
.env
View File

@@ -1,9 +1,15 @@
# Project
IMAGE_TAG=v1.8.12
IMAGE_TAG=v1.8.19
PROJECT_NAME=at-mintel
PROJECT_COLOR=#82ed20
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
OPENROUTER_API_KEY=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
ZYTE_API_KEY=1f0f74726f044f55aaafc7ead32cd489
REPLICATE_API_KEY=r8_W3grtpXMRfi0u3AM9VdkKbuWdZMmhwU2Tn0yt
SERPER_API_KEY=02f69a8db9578c41fb1c8ed9f7a999302da644ff
DATA_FOR_SEO_API_KEY=bWFyY0BtaW50ZWwubWU6MjQ0YjBjZmIzOGY3NTIzZA==
DATA_FOR_SEO_LOGIN=marc@mintel.me
DATA_FOR_SEO_PASSWORD=244b0cfb38f7523d
# Authentication
GATEKEEPER_PASSWORD=mintel

View File

@@ -1,5 +1,5 @@
# Project
IMAGE_TAG=v1.8.16
IMAGE_TAG=v1.9.8
PROJECT_NAME=sample-website
PROJECT_COLOR=#82ed20

View File

@@ -0,0 +1,41 @@
name: "Mintel Core Smoke Tests"
description: "Executes standard fast HTTP, API, and Locale validation checks."
inputs:
TARGET_URL:
description: 'The deployed URL to test against'
required: true
GATEKEEPER_PASSWORD:
description: 'Gatekeeper bypass password'
required: true
UMAMI_API_ENDPOINT:
description: 'Umami Analytics Endpoint'
required: false
default: 'https://analytics.infra.mintel.me'
SENTRY_DSN:
description: 'Sentry / Glitchtip DSN'
required: false
runs:
using: "composite"
steps:
- name: 🌐 Full Sitemap HTTP Validation
shell: bash
env:
NEXT_PUBLIC_BASE_URL: ${{ inputs.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ inputs.GATEKEEPER_PASSWORD }}
run: pnpm run check:http
- name: 🌐 Locale & Language Switcher Validation
shell: bash
env:
NEXT_PUBLIC_BASE_URL: ${{ inputs.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ inputs.GATEKEEPER_PASSWORD }}
run: pnpm run check:locale
- name: 🌐 External API Smoke Test (Umami & Sentry)
shell: bash
env:
UMAMI_API_ENDPOINT: ${{ inputs.UMAMI_API_ENDPOINT }}
SENTRY_DSN: ${{ inputs.SENTRY_DSN }}
run: pnpm run check:apis

View File

@@ -91,6 +91,8 @@ jobs:
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
- name: Lint
run: pnpm lint
- name: Check Dependencies (Depcheck)
run: pnpm -r exec npx --yes depcheck --skip-missing --ignores="eslint*,@eslint/*,@types/*,typescript,tsup,tsx,vitest,tailwindcss,postcss,autoprefixer,@mintel/*,ts-node,*in-the-middle,pino*,@commitlint/*,@changesets/*,globals"
test:
name: 🧪 Test
@@ -189,12 +191,7 @@ jobs:
- image: gatekeeper
file: packages/infra/docker/Dockerfile.gatekeeper
name: Gatekeeper (Product)
- image: directus
file: packages/infra/docker/Dockerfile.directus
name: Directus (Base)
- image: image-processor
file: apps/image-service/Dockerfile
name: Image Processor
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -214,7 +211,7 @@ jobs:
with:
context: .
file: ${{ matrix.file }}
platforms: linux/arm64
platforms: linux/amd64
pull: true
provenance: false
push: true

View File

@@ -0,0 +1,243 @@
name: Reusable Nightly QA
on:
workflow_call:
inputs:
TARGET_URL:
description: 'The URL to test (e.g., https://testing.klz-cables.com)'
required: true
type: string
PROJECT_NAME:
description: 'The internal project name for notifications'
required: true
type: string
secrets:
GOTIFY_URL:
required: true
GOTIFY_TOKEN:
required: true
GATEKEEPER_PASSWORD:
required: true
NPM_TOKEN:
required: false
MINTEL_PRIVATE_TOKEN:
required: false
GITEA_PAT:
required: false
jobs:
prepare:
name: 🏗️ Prepare & Install
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: 🔐 Registry Auth
run: |
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN || secrets.MINTEL_PRIVATE_TOKEN || secrets.GITEA_PAT }}" >> .npmrc
- name: Install dependencies
run: |
pnpm store prune
pnpm install --no-frozen-lockfile
- name: 📦 Archive dependencies
uses: actions/upload-artifact@v4
with:
name: node_modules
path: |
node_modules
.npmrc
retention-days: 1
static:
name: 🔍 Static Analysis
needs: prepare
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: 📥 Restore dependencies
uses: actions/download-artifact@v4
with:
name: node_modules
- name: 🌐 HTML Validation
env:
NEXT_PUBLIC_BASE_URL: ${{ inputs.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
run: pnpm run check:html
- name: 🖼️ Asset Scan
env:
NEXT_PUBLIC_BASE_URL: ${{ inputs.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
run: pnpm run check:assets
accessibility:
name: ♿ Accessibility
needs: prepare
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: 📥 Restore dependencies
uses: actions/download-artifact@v4
with:
name: node_modules
- name: 🔍 Install Chromium
run: |
apt-get update && apt-get install -y gnupg wget ca-certificates
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
mkdir -p /etc/apt/keyrings
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x82BB6851C64F6880" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
apt-get update && apt-get install -y --allow-downgrades chromium
ln -sf /usr/bin/chromium /usr/bin/google-chrome
- name: ♿ WCAG Scan
continue-on-error: true
env:
NEXT_PUBLIC_BASE_URL: ${{ inputs.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
run: pnpm run check:wcag
analysis:
name: 🧪 Maintenance & Links
needs: prepare
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: 📥 Restore dependencies
uses: actions/download-artifact@v4
with:
name: node_modules
- name: 📦 Depcheck
continue-on-error: true
run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*"
- name: 🔗 Lychee Link Check
uses: lycheeverse/lychee-action@v2
with:
args: --accept 200,204,429 --timeout 15 content/ app/ public/
fail: true
performance:
name: 🎭 Lighthouse
needs: prepare
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: 📥 Restore dependencies
uses: actions/download-artifact@v4
with:
name: node_modules
- name: 🔍 Install Chromium
run: |
apt-get update && apt-get install -y gnupg wget ca-certificates
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
mkdir -p /etc/apt/keyrings
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x82BB6851C64F6880" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
apt-get update && apt-get install -y --allow-downgrades chromium
ln -sf /usr/bin/chromium /usr/bin/google-chrome
- name: 🎭 LHCI Desktop
env:
LHCI_URL: ${{ inputs.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
run: pnpm run pagespeed:test -- --collect.settings.preset=desktop
- name: 📱 LHCI Mobile
env:
LHCI_URL: ${{ inputs.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
run: pnpm run pagespeed:test -- --collect.settings.preset=mobile
notifications:
name: 🔔 Notify
needs: [prepare, static, accessibility, analysis, performance]
if: always()
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: 🔔 Gotify
shell: bash
run: |
PREPARE="${{ needs.prepare.result }}"
STATIC="${{ needs.static.result }}"
A11Y="${{ needs.accessibility.result }}"
ANALYSIS="${{ needs.analysis.result }}"
PERF="${{ needs.performance.result }}"
PROJECT="${{ inputs.PROJECT_NAME }}"
URL="${{ inputs.TARGET_URL }}"
if [[ "$PREPARE" != "success" || "$STATIC" != "success" || "$PERF" != "success" ]]; then
PRIORITY=8
EMOJI="🚨"
STATUS_LINE="Nightly QA Failed! Action required."
else
PRIORITY=2
EMOJI="✅"
STATUS_LINE="Nightly QA Passed."
fi
TITLE="$EMOJI $PROJECT Nightly QA"
MESSAGE="$STATUS_LINE
Prepare: $PREPARE | Static: $STATIC | A11y: $A11Y
Analysis: $ANALYSIS | Perf: $PERF
$URL"
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=$TITLE" \
-F "message=$MESSAGE" \
-F "priority=$PRIORITY" || true

7
.gitignore vendored
View File

@@ -41,4 +41,9 @@ directus/extensions/
packages/cms-infra/extensions/
packages/cms-infra/uploads/
directus/uploads/directus-health-file
directus/uploads/directus-health-file
# Estimation Engine Data
data/crawls/
packages/estimation-engine/out/
apps/web/out/estimations/

View File

@@ -5,40 +5,4 @@ if [ -f "$SCRIPT_DIR/scripts/validate-sdk-imports.sh" ]; then
"$SCRIPT_DIR/scripts/validate-sdk-imports.sh" || exit 1
fi
# Check if we are pushing a tag
while read local_ref local_sha remote_ref remote_sha
do
if [[ "$remote_ref" == refs/tags/* ]]; then
TAG=${remote_ref#refs/tags/}
echo "🏷️ Tag detected: $TAG, ensuring versions are synced..."
# Run sync script
pnpm sync-versions "$TAG"
# Check for changes in relevant files
SYNC_FILES="package.json packages/*/package.json apps/*/package.json .env.example"
CHANGES=$(git status --porcelain $SYNC_FILES)
if [[ -n "$CHANGES" ]]; then
echo "📝 Version sync made changes. Integrating into tag..."
# Stage and commit
git add $SYNC_FILES
git commit -m "chore: sync versions to $TAG" --no-verify
# Force update the local tag to point to the new commit
git tag -f "$TAG" > /dev/null
echo "✅ Tag $TAG has been updated locally with synced versions."
echo "🚀 Auto-pushing updated tag..."
# Push the updated tag directly (using --no-verify to avoid recursion)
git push origin "$TAG" --force --no-verify
echo "✨ All done! Hook integrated the sync and pushed for you."
exit 1 # Still exit 1 to abort the original (now outdated) push attempt
else
echo "✨ Versions already in sync for $TAG."
fi
fi
done

5
.npmrc
View File

@@ -1,6 +1,5 @@
@mintel:registry=https://npm.infra.mintel.me/
registry=https://npm.infra.mintel.me/
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${NPM_TOKEN}
always-auth=true
public-hoist-pattern[]=*

View File

@@ -81,3 +81,4 @@ Client websites scaffolded via the CLI use a **tag-based deployment** strategy:
See the [`@mintel/infra`](packages/infra/README.md) package for detailed template documentation.
Trigger rebuilding for x86 architecture.

View File

@@ -1,27 +0,0 @@
FROM node:20.18-bookworm-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@10.30.1
FROM base AS build
WORKDIR /app
COPY . .
# We only need standard pnpm install now, no C++ tools needed for basic Sharp
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @mintel/image-processor build
RUN pnpm --filter image-service build
FROM base
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/apps/image-service/node_modules ./apps/image-service/node_modules
COPY --from=build /app/packages/image-processor/node_modules ./packages/image-processor/node_modules
RUN mkdir -p /app/apps/image-service/dist
COPY --from=build /app/apps/image-service/dist ./apps/image-service/dist
COPY --from=build /app/apps/image-service/package.json ./apps/image-service/package.json
COPY --from=build /app/packages/image-processor/dist ./packages/image-processor/dist
COPY --from=build /app/packages/image-processor/package.json ./packages/image-processor/package.json
EXPOSE 8080
WORKDIR /app/apps/image-service
CMD ["npm", "run", "start"]

View File

@@ -1,23 +0,0 @@
{
"name": "image-service",
"version": "1.8.16",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src"
},
"dependencies": {
"@mintel/image-processor": "workspace:*",
"fastify": "^4.26.2"
},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@types/node": "^20.0.0",
"tsx": "^4.7.1",
"typescript": "^5.0.0"
}
}

View File

@@ -1,79 +0,0 @@
import Fastify from "fastify";
import { processImageWithSmartCrop } from "@mintel/image-processor";
const fastify = Fastify({
logger: true,
});
fastify.get("/unsafe/:options/:urlSafeB64", async (request, reply) => {
// Compatibility endpoint for old imgproxy calls (optional, but requested by some systems sometimes)
// For now, replacing logic in clients is preferred. So we just redirect or error.
return reply
.status(400)
.send({ error: "Legacy imgproxy API not supported. Use /process" });
});
fastify.get("/process", async (request, reply) => {
const query = request.query as {
url?: string;
w?: string;
h?: string;
q?: string;
format?: string;
};
const { url } = query;
const width = parseInt(query.w || "800", 10);
const height = parseInt(query.h || "600", 10);
const quality = parseInt(query.q || "80", 10);
const format = (query.format || "webp") as "webp" | "jpeg" | "png" | "avif";
if (!url) {
return reply.status(400).send({ error: 'Parameter "url" is required' });
}
try {
const response = await fetch(url);
if (!response.ok) {
return reply.status(response.status).send({
error: `Failed to fetch source image: ${response.statusText}`,
});
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const processedBuffer = await processImageWithSmartCrop(buffer, {
width,
height,
format,
quality,
openRouterApiKey: process.env.OPENROUTER_API_KEY,
});
reply.header("Content-Type", `image/${format}`);
reply.header("Cache-Control", "public, max-age=31536000, immutable");
return reply.send(processedBuffer);
} catch (err) {
fastify.log.error(err);
return reply
.status(500)
.send({ error: "Internal Server Error processing image" });
}
});
fastify.get("/health", async () => {
return { status: "ok" };
});
const start = async () => {
try {
await fastify.listen({ port: 8080, host: "0.0.0.0" });
console.log(`Server listening on 8080`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();

View File

@@ -1,11 +0,0 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": false
},
"include": ["src/**/*"]
}

View File

@@ -2,12 +2,7 @@ import mintelNextConfig from "@mintel/next-config";
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: [
"@mintel/image-processor",
"@tensorflow/tfjs-node",
"sharp",
"canvas",
],
transpilePackages: ["@mintel/ui"],
};
export default mintelNextConfig(nextConfig);

View File

@@ -1,6 +1,6 @@
{
"name": "sample-website",
"version": "1.8.16",
"version": "1.9.8",
"private": true,
"type": "module",
"scripts": {
@@ -15,13 +15,11 @@
"pagespeed:test": "mintel pagespeed"
},
"dependencies": {
"@mintel/next-observability": "workspace:*",
"@mintel/next-utils": "workspace:*",
"@mintel/observability": "workspace:*",
"@mintel/next-observability": "workspace:*",
"@mintel/image-processor": "workspace:*",
"@sentry/nextjs": "10.38.0",
"next": "16.1.6",
"next-intl": "^4.8.2",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},

View File

@@ -1,60 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");
const width = parseInt(searchParams.get("w") || "800");
const height = parseInt(searchParams.get("h") || "600");
const q = parseInt(searchParams.get("q") || "80");
if (!url) {
return NextResponse.json(
{ error: "Missing url parameter" },
{ status: 400 },
);
}
try {
// 1. Fetch image from original URL
const response = await fetch(url);
if (!response.ok) {
return NextResponse.json(
{ error: "Failed to fetch original image" },
{ status: response.status },
);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Dynamically import to prevent Next.js from trying to bundle tfjs-node/sharp locally at build time
const { processImageWithSmartCrop } =
await import("@mintel/image-processor");
// 2. Process image with Face-API and Sharp
const processedBuffer = await processImageWithSmartCrop(buffer, {
width,
height,
format: "webp",
quality: q,
});
// 3. Return the processed image
return new NextResponse(new Uint8Array(processedBuffer), {
status: 200,
headers: {
"Content-Type": "image/webp",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
console.error("Image Processing Error:", error);
return NextResponse.json(
{ error: "Failed to process image" },
{ status: 500 },
);
}
}

246
data/briefings/etib.txt Normal file
View File

@@ -0,0 +1,246 @@
Hallo Marc,
eine harte Deadline gibt es nicht Was denkst du ist realistisch? Ich habe als Ziel so
April / Mai im Kopf -> dann aber schon zu 95 % fertig. Viele Grüße
Mit freundlichen Grüßen
Danny Joseph
Geschäftsführer
E-TIB GmbH
Gewerbestraße 22
D-03172 Guben
Mobil +49 15207230518
E-Mail d.joseph@e-tib.com
Web www.e-tib.com
--------------------------------------------------------------------------------------------------
Hey,
ich würde wie bei https://www.schleicher-gruppe.de/ ein Video auf der Startseite
haben wollen. Da ginge sicherlich was vom bisherigen Messevideo. Liebe Grüße.
Mit freundlichen Grüßen
Danny Joseph
Geschäftsführer
E-TIB GmbH
Gewerbestraße 22
D-03172 Guben
Mobil +49 15207230518
E-Mail d.joseph@e-tib.com
Web www.e-tib.com
--------------------------------------------------------------------------------------------------
Geschäftsführung: Danny Joseph
Handelsregister: Amtsgericht Cottbus
HRB: 12403 CB
USt. ID-Nr.: DE304799919
--------------------------------------------------------------------------------------------------
Von: Frieder Helmich <f.helmich@etib-ing.com>
Gesendet: Donnerstag, 29. Januar 2026 08:49
An: Marc Mintel <marc@cablecreations.de>; Danny Joseph <d.joseph@e-tib.com>
Betreff: AW: Homepage E-TIB
Hi Marc,
brauchst du nur Fotos oder bindest du auch videos ein? Wir haben sehr viel Videomaterial. Wir haben auch einen kleinen Film den wir auf der Messe laufen lassen haben.
Mit freundlichen Grüßen
i.A. Frieder Helmich
E-TIB Ingenieurgesellschaft mbH
Kampstraße 3
D-27412 Bülstedt
Tel +49 4283 6979923
Mobil +49 173 6560514
Fax +49 4283 6084091
E-Mail f.helmich@etib-ing.com
Web www.etib-ing.com
ETIB_Ing_logo_mk
Datenschutzhinweise: www.etib-ing.com/datenschutz
-----------------------------------------------------------------------------------------------
Geschäftsführung: Julian Helmich
Handelsregister: Amtsgericht Tostedt
HRB: 207158
-----------------------------------------------------------------------------------------------
Von: Marc Mintel <marc@cablecreations.de>
Gesendet: Mittwoch, 28. Januar 2026 18:10
An: Danny Joseph <d.joseph@e-tib.com>
Cc: Frieder Helmich <f.helmich@etib-ing.com>
Betreff: Re: Homepage E-TIB
Hallo Danny,
Vielen Dank für die schnelle Rückmeldung.
Wie gesprochen werde ich mir die Unterlagen und Webseiten im Detail anschauen und mich dann noch einmal bei dir melden.
Gibt es eigentlich eine Deadline oder einen zeitlichen Rahmen, wo ihr mit der neuen Webseite rechnen möchtet?
Je nach dem könnte man auch Features priorisieren, so dass der Kern der Seite schnellstmöglich modernisiert online geht und der Rest im Nachgang.
Das Foto-Material würde ich auch gerne sichten, dann kann man schon sehen, wie viel sich damit arbeiten lässt.
Viele Grüße
From: Danny Joseph <d.joseph@e-tib.com>
Organization: E-TIB GmbH
Date: Wednesday, 28. January 2026 at 16:16
To: Marc Mintel <marc@cablecreations.de>
Cc: 'Frieder Helmich' <f.helmich@etib-ing.com>
Subject: Homepage E-TIB
Hallo Marc,
wie telefonisch besprochen erste wirre Gedanken:
Wir möchten eine minimalistische, hochwertige Homepage die sowohl am PV, als auch
Auf Smartphone / Tablet etc. vernünftig ausschaut.
Bisher war unser Aufhänger:
DIE EXPERTEN FÜR KABELTIEFBAU …
Alles nur Ideen: …
# Schaltflächen ähnlich: https://www.schleicher-gruppe.de/
E-TIB GmbH
E-TIB Verwaltung GmbH
E-TIB Ingenieurgesellschaft mbH
E-TIB Bohrtechnik GmbH
# Schaltflächen ähnlich: https://www.schleicher-gruppe.de/
(ehemals Kompetenzen www.e-tib.com)
Kabelbau
Kabelpflugarbeiten
Horizontalspülbohrungen
Elektromontagen bis 110 kV
Glasfaser-Kabelmontagen
Wartung & Störungsdienst
Genehmigungs- und Ausführungsplanung
Komplexe Querung (Bahn, Autobahn, Gewässer)
Elektro- und Netzanschlussplanung
Vermessung & Dokumentation
Input für Über uns: Grid … Timeline?
Gründung E-TIB GmbH: 16.12.2015
Kabelbau
Kabelpflugarbeiten
Horizontalspülbohrungen
Elektromontagen bis 110 kV
Glasfaser-Kabelmontagen
Wartung & Störungsdienst
Elektro- und Netzanschlussplanung
Vermessung & Dokumentation
Gründung E-TIB Verwaltung GmbH: 14.11.2019
Der Erwerb, die Vermietung, Verpachtung und Verwaltung
von Immobilien, Grundstücken, Maschinen und Geräten.
Gründung E-TIB Ingenieurgesellschaft mbH: 04.02.2019
Genehmigungs- und Ausführungsplanung
Komplexe Querung (Bahn, Autobahn, Gewässer)
Elektro- und Netzanschlussplanung
Gründung E-TIB Bohrtechnik GmbH: 21.10.2025
Horizontalspülbohrungen in allen Bodenklassen
GruppenKacheln (Beispieltexte) ...
ETIB GmbH Ausführung elektrischer Infrastrukturprojekte
ETIB Bohrtechnik GmbH Präzise Horizontalbohrungen in allen Bodenklassen
ETIB Verwaltung GmbH Zentrale Dienste, Einkauf, Finanzen
ETIB Ingenieurgesellschaft mbH Planung, Projektierung, Dokumentation
Kontaktseite siehe: www.e-tib.com
Karriere: ...
Messen: wo wir dieses Jahr einen Stand haben: Intersolar München, Windenergietage Linstow, Kabelwerkstatt Wiesbaden
Referenzen: … müsste ich dir zur Verfügung stellen
Pflichtseiten
Impressum (vollständig, Verantwortliche, Registernummer, UStID).
Datenschutz (Verarbeitungen, Rechtsgrundlagen, AVV, CookieGruppen, Löschfristen, Rechte).
CookieEinstellungen (Consent Manager: ...)
www.e-tib.com
www.etib-ing.com
Hier mein instagram account:
me.and.eloise
Verstehst du mich vielleicht ein kleines Stück mehr…
Unser Frieder Helmich kann erstes Foto-/Videomaterial zur Verfügung stellen:
f.helmich@etib-ing.com
Lass mir mal eine Idee vom Stundenaufwand / Kosten pro Stunde für Erstellung zukommen,
damit wir eine Vertragsgrundlage haben. Danach lass uns loslegen.
Besten Dank dir.
Mit freundlichen Grüßen
Danny Joseph
Geschäftsführer
E-TIB GmbH
Gewerbestraße 22
D-03172 Guben
Mobil +49 15207230518
E-Mail d.joseph@e-tib.com
Web www.e-tib.com
--------------------------------------------------------------------------------------------------
Geschäftsführung: Danny Joseph
Handelsregister: Amtsgericht Cottbus
HRB: 12403 CB
USt. ID-Nr.: DE304799919
--------------------------------------------------------------------------------------------------
Von: Marc Mintel <marc@cablecreations.de>
Gesendet: Donnerstag, 13. November 2025 16:30
An: d.joseph@e-tib.com
Betreff: Homepage
Hi Danny,
mein Vater meinte, ich könnte mich mal bei dir melden, weil ihr jemanden für eure Website sucht.
Kurz zu mir: Ich habe über 10 Jahre in der Webentwicklung gearbeitet. Inzwischen liegt mein Schwerpunkt zwar im 3D-Bereich (u. a. cablecreations.de), aber ich betreue weiterhin Websites für Firmen, die das Ganze unkompliziert abgegeben haben möchten. Unter anderem betreue ich auch die Seite von KLZ (klz-cables.com). Der Ablauf ist bei mir recht einfach: Wenn ihr etwas braucht, reicht in der Regel eine kurze Mail Anpassungen, Inhalte oder technische Themen erledige ich dann im Hintergrund. Dadurch spart ihr euch Schulungen, Zugänge oder lange Meetings, wie man sie oft mit Agenturen hat.
Wichtig ist: Eine Website braucht auch nach dem Aufbau regelmäßige Pflege, damit Technik und Sicherheit sauber laufen das übernehme ich dann ebenfalls, damit ihr im Alltag keinen Aufwand damit habt.
Um einschätzen zu können, ob und wie ich euch unterstützen kann, wäre es gut zu wissen, was ihr mit der Website vorhabt und was an der aktuellen Seite nicht mehr passt. Wenn du magst, können wir dazu auch kurz telefonieren.
Viele Grüße
Marc
Marc Mintel
Founder & 3D Artist
marc@cablecreations.de
Cable Creations
www.cablecreations.de
info@cablecreations.de
VAT: DE367588065
Georg-Meistermann-Straße 7
54586 Schüller
Germany

View File

@@ -0,0 +1,39 @@
services:
gatekeeper-proxy:
image: alpine:latest
command: sleep infinity
restart: unless-stopped
networks:
- infra
labels:
- "caddy=http://gatekeeper.localhost"
- "caddy.route=/*"
- "caddy.route.0_redir=/ /gatekeeper/login 302"
- "caddy.route.1_reverse_proxy=gatekeeper-app:3000"
gatekeeper-app:
image: node:20-alpine
working_dir: /app
volumes:
- .:/app
- gatekeeper_root_node_modules:/app/node_modules
- gatekeeper_pkg_node_modules:/app/packages/gatekeeper/node_modules
- gatekeeper_next_cache:/app/packages/gatekeeper/.next
- gatekeeper_pnpm_store:/pnpm
environment:
- NODE_ENV=development
- NPM_TOKEN=${NPM_TOKEN:-}
networks:
- infra
command: >
sh -c "corepack enable && pnpm config set store-dir /pnpm && pnpm install --no-frozen-lockfile && pnpm --filter @mintel/gatekeeper run dev --hostname 0.0.0.0 --port 3000"
networks:
infra:
external: true
volumes:
gatekeeper_root_node_modules:
gatekeeper_pkg_node_modules:
gatekeeper_next_cache:
gatekeeper_pnpm_store:

View File

@@ -11,8 +11,6 @@ services:
restart: always
networks:
- infra
environment:
- DIRECTUS_URL=${DIRECTUS_URL:-http://directus:8055}
env_file:
- .env
ports:
@@ -24,60 +22,6 @@ services:
- "caddy=http://${TRAEFIK_HOST:-acquisition.localhost}"
- "caddy.reverse_proxy={{upstreams 3000}}"
directus:
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8055/server/health" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
restart: always
networks:
- infra
env_file:
- .env
environment:
KEY: ${DIRECTUS_KEY:-mintel-key}
SECRET: ${DIRECTUS_SECRET:-mintel-secret}
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
DB_CLIENT: 'pg'
DB_HOST: 'at-mintel-directus-db'
DB_PORT: '5432'
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel-db-pass}
WEBSOCKETS_ENABLED: 'true'
PUBLIC_URL: ${DIRECTUS_URL:-http://localhost:8055}
ports:
- "8055:8055"
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
- ./directus/schema:/directus/schema
labels:
- "traefik.enable=true"
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
- "caddy=http://${DIRECTUS_HOST:-cms.at.localhost}"
- "caddy.reverse_proxy={{upstreams 8055}}"
at-mintel-directus-db:
image: postgres:15-alpine
restart: always
networks:
- infra
environment:
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel-db-pass}
volumes:
- directus-db-data:/var/lib/postgresql/data
networks:
infra:
external: true
volumes:
directus-db-data:

14
optimize-images.sh Normal file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
# Ghost Image Optimizer
# Target directory for Ghost content
TARGET_DIR="/home/deploy/sites/marisas.world/content/images"
echo "Starting image optimization for $TARGET_DIR..."
# Find all original images, excluding the 'size/' directory where Ghost stores thumbnails
# Resize images larger than 2500px down to 2500px width
# Compress JPEG/PNG to 80% quality
find "$TARGET_DIR" -type d -name "size" -prune -o \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" \) -type f -exec mogrify -resize '2500x>' -quality 80 {} +
echo "Optimization complete."

View File

@@ -5,21 +5,13 @@
"scripts": {
"build": "pnpm -r build",
"dev": "pnpm -r dev",
"dev:gatekeeper": "bash -c 'trap \"COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml down && COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml up --build --remove-orphans'",
"lint": "pnpm -r --filter='./packages/**' --filter='./apps/**' lint",
"test": "pnpm -r test",
"changeset": "changeset",
"version-packages": "changeset version",
"sync-versions": "tsx scripts/sync-versions.ts --",
"cms:dev": "pnpm --filter @mintel/cms-infra dev",
"cms:up": "pnpm --filter @mintel/cms-infra up",
"cms:down": "pnpm --filter @mintel/cms-infra down",
"cms:logs": "pnpm --filter @mintel/cms-infra logs",
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
"cms:schema:apply": "./scripts/cms-apply.sh local",
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
"cms:sync:push": "./scripts/sync-directus.sh push infra",
"cms:sync:pull": "./scripts/sync-directus.sh pull infra",
"build:extensions": "./scripts/sync-extensions.sh",
"release:version": "bash scripts/release.sh",
"release": "pnpm build && changeset publish",
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
"prepare": "husky"
@@ -57,7 +49,7 @@
"pino-pretty": "^13.1.3",
"require-in-the-middle": "^8.0.1"
},
"version": "1.8.16",
"version": "1.9.8",
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",

View File

@@ -1,30 +0,0 @@
{
"name": "acquisition-manager",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.16",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "app",
"name": "acquisition manager"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"dependencies": {
"@mintel/directus-extension-toolkit": "workspace:*"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}

View File

@@ -1,19 +0,0 @@
import { defineModule } from "@directus/extensions-sdk";
import ModuleComponent from "./module.vue";
export default defineModule({
id: "acquisition-manager",
name: "Acquisition",
icon: "auto_awesome",
routes: [
{
path: "",
component: ModuleComponent,
},
{
path: ":id",
component: ModuleComponent,
props: true
}
],
});

View File

@@ -1,468 +0,0 @@
<template>
<MintelManagerLayout
title="Acquisition Manager"
:item-title="getCompanyName(selectedLead) || 'Lead wählen'"
:is-empty="!selectedLead"
empty-title="Lead auswählen"
empty-icon="auto_awesome"
:notice="notice"
@close-notice="notice = null"
>
<template #navigation>
<v-list nav>
<v-list-item @click="openCreateDrawer" clickable>
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow text="Neuen Lead anlegen" />
</v-list-item-content>
</v-list-item>
<v-divider />
<v-list-item
v-for="lead in leads"
:key="lead.id"
:active="selectedLeadId === lead.id"
class="nav-item"
clickable
@click="selectLead(lead.id)"
>
<v-list-item-icon>
<v-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="getCompanyName(lead)" />
</v-list-item-content>
</v-list-item>
</v-list>
</template>
<template #subtitle>
<template v-if="selectedLead">
<v-icon name="language" x-small />
<a :href="selectedLead.website_url" target="_blank" class="url-link">
{{ selectedLead.website_url.replace(/^https?:\/\//, '') }}
</a>
&middot; Status: {{ selectedLead.status.toUpperCase() }}
</template>
</template>
<template #actions>
<v-button
v-if="selectedLead?.status === 'new'"
secondary
:loading="loadingAudit"
@click="runAudit"
>
<v-icon name="settings_suggest" left />
Audit starten
</v-button>
<template v-if="selectedLead?.status === 'audit_ready'">
<v-button secondary :loading="loadingEmail" @click="sendAuditEmail">
<v-icon name="mail" left />
Audit E-Mail
</v-button>
<v-button :loading="loadingPdf" @click="generatePdf">
<v-icon name="picture_as_pdf" left />
PDF Erstellen
</v-button>
</template>
<v-button v-if="selectedLead?.audit_pdf_path" secondary icon v-tooltip.bottom="'PDF öffnen'" @click="openPdf">
<v-icon name="open_in_new" />
</v-button>
<v-button
v-if="selectedLead && !isCustomer(selectedLead.company)"
secondary
@click="linkAsCustomer"
>
<v-icon name="handshake" left />
Kunde verlinken
</v-button>
<v-button
v-if="selectedLead?.audit_pdf_path"
primary
:loading="loadingEmail"
@click="sendEstimateEmail"
>
<v-icon name="send" left />
Angebot senden
</v-button>
</template>
<template #empty-state>
Wähle einen Lead in der Navigation aus oder
<v-button x-small @click="openCreateDrawer">registriere einen neuen Lead</v-button>.
</template>
<div v-if="selectedLead" class="sections">
<div class="main-info">
<div class="form-grid">
<div class="field">
<span class="label">Kontaktperson</span>
<div v-if="selectedLead.contact_person" class="value person-link" @click="goToPerson(selectedLead.contact_person)">
{{ getPersonName(selectedLead.contact_person) }}
</div>
<div v-else class="value text-subdued">Keine Person verknüpft</div>
</div>
<div class="field full">
<span class="label">Briefing / Fokus</span>
<div class="value text-block">{{ selectedLead.briefing || 'Kein Briefing hinterlegt.' }}</div>
</div>
</div>
</div>
<v-divider />
<div v-if="selectedLead.ai_state" class="ai-observations">
<h3 class="section-title">AI Observations & Estimation</h3>
<div class="metrics">
<MintelStatCard label="Projekt-Modus" :value="selectedLead.ai_state.projectType || 'Unbekannt'" icon="category" />
<MintelStatCard label="Seitenanzahl" :value="selectedLead.ai_state.sitemap?.length || '0'" icon="description" />
</div>
<v-table
v-if="selectedLead.ai_state.sitemap"
:headers="[ { text: 'Seite', value: 'title' }, { text: 'URL', value: 'url' } ]"
:items="selectedLead.ai_state.sitemap"
class="observation-table"
>
<template #[`item.title`]="{ item }">
<span class="page-title">{{ item.title }}</span>
</template>
<template #[`item.url`]="{ item }">
<span class="page-url">{{ item.url }}</span>
</template>
</v-table>
</div>
</div>
<!-- Drawer: New Lead -->
<v-drawer
v-model="drawerActive"
title="Neuen Lead registrieren"
icon="person_add"
@cancel="drawerActive = false"
>
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Organisation / Firma (Zentral)</span>
<MintelSelect
v-model="newLead.company"
:items="companyOptions"
placeholder="Bestehende Firma auswählen..."
allow-add
@add="openQuickAdd('company')"
/>
</div>
<div class="field">
<span class="label">Website URL</span>
<v-input v-model="newLead.website_url" placeholder="https://..." />
</div>
<div class="field">
<span class="label">Briefing / Fokus</span>
<v-textarea v-model="newLead.briefing" placeholder="Besonderheiten für das Audit..." />
</div>
<div class="field">
<span class="label">Kontaktperson (Optional)</span>
<MintelSelect
v-model="newLead.contact_person"
:items="peopleOptions"
placeholder="Person auswählen..."
allow-add
@add="openQuickAdd('person')"
/>
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="savingLead" @click="saveLead">Lead speichern</v-button>
</div>
</div>
</v-drawer>
</MintelManagerLayout>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useApi } from '@directus/extensions-sdk';
import { useRoute, useRouter } from 'vue-router';
import { MintelManagerLayout, MintelSelect, MintelStatCard } from '@mintel/directus-extension-toolkit';
const api = useApi();
const route = useRoute();
const router = useRouter();
const leads = ref<any[]>([]);
const selectedLeadId = ref<string | null>(null);
const loadingAudit = ref(false);
const loadingPdf = ref(false);
const loadingEmail = ref(false);
const drawerActive = ref(false);
const savingLead = ref(false);
const notice = ref<{ type: string; message: string } | null>(null);
const newLead = ref({
company: null,
website_url: '',
contact_person: null,
briefing: '',
status: 'new'
});
const companies = ref<any[]>([]);
const people = ref<any[]>([]);
const customers = ref<any[]>([]);
const companyOptions = computed(() =>
companies.value.map(c => ({
text: c.name,
value: c.id
}))
);
const peopleOptions = computed(() =>
people.value.map(p => ({
text: `${p.first_name} ${p.last_name}`,
value: p.id
}))
);
function getCompanyName(lead: any) {
if (!lead) return '';
if (lead.company) {
return typeof lead.company === 'object' ? lead.company.name : (companies.value.find(c => c.id === lead.company)?.name || 'Unbekannte Firma');
}
return 'Unbekannte Organisation';
}
function getPersonName(id: string | any) {
if (!id) return '';
if (typeof id === 'object') return `${id.first_name} ${id.last_name}`;
const person = people.value.find(p => p.id === id);
return person ? `${person.first_name} ${person.last_name}` : id;
}
function goToPerson(id: string) {
notice.value = { type: 'info', message: `Navigiere zu Person: ${id}` };
}
const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value));
async function fetchData() {
try {
const [leadsResp, peopleResp, companiesResp, customersResp] = await Promise.all([
api.get('/items/leads', {
params: {
sort: '-date_created',
fields: '*.*'
}
}),
api.get('/items/people', { params: { sort: 'last_name' } }),
api.get('/items/companies', { params: { sort: 'name' } }),
api.get('/items/customers', { params: { fields: ['company'] } })
]);
leads.value = leadsResp.data.data;
people.value = peopleResp.data.data;
companies.value = companiesResp.data.data;
customers.value = customersResp.data.data;
if (!selectedLeadId.value && leads.value.length > 0) {
selectedLeadId.value = leads.value[0].id;
}
} catch (e: any) {
console.error('Fetch error:', e);
}
}
function isCustomer(companyId: string | any) {
if (!companyId) return false;
const id = typeof companyId === 'object' ? companyId.id : companyId;
return customers.value.some(c => (typeof c.company === 'object' ? c.company.id : c.company) === id);
}
async function linkAsCustomer() {
if (!selectedLead.value) return;
const companyId = selectedLead.value.company
? (typeof selectedLead.value.company === 'object' ? selectedLead.value.company.id : selectedLead.value.company)
: null;
const personId = selectedLead.value.contact_person
? (typeof selectedLead.value.contact_person === 'object' ? selectedLead.value.contact_person.id : selectedLead.value.contact_person)
: null;
router.push({
name: 'module-customer-manager',
query: {
create: 'true',
company: companyId,
contact_person: personId
}
});
}
async function fetchLeads() {
await fetchData();
}
function selectLead(id: string) {
selectedLeadId.value = id;
}
function openCreateDrawer() {
newLead.value = {
company: null,
website_url: '',
contact_person: null,
briefing: '',
status: 'new'
};
drawerActive.value = true;
}
async function runAudit() {
if (!selectedLeadId.value) return;
loadingAudit.value = true;
try {
await api.post(`/acquisition/audit/${selectedLeadId.value}`);
notice.value = { type: 'success', message: 'Audit erfolgreich gestartet!' };
await fetchLeads();
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler beim Audit: ${e.message}` };
} finally {
loadingAudit.value = false;
}
}
async function sendAuditEmail() {
if (!selectedLeadId.value) return;
loadingEmail.value = true;
try {
await api.post(`/acquisition/audit-email/${selectedLeadId.value}`);
notice.value = { type: 'success', message: 'Audit E-Mail versendet!' };
await fetchLeads();
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler beim Versenden: ${e.message}` };
} finally {
loadingEmail.value = false;
}
}
async function generatePdf() {
if (!selectedLeadId.value) return;
loadingPdf.value = true;
try {
await api.post(`/acquisition/estimate/${selectedLeadId.value}`);
notice.value = { type: 'success', message: 'Angebot (PDF) wurde generiert!' };
await fetchLeads();
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler bei PDF Generierung: ${e.message}` };
} finally {
loadingPdf.value = false;
}
}
async function sendEstimateEmail() {
if (!selectedLeadId.value) return;
loadingEmail.value = true;
try {
await api.post(`/acquisition/estimate-email/${selectedLeadId.value}`);
notice.value = { type: 'success', message: 'Angebot erfolgreich versendet!' };
await fetchLeads();
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler beim Versenden: ${e.message}` };
} finally {
loadingEmail.value = false;
}
}
function openPdf() {
if (!selectedLead.value?.audit_pdf_path) return;
window.open(`${window.location.origin}/assets/${selectedLead.value.audit_pdf_path}`, '_blank');
}
async function saveLead() {
if (!newLead.value.company) {
notice.value = { type: 'danger', message: 'Organisation erforderlich.' };
return;
}
savingLead.value = true;
try {
const payload = {
id: crypto.randomUUID(),
...newLead.value
};
await api.post('/items/leads', payload);
notice.value = { type: 'success', message: 'Lead erfolgreich registriert!' };
drawerActive.value = false;
await fetchLeads();
selectedLeadId.value = payload.id;
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler beim Speichern: ${e.message}` };
} finally {
savingLead.value = false;
}
}
function openQuickAdd(type: string) {
notice.value = { type: 'info', message: `${type === 'company' ? 'Firma' : 'Person'} im jeweiligen Manager anlegen.` };
}
function getStatusIcon(status: string) {
switch(status) {
case 'new': return 'fiber_new';
case 'auditing': return 'hourglass_empty';
case 'audit_ready': return 'check_circle';
case 'contacted': return 'mail_outline';
default: return 'help_outline';
}
}
function getStatusColor(status: string) {
switch(status) {
case 'new': return 'var(--theme--primary)';
case 'auditing': return 'var(--theme--warning)';
case 'audit_ready': return 'var(--theme--success)';
case 'contacted': return 'var(--theme--secondary)';
default: return 'var(--theme--foreground-subdued)';
}
}
onMounted(async () => {
await fetchData();
if (route.query.create === 'true') {
openCreateDrawer();
}
});
</script>
<style scoped>
.url-link { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; }
.url-link:hover { border-bottom-color: currentColor; }
.sections { display: flex; flex-direction: column; gap: 32px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.field { display: flex; flex-direction: column; gap: 8px; }
.field.full { grid-column: span 2; }
.label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
.value { font-size: 15px; color: var(--theme--foreground); }
.text-block { line-height: 1.6; white-space: pre-wrap; background: var(--theme--background-subdued); padding: 16px; border-radius: 8px; }
.ai-observations { display: flex; flex-direction: column; gap: 16px; }
.section-title { font-size: 16px; font-weight: 700; color: var(--theme--foreground); margin-bottom: 8px; }
.metrics { display: flex; gap: 24px; margin-bottom: 16px; }
.observation-table { border: 1px solid var(--theme--border); border-radius: 8px; overflow: hidden; }
.page-title { font-weight: 600; }
.page-url { font-family: var(--family-monospace); font-size: 12px; color: var(--theme--foreground-subdued); }
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
.form-section { display: flex; flex-direction: column; gap: 20px; }
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
</style>

View File

@@ -1,55 +0,0 @@
import { build } from 'esbuild';
import { resolve, dirname } from 'path';
import { mkdirSync } from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const entryPoint = resolve(__dirname, 'src/index.ts');
const outfile = resolve(__dirname, 'dist/index.js');
try {
mkdirSync(dirname(outfile), { recursive: true });
} catch {
// ignore
}
console.log(`Building from ${entryPoint} to ${outfile}...`);
build({
entryPoints: [entryPoint],
bundle: true,
platform: 'node',
target: 'node18',
outfile: outfile,
jsx: 'automatic',
format: 'esm',
// footer: {
// js: "module.exports = module.exports.default || module.exports;",
// },
loader: {
'.tsx': 'tsx',
'.ts': 'ts',
'.js': 'js',
},
external: ["canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"],
plugins: [{
name: 'mock-canvas',
setup(build) {
build.onResolve({ filter: /^canvas/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
}
}, {
name: 'mock-jsdom',
setup(build) {
build.onResolve({ filter: /^jsdom/ }, args => ({ path: args.path, namespace: 'mock-jsdom' }));
build.onLoad({ filter: /.*/, namespace: 'mock-jsdom' }, () => ({ contents: 'export default {};', loader: 'js' }));
}
}]
}).then(() => {
console.log("Build succeeded!");
}).catch((e) => {
console.error("Build failed:", e);
process.exit(1);
});

View File

@@ -1,27 +0,0 @@
{
"name": "acquisition",
"version": "1.8.16",
"type": "module",
"directus:extension": {
"type": "endpoint",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "^11.0.0"
},
"scripts": {
"build": "node build.mjs",
"dev": "node build.mjs --watch"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/pdf": "workspace:*",
"@mintel/mail": "workspace:*",
"esbuild": "^0.25.0",
"typescript": "^5.6.3"
},
"dependencies": {
"jquery": "^3.7.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
}
}

View File

@@ -1,236 +0,0 @@
import { defineEndpoint } from "@directus/extensions-sdk";
import { AcquisitionService, PdfEngine } from "@mintel/pdf/server";
import {
render,
SiteAuditTemplate,
ProjectEstimateTemplate,
} from "@mintel/mail";
import { createElement } from "react";
import * as path from "path";
import * as fs from "fs";
export default defineEndpoint((router, { services, env }) => {
const { ItemsService, MailService } = services;
router.get("/ping", (req, res) => res.send("pong"));
router.post("/audit/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", {
schema: req.schema,
accountability: req.accountability,
});
try {
const lead = await leadsService.readOne(id);
if (!lead) return res.status(404).send({ error: "Lead not found" });
await leadsService.updateOne(id, { status: "auditing" });
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
const result = await acqService.runFullSequence(
lead.website_url,
lead.briefing,
lead.comments,
);
await leadsService.updateOne(id, {
status: "audit_ready",
ai_state: result.state,
audit_context: JSON.stringify(result.usage),
});
res.send({ success: true, result });
} catch (error: any) {
console.error("Audit failed:", error);
await leadsService.updateOne(id, {
status: "new",
comments: `Audit failed: ${error.message}`,
});
res.status(500).send({ error: error.message });
}
});
router.post("/audit-email/:id", async (req: any, res: any) => {
const { id } = req.params;
const { ItemsService, MailService } = services;
const leadsService = new ItemsService("leads", {
schema: req.schema,
accountability: req.accountability,
});
const _peopleService = new ItemsService("people", {
schema: req.schema,
accountability: req.accountability,
});
const companiesService = new ItemsService("companies", {
schema: req.schema,
accountability: req.accountability,
});
const mailService = new MailService({
schema: req.schema,
accountability: req.accountability,
});
try {
const lead = await leadsService.readOne(id, {
fields: ["*", "company.*", "contact_person.*"],
});
if (!lead || !lead.ai_state)
return res.status(400).send({ error: "Lead or Audit not ready" });
let recipientEmail = lead.contact_email;
let companyName = lead.company?.name || lead.company_name;
if (lead.contact_person) {
recipientEmail = lead.contact_person.email || recipientEmail;
if (lead.contact_person.company) {
const personCompany = await companiesService.readOne(
lead.contact_person.company,
);
companyName = personCompany?.name || companyName;
}
}
if (!recipientEmail)
return res.status(400).send({ error: "No recipient email found" });
const auditHighlights = [
`Projekt-Typ: ${lead.ai_state.projectType === "website" ? "Website" : "Web App"}`,
...(lead.ai_state.sitemap || [])
.slice(0, 3)
.map((item: any) => `Potenzial in: ${item.category}`),
];
const html = await render(
createElement(SiteAuditTemplate, {
companyName: companyName,
websiteUrl: lead.website_url,
auditHighlights,
}),
);
await mailService.send({
to: recipientEmail,
subject: `Analyse Ihrer Webpräsenz: ${companyName}`,
html,
});
await leadsService.updateOne(id, {
status: "contacted",
last_contacted_at: new Date().toISOString(),
});
res.send({ success: true });
} catch (error: any) {
console.error("Audit Email failed:", error);
res.status(500).send({ error: error.message });
}
});
router.post("/estimate/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", {
schema: req.schema,
accountability: req.accountability,
});
try {
const lead = await leadsService.readOne(id);
if (!lead || !lead.ai_state)
return res.status(400).send({ error: "Lead or AI state not found" });
const pdfEngine = new PdfEngine();
const filename = `estimate_${id}_${Date.now()}.pdf`;
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
const outputPath = path.join(storageRoot, filename);
await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath);
await leadsService.updateOne(id, {
audit_pdf_path: filename,
});
res.send({ success: true, filename });
} catch (error: any) {
console.error("PDF Generation failed:", error);
res.status(500).send({ error: error.message });
}
});
router.post("/estimate-email/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", {
schema: req.schema,
accountability: req.accountability,
});
const _peopleService = new ItemsService("people", {
schema: req.schema,
accountability: req.accountability,
});
const companiesService = new ItemsService("companies", {
schema: req.schema,
accountability: req.accountability,
});
const mailService = new MailService({
schema: req.schema,
accountability: req.accountability,
});
try {
const lead = await leadsService.readOne(id, {
fields: ["*", "company.*", "contact_person.*"],
});
if (!lead || !lead.audit_pdf_path)
return res.status(400).send({ error: "PDF not generated" });
let recipientEmail = lead.contact_email;
let companyName = lead.company?.name || lead.company_name;
if (lead.contact_person) {
recipientEmail = lead.contact_person.email || recipientEmail;
if (lead.contact_person.company) {
const personCompany = await companiesService.readOne(
lead.contact_person.company,
);
companyName = personCompany?.name || companyName;
}
}
if (!recipientEmail)
return res.status(400).send({ error: "No recipient email found" });
const html = await render(
createElement(ProjectEstimateTemplate, {
companyName: companyName,
}),
);
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
const attachmentPath = path.join(storageRoot, lead.audit_pdf_path);
await mailService.send({
to: recipientEmail,
subject: `Ihre Projekt-Schätzung: ${companyName}`,
html,
attachments: [
{
filename: `Angebot_${companyName}.pdf`,
content: fs.readFileSync(attachmentPath),
},
],
});
await leadsService.updateOne(id, {
status: "contacted",
last_contacted_at: new Date().toISOString(),
});
res.send({ success: true });
} catch (error: any) {
console.error("Estimate Email failed:", error);
res.status(500).send({ error: error.message });
}
});
});

View File

@@ -1,9 +1,9 @@
{
"name": "@mintel/cli",
"version": "1.8.16",
"version": "1.9.8",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
},
"type": "module",
"bin": {
@@ -16,16 +16,19 @@
"test": "vitest run"
},
"dependencies": {
"commander": "^11.0.0",
"fs-extra": "^11.1.0",
"chalk": "^5.3.0",
"prompts": "^2.4.2"
"commander": "^11.0.0",
"fs-extra": "^11.1.0"
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"@mintel/tsconfig": "workspace:*",
"@types/fs-extra": "^11.0.0",
"@types/prompts": "^2.4.4",
"@mintel/tsconfig": "workspace:*"
"tsup": "^8.0.0",
"typescript": "^5.0.0"
},
"repository": {
"type": "git",
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
}
}

View File

@@ -36,153 +36,15 @@ program
console.log(
chalk.yellow(`
📱 App: http://localhost:3000
🗄️ CMS: http://localhost:8055/admin
🚦 Traefik: http://localhost:8080
`),
);
execSync(
"docker compose down --remove-orphans && docker compose up -d app directus at-mintel-directus-db",
"docker compose down --remove-orphans && docker compose up -d app",
{ stdio: "inherit" },
);
});
const directus = program
.command("directus")
.description("Directus management commands");
directus
.command("bootstrap")
.description("Setup Directus branding and settings")
.action(async () => {
const { execSync } = await import("child_process");
console.log(chalk.blue("🎨 Bootstrapping Directus..."));
execSync("npx tsx --env-file=.env scripts/setup-directus.ts", {
stdio: "inherit",
});
});
directus
.command("bootstrap-feedback")
.description("Setup Directus collections and flows for Feedback")
.action(async () => {
const { execSync } = await import("child_process");
console.log(chalk.blue("📧 Bootstrapping Visual Feedback System..."));
// Use the logic from setup-feedback-hardened.ts
const bootstrapScript = `
import { createDirectus, rest, authentication, createCollection, createDashboard, createPanel, createItems, createPermission, readPolicies, readRoles, readUsers } from '@directus/sdk';
async function setup() {
const url = process.env.DIRECTUS_URL || 'http://localhost:8055';
const email = process.env.DIRECTUS_ADMIN_EMAIL;
const password = process.env.DIRECTUS_ADMIN_PASSWORD;
if (!email || !password) {
console.error('❌ DIRECTUS_ADMIN_EMAIL or DIRECTUS_ADMIN_PASSWORD not set');
process.exit(1);
}
const client = createDirectus(url).with(authentication('json')).with(rest());
try {
console.log('🔑 Authenticating...');
await client.login(email, password);
const roles = await client.request(readRoles());
const adminRole = roles.find(r => r.name === 'Administrator');
const policies = await client.request(readPolicies());
const adminPolicy = policies.find(p => p.name === 'Administrator');
console.log('🏗️ Creating Collection "visual_feedback"...');
try {
await client.request(createCollection({
collection: 'visual_feedback',
meta: { icon: 'feedback', display_template: '{{user_name}}: {{text}}' },
fields: [
{ field: 'id', type: 'uuid', schema: { is_primary_key: true } },
{ field: 'status', type: 'string', schema: { default_value: 'open' }, meta: { interface: 'select-dropdown' } },
{ field: 'url', type: 'string' },
{ field: 'selector', type: 'string' },
{ field: 'x', type: 'float' },
{ field: 'y', type: 'float' },
{ field: 'type', type: 'string' },
{ field: 'text', type: 'text' },
{ field: 'user_name', type: 'string' },
{ field: 'user_identity', type: 'string' },
{ field: 'screenshot', type: 'uuid', meta: { interface: 'file' } },
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
]
} as any));
} catch (_e) { console.log(' (Collection might already exist)'); }
try {
await client.request(createCollection({
collection: 'visual_feedback_comments',
meta: { icon: 'comment' },
fields: [
{ field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true } },
{ field: 'feedback_id', type: 'uuid', meta: { interface: 'select-dropdown' } },
{ field: 'user_name', type: 'string' },
{ field: 'text', type: 'text' },
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
]
} as any));
} catch (e) { }
if (adminPolicy) {
console.log('🔐 Granting ALL permissions to Administrator Policy...');
for (const coll of ['visual_feedback', 'visual_feedback_comments']) {
for (const action of ['create', 'read', 'update', 'delete']) {
try {
await client.request(createPermission({
collection: coll,
action,
fields: ['*'],
policy: adminPolicy.id
} as any));
} catch (_e) { }
}
}
}
console.log('📊 Creating Dashboard...');
try {
const dash = await client.request(createDashboard({ name: 'Visual Feedback', icon: 'feedback', color: '#6366f1' }));
await client.request(createPanel({
dashboard: dash.id,
name: 'Total Feedbacks',
type: 'metric',
width: 12, height: 6, position_x: 1, position_y: 1,
options: { collection: 'visual_feedback', function: 'count', field: 'id' }
} as any));
} catch (e) { }
console.log('✨ FEEDBACK BOOTSTRAP DONE.');
} catch (e) { console.error('❌ FAILURE:', e); }
}
setup();
`;
const tempFile = path.join(process.cwd(), "temp-bootstrap-feedback.ts");
await fs.writeFile(tempFile, bootstrapScript);
try {
execSync("npx tsx --env-file=.env " + tempFile, { stdio: "inherit" });
} finally {
await fs.remove(tempFile);
}
});
directus
.command("sync <action> <env>")
.description("Sync Directus data (push/pull) for a specific environment")
.action(async (action, env) => {
const { execSync } = await import("child_process");
console.log(
chalk.blue(`📥 Executing Directus sync: ${action} -> ${env}...`),
);
execSync(`./scripts/sync-directus.sh ${action} ${env}`, {
stdio: "inherit",
});
});
program
.command("pagespeed")
.description("Run PageSpeed (Lighthouse) tests")
@@ -221,13 +83,6 @@ program
lint: "next lint",
typecheck: "tsc --noEmit",
test: "vitest run --passWithNoTests",
"directus:bootstrap": "mintel directus bootstrap",
"directus:push:testing": "mintel directus sync push testing",
"directus:pull:testing": "mintel directus sync pull testing",
"directus:push:staging": "mintel directus sync push staging",
"directus:pull:staging": "mintel directus sync pull staging",
"directus:push:prod": "mintel directus sync push production",
"directus:pull:prod": "mintel directus sync pull production",
"pagespeed:test": "mintel pagespeed",
},
dependencies: {
@@ -236,7 +91,6 @@ program
"react-dom": "^19.0.0",
"@mintel/next-utils": "workspace:*",
"@mintel/next-observability": "workspace:*",
"@directus/sdk": "^21.0.0",
},
devDependencies: {
"@types/node": "^20.0.0",
@@ -473,15 +327,6 @@ export default function Home() {
}
}
// Create Directus structure
await fs.ensureDir(path.join(fullPath, "directus/uploads"));
await fs.ensureDir(path.join(fullPath, "directus/extensions"));
await fs.writeFile(path.join(fullPath, "directus/uploads/.gitkeep"), "");
await fs.writeFile(
path.join(fullPath, "directus/extensions/.gitkeep"),
"",
);
// Create .env.example
const envExample = `# Project
PROJECT_NAME=${projectName}
@@ -493,21 +338,10 @@ AUTH_COOKIE_NAME=mintel_gatekeeper_session
# Host Config (Local)
TRAEFIK_HOST=\`${projectName}.localhost\`
DIRECTUS_HOST=\`cms.${projectName}.localhost\`
# Next.js
NEXT_PUBLIC_BASE_URL=http://${projectName}.localhost
# Directus
DIRECTUS_URL=http://cms.${projectName}.localhost
DIRECTUS_KEY=$(openssl rand -hex 32 2>/dev/null || echo "mintel-key")
DIRECTUS_SECRET=$(openssl rand -hex 32 2>/dev/null || echo "mintel-secret")
DIRECTUS_ADMIN_EMAIL=admin@mintel.me
DIRECTUS_ADMIN_PASSWORD=mintel-admin-pass
DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=directus
DIRECTUS_DB_PASSWORD=mintel-db-pass
# Sentry / Glitchtip
SENTRY_DSN=

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/cloner",
"version": "1.8.16",
"version": "1.9.8",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
@@ -17,14 +17,17 @@
"dev": "node build.mjs --watch"
},
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.25.0",
"typescript": "^5.6.3",
"@types/node": "^22.0.0"
"typescript": "^5.6.3"
},
"dependencies": {
"playwright": "^1.40.0",
"crawlee": "^3.7.0",
"axios": "^1.6.0",
"cheerio": "^1.0.0-rc.12"
"crawlee": "^3.7.0",
"playwright": "^1.40.0"
},
"repository": {
"type": "git",
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
}
}

View File

@@ -1,11 +0,0 @@
FROM directus/directus:11
USER root
# Install dependencies in a way that avoids metadata conflicts in the root
RUN mkdir -p /directus/lib-dependencies && \
cd /directus/lib-dependencies && \
npm init -y && \
npm install vue @vueuse/core vue-router
# Ensure they are in the NODE_PATH
ENV NODE_PATH="/directus/lib-dependencies/node_modules:${NODE_PATH}"
USER node

Binary file not shown.

View File

@@ -1,53 +0,0 @@
services:
infra-cms:
image: directus/directus:11.15.2
ports:
- "8059:8055"
networks:
- default
- infra
environment:
KEY: "infra-cms-key"
SECRET: "infra-cms-secret"
ADMIN_EMAIL: "marc@mintel.me"
ADMIN_PASSWORD: "Tim300493."
DB_CLIENT: "sqlite3"
DB_FILENAME: "/directus/database/data.db"
WEBSOCKETS_ENABLED: "true"
PUBLIC_URL: "http://cms.localhost"
EMAIL_TRANSPORT: "smtp"
EMAIL_SMTP_HOST: "smtp.eu.mailgun.org"
EMAIL_SMTP_PORT: "587"
EMAIL_SMTP_USER: "postmaster@mg.mintel.me"
EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6"
EMAIL_SMTP_SECURE: "false"
EMAIL_FROM: "postmaster@mg.mintel.me"
LOG_LEVEL: "debug"
SERVE_APP: "true"
EXTENSIONS_AUTO_RELOAD: "true"
EXTENSIONS_SANDBOX: "false"
CONTENT_SECURITY_POLICY: "false"
volumes:
- ./database:/directus/database
- ./uploads:/directus/uploads
- ./schema:/directus/schema
- ./extensions:/directus/extensions
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8055/server/health" ]
interval: 10s
timeout: 5s
retries: 5
labels:
traefik.enable: "true"
traefik.http.routers.at-mintel-infra-cms.rule: "Host(`cms.localhost`)"
traefik.docker.network: "infra"
caddy: "http://cms.localhost"
caddy.reverse_proxy: "{{upstreams 8055}}"
caddy.header.Cache-Control: "no-store, no-cache, must-revalidate, max-age=0"
networks:
default:
name: at-mintel-cms-network
infra:
external: true

View File

@@ -1,18 +0,0 @@
{
"name": "@mintel/cms-infra",
"version": "1.8.16",
"private": true,
"type": "module",
"scripts": {
"dev": "npm run up -- --link",
"up": "../../scripts/cms-up.sh",
"down": "docker compose down",
"logs": "docker compose logs -f",
"build:extensions": "../../scripts/sync-extensions.sh",
"schema:apply:local": "../../scripts/cms-apply.sh local",
"schema:apply:infra": "../../scripts/cms-apply.sh infra",
"snapshot:local": "../../scripts/cms-snapshot.sh",
"sync:push": "../../scripts/sync-directus.sh push infra",
"sync:pull": "../../scripts/sync-directus.sh pull infra"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +0,0 @@
{
"name": "company-manager",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.16",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "app",
"name": "company manager"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"dependencies": {
"@mintel/directus-extension-toolkit": "workspace:*"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}

View File

@@ -1,14 +0,0 @@
import { defineModule } from '@directus/extensions-sdk';
import ModuleComponent from './module.vue';
export default defineModule({
id: 'company-manager',
name: 'Company Manager',
icon: 'business',
routes: [
{
path: '',
component: ModuleComponent,
},
],
});

View File

@@ -1,224 +0,0 @@
<template>
<MintelManagerLayout
title="Company Manager"
:item-title="selectedCompany?.name || 'Firma wählen'"
:is-empty="!selectedCompany"
empty-title="Firma auswählen"
empty-icon="business"
:notice="feedback"
@close-notice="feedback = null"
>
<template #navigation>
<v-list nav>
<v-list-item @click="openCreateDrawer" clickable>
<v-list-item-icon>
<v-icon name="add" color="var(--theme--primary)" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow text="Neue Firma anlegen" />
</v-list-item-content>
</v-list-item>
<v-divider />
<v-list-item
v-for="company in companies"
:key="company.id"
:active="selectedCompany?.id === company.id"
class="nav-item"
clickable
@click="selectCompany(company)"
>
<v-list-item-icon>
<v-icon name="business" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="company.name" />
</v-list-item-content>
</v-list-item>
</v-list>
</template>
<template #subtitle>
<template v-if="selectedCompany">
{{ selectedCompany.domain || 'Keine Domain angegeben' }}
</template>
</template>
<template #actions>
<v-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openEditDrawer">
<v-icon name="edit" />
</v-button>
<v-button danger rounded icon v-tooltip.bottom="'Firma löschen'" @click="deleteCompany">
<v-icon name="delete" />
</v-button>
</template>
<template #empty-state>
Wähle eine Firma in der Navigation aus oder
<v-button x-small @click="openCreateDrawer">erstelle eine neue Firma</v-button>.
</template>
<div v-if="selectedCompany" class="details-grid">
<div class="detail-item full">
<span class="label">Notizen / Adresse</span>
<p class="value">{{ selectedCompany.notes || '---' }}</p>
</div>
</div>
<!-- Create/Edit Drawer -->
<v-drawer
v-model="drawerActive"
:title="isEditing ? 'Firma bearbeiten' : 'Neue Firma anlegen'"
icon="business"
@cancel="drawerActive = false"
>
<template #default>
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Firmenname</span>
<v-input v-model="form.name" placeholder="z.B. Schmidt GmbH" autofocus />
</div>
<div class="field">
<span class="label">Domain / Website</span>
<v-input v-model="form.domain" placeholder="example.com" />
</div>
<div class="field">
<span class="label">Notizen / Adresse</span>
<v-textarea v-model="form.notes" placeholder="z.B. Branche, Adresse, etc." />
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="saving" @click="saveCompany">
Firma speichern
</v-button>
</div>
</div>
</template>
</v-drawer>
</MintelManagerLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useApi } from '@directus/extensions-sdk';
import { useRoute } from 'vue-router';
import { MintelManagerLayout } from '@mintel/directus-extension-toolkit';
const api = useApi();
const route = useRoute();
const companies = ref([]);
const selectedCompany = ref(null);
const feedback = ref(null);
const saving = ref(false);
const drawerActive = ref(false);
const isEditing = ref(false);
const form = ref({
id: null,
name: '',
domain: '',
notes: ''
});
async function fetchData() {
try {
const resp = await api.get('/items/companies', {
params: { sort: 'name' }
});
companies.value = resp.data.data;
} catch (error) {
console.error('Failed to fetch companies:', error);
}
}
function selectCompany(company: any) {
selectedCompany.value = company;
}
function openCreateDrawer() {
isEditing.value = false;
form.value = {
id: null,
name: '',
domain: '',
notes: ''
};
drawerActive.value = true;
}
function openEditDrawer() {
isEditing.value = true;
form.value = {
id: selectedCompany.value.id,
name: selectedCompany.value.name,
domain: selectedCompany.value.domain,
notes: selectedCompany.value.notes
};
drawerActive.value = true;
}
async function saveCompany() {
if (!form.value.name) {
feedback.value = { type: 'danger', message: 'Firmenname ist erforderlich.' };
return;
}
saving.value = true;
try {
let updatedItem;
if (isEditing.value) {
const res = await api.patch(`/items/companies/${form.value.id}`, form.value);
updatedItem = res.data.data;
feedback.value = { type: 'success', message: 'Firma aktualisiert!' };
} else {
const res = await api.post('/items/companies', form.value);
updatedItem = res.data.data;
feedback.value = { type: 'success', message: 'Firma angelegt!' };
}
drawerActive.value = false;
await fetchData();
if (updatedItem) {
selectedCompany.value = companies.value.find(c => c.id === updatedItem.id) || updatedItem;
}
} catch (error) {
feedback.value = { type: 'danger', message: error.message };
} finally {
saving.value = false;
}
}
async function deleteCompany() {
if (!confirm('Soll diese Firma wirklich gelöscht werden?')) return;
try {
await api.delete(`/items/companies/${selectedCompany.value.id}`);
feedback.value = { type: 'success', message: 'Firma gelöscht.' };
selectedCompany.value = null;
await fetchData();
} catch (error) {
feedback.value = { type: 'danger', message: error.message };
}
}
onMounted(async () => {
await fetchData();
if (route.query.create === 'true') {
openCreateDrawer();
}
});
</script>
<style scoped>
.details-grid { display: flex; flex-direction: column; gap: 24px; }
.detail-item { display: flex; flex-direction: column; gap: 8px; }
.detail-item.full { width: 100%; }
.label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
.value { font-size: 16px; font-weight: 500; }
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
.form-section { display: flex; flex-direction: column; gap: 20px; }
.field { display: flex; flex-direction: column; gap: 8px; }
.drawer-actions { margin-top: 24px; }
</style>

View File

@@ -0,0 +1,36 @@
{
"name": "@mintel/concept-engine",
"version": "1.9.8",
"private": true,
"description": "AI-powered web project concept generation and analysis",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"concept": "./dist/cli.js"
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest",
"clean": "rm -rf dist",
"lint": "eslint src --ext .ts",
"concept": "tsx src/cli.ts run"
},
"dependencies": {
"@mintel/journaling": "workspace:*",
"@mintel/page-audit": "workspace:*",
"axios": "^1.7.9",
"cheerio": "1.0.0-rc.12",
"commander": "^13.1.0",
"dotenv": "^16.4.7"
},
"devDependencies": {
"@types/node": "^20.17.17",
"tsup": "^8.3.6",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}

View File

@@ -0,0 +1,39 @@
import { config as dotenvConfig } from "dotenv";
import * as path from "node:path";
import * as fs from "node:fs/promises";
import { ConceptPipeline } from "./pipeline.js";
dotenvConfig({ path: path.resolve(process.cwd(), "../../.env") });
const briefing = await fs.readFile(
path.resolve(process.cwd(), "../../data/briefings/etib.txt"),
"utf8",
);
console.log(`Briefing loaded: ${briefing.length} chars`);
const pipeline = new ConceptPipeline(
{
openrouterKey: process.env.OPENROUTER_API_KEY || "",
zyteApiKey: process.env.ZYTE_API_KEY,
outputDir: path.resolve(process.cwd(), "../../out/estimations"),
crawlDir: path.resolve(process.cwd(), "../../data/crawls"),
},
{
onStepStart: (id, _name) => console.log(`[CB] Starting: ${id}`),
onStepComplete: (id) => console.log(`[CB] Done: ${id}`),
onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`),
},
);
try {
await pipeline.run({
briefing,
url: "https://www.e-tib.com",
});
console.log("\n✨ Pipeline complete!");
} catch (err: any) {
console.error("\n❌ Pipeline failed:", err.message);
console.error(err.stack);
}

View File

@@ -0,0 +1,334 @@
// ============================================================================
// Analyzer — Deterministic Site Analysis (NO LLM!)
// Builds a SiteProfile from crawled pages using pure code logic.
// This is the core fix against hallucinated page structures.
// ============================================================================
import type {
CrawledPage,
SiteProfile,
NavItem,
CompanyInfo,
PageInventoryItem,
} from "./types.js";
/**
* Build a complete SiteProfile from an array of crawled pages.
* This is 100% deterministic — no LLM calls involved.
*/
export function analyzeSite(pages: CrawledPage[], domain: string): SiteProfile {
const navigation = extractNavigation(pages);
const existingFeatures = extractExistingFeatures(pages);
const services = extractAllServices(pages);
const companyInfo = extractCompanyInfo(pages);
const colors = extractColors(pages);
const socialLinks = extractSocialLinks(pages);
const externalDomains = extractExternalDomains(pages, domain);
const images = extractAllImages(pages);
const employeeCount = extractEmployeeCount(pages);
const pageInventory = buildPageInventory(pages);
return {
domain,
crawledAt: new Date().toISOString(),
totalPages: pages.filter((p) => p.type !== "legal").length,
navigation,
existingFeatures,
services,
companyInfo,
pageInventory,
colors,
socialLinks,
externalDomains,
images,
employeeCount,
};
}
/**
* Extract the site's main navigation structure from <nav> elements.
* Uses the HOME page's nav as the canonical source.
*/
function extractNavigation(pages: CrawledPage[]): NavItem[] {
// Prefer the home page's nav
const homePage = pages.find((p) => p.type === "home");
const sourcePage = homePage || pages[0];
if (!sourcePage) return [];
// Deduplicate nav items
const seen = new Set<string>();
const navItems: NavItem[] = [];
for (const label of sourcePage.navItems) {
const normalized = label.toLowerCase().trim();
if (seen.has(normalized)) continue;
if (normalized.length < 2) continue;
seen.add(normalized);
navItems.push({ label, href: "" });
}
return navItems;
}
/**
* Aggregate all detected interactive features across all pages.
*/
function extractExistingFeatures(pages: CrawledPage[]): string[] {
const allFeatures = new Set<string>();
for (const page of pages) {
for (const feature of page.features) {
allFeatures.add(feature);
}
}
return [...allFeatures];
}
/**
* Aggregate all images found across all pages.
*/
function extractAllImages(pages: CrawledPage[]): string[] {
const allImages = new Set<string>();
for (const page of pages) {
if (!page.images) continue;
for (const img of page.images) {
allImages.add(img);
}
}
return [...allImages];
}
/**
* Extract employee count from page text.
* Looks for patterns like "über 50 Mitarbeitern", "200 Mitarbeiter", "50+ employees".
*/
function extractEmployeeCount(pages: CrawledPage[]): string | null {
const allText = pages.map((p) => p.text).join(" ");
// German patterns: 'über 50 Mitarbeitern', '120 Beschäftigte', '+200 MA'
const patterns = [
/(über|ca\.?|rund|mehr als|\+)?\s*(\d{1,4})\s*(Mitarbeiter(?:innen)?|Beschäftigte|MA|Fachkräfte)\b/gi,
/(\d{1,4})\+?\s*(employees|team members)/gi,
];
for (const pattern of patterns) {
const match = allText.match(pattern);
if (match && match[0]) {
const num = match[0].match(/(\d{1,4})/)?.[1];
const prefix = match[0].match(/über|ca\.?|rund|mehr als/i)?.[0];
if (num) return prefix ? `${prefix} ${num}` : num;
}
}
return null;
}
/**
* Extract services/competencies from service-type pages.
* Focuses on H2-H3 headings and list items on service pages.
*/
function extractAllServices(pages: CrawledPage[]): string[] {
const servicePages = pages.filter(
(p) => p.type === "service" || p.pathname.includes("kompetenz"),
);
const services = new Set<string>();
for (const page of servicePages) {
// Use headings as primary service indicators
for (const heading of page.headings) {
const clean = heading.trim();
if (clean.length > 3 && clean.length < 100) {
// Skip generic headings
if (/^(home|kontakt|impressum|datenschutz|menü|navigation|suche)/i.test(clean)) continue;
services.add(clean);
}
}
}
// If no service pages found, look at the home page headings too
if (services.size === 0) {
const homePage = pages.find((p) => p.type === "home");
if (homePage) {
for (const heading of homePage.headings) {
const clean = heading.trim();
if (clean.length > 3 && clean.length < 80) {
services.add(clean);
}
}
}
}
return [...services];
}
/**
* Extract company information from Impressum / footer content.
*/
function extractCompanyInfo(pages: CrawledPage[]): CompanyInfo {
const info: CompanyInfo = {};
// Find Impressum or legal page
const legalPage = pages.find(
(p) =>
p.type === "legal" &&
(p.pathname.includes("impressum") || p.title.toLowerCase().includes("impressum")),
);
const sourceText = legalPage?.text || pages.find((p) => p.type === "home")?.text || "";
// USt-ID
const taxMatch = sourceText.match(/USt[.\s-]*(?:ID[.\s-]*Nr\.?|IdNr\.?)[:\s]*([A-Z]{2}\d{9,11})/i);
if (taxMatch) info.taxId = taxMatch[1];
// HRB number
const hrbMatch = sourceText.match(/HRB[:\s]*(\d+\s*[A-Z]*)/i);
if (hrbMatch) info.registerNumber = `HRB ${hrbMatch[1].trim()}`;
// Phone
const phoneMatch = sourceText.match(/(?:Tel|Telefon|Fon)[.:\s]*([+\d\s()/-]{10,20})/i);
if (phoneMatch) info.phone = phoneMatch[1].trim();
// Email
const emailMatch = sourceText.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/);
if (emailMatch) info.email = emailMatch[0];
// Address (look for German postal code pattern)
const addressMatch = sourceText.match(
/(?:[\w\s.-]+(?:straße|str\.|weg|platz|ring|allee|gasse)\s*\d+[a-z]?\s*,?\s*)?(?:D-)?(\d{5})\s+\w+/i,
);
if (addressMatch) info.address = addressMatch[0].trim();
// GF / Geschäftsführer
const gfMatch = sourceText.match(
/Geschäftsführ(?:er|ung)[:\s]*([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+){1,3})/,
);
if (gfMatch) info.managingDirector = gfMatch[1].trim();
return info;
}
/**
* Extract brand colors from HTML (inline styles, CSS variables).
*/
function extractColors(pages: CrawledPage[]): string[] {
const colors = new Set<string>();
const homePage = pages.find((p) => p.type === "home");
if (!homePage) return [];
const hexMatches = homePage.html.match(/#(?:[0-9a-fA-F]{3}){1,2}\b/g) || [];
for (const hex of hexMatches) {
colors.add(hex.toLowerCase());
if (colors.size >= 8) break;
}
return [...colors];
}
/**
* Extract social media links from footers / headers.
*/
function extractSocialLinks(pages: CrawledPage[]): Record<string, string> {
const socials: Record<string, string> = {};
const platforms = [
{ key: "linkedin", patterns: ["linkedin.com"] },
{ key: "instagram", patterns: ["instagram.com"] },
{ key: "facebook", patterns: ["facebook.com", "fb.com"] },
{ key: "youtube", patterns: ["youtube.com", "youtu.be"] },
{ key: "twitter", patterns: ["twitter.com", "x.com"] },
{ key: "xing", patterns: ["xing.com"] },
];
const homePage = pages.find((p) => p.type === "home");
if (!homePage) return socials;
const urlMatches = homePage.html.match(/https?:\/\/[^\s"'<>]+/g) || [];
for (const url of urlMatches) {
for (const platform of platforms) {
if (platform.patterns.some((p) => url.includes(p)) && !socials[platform.key]) {
socials[platform.key] = url;
}
}
}
return socials;
}
/**
* Find domains that are linked but separate from the main domain.
* Critical for detecting sister companies with own websites (e.g. etib-ing.com).
*/
function extractExternalDomains(pages: CrawledPage[], mainDomain: string): string[] {
const externalDomains = new Set<string>();
const cleanMain = mainDomain.replace(/^www\./, "");
// Extract meaningful base parts: "e-tib.com" → ["e", "tib", "etib"]
const mainParts = cleanMain.split(".")[0].toLowerCase().split(/[-_]/).filter(p => p.length > 1);
const mainJoined = mainParts.join(""); // "etib"
for (const page of pages) {
const linkMatches = page.html.match(/https?:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g) || [];
for (const url of linkMatches) {
try {
const urlObj = new URL(url);
const domain = urlObj.hostname.replace(/^www\./, "");
// Skip same domain
if (domain === cleanMain) continue;
// Skip common third-party services
if (
domain.includes("google") ||
domain.includes("facebook") ||
domain.includes("twitter") ||
domain.includes("linkedin") ||
domain.includes("instagram") ||
domain.includes("youtube") ||
domain.includes("cookie") ||
domain.includes("analytics") ||
domain.includes("cdn") ||
domain.includes("cloudflare") ||
domain.includes("fonts") ||
domain.includes("jquery") ||
domain.includes("bootstrap") ||
domain.includes("wordpress") ||
domain.includes("jimdo") ||
domain.includes("wix")
)
continue;
// Fuzzy match: check if the domain contains any base part of the main domain
// e.g. main="e-tib.com" → mainParts=["e","tib"], mainJoined="etib"
// target="etib-ing.com" → domainBase="etib-ing", domainJoined="etibing"
const domainBase = domain.split(".")[0].toLowerCase();
const domainJoined = domainBase.replace(/[-_]/g, "");
const isRelated =
domainJoined.includes(mainJoined) ||
mainJoined.includes(domainJoined) ||
mainParts.some(part => part.length > 2 && domainBase.includes(part));
if (isRelated) {
externalDomains.add(domain);
}
} catch {
// Invalid URL
}
}
}
return [...externalDomains];
}
/**
* Build a structured inventory of all pages.
*/
function buildPageInventory(pages: CrawledPage[]): PageInventoryItem[] {
return pages.map((page) => ({
url: page.url,
pathname: page.pathname,
title: page.title,
type: page.type,
headings: page.headings.slice(0, 10),
services: page.type === "service" ? page.headings.filter((h) => h.length > 3 && h.length < 80) : [],
hasSearch: page.features.includes("search"),
hasForms: page.features.includes("forms"),
hasMap: page.features.includes("maps"),
hasVideo: page.features.includes("video"),
contentSummary: page.text.substring(0, 500),
}));
}

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env node
// ============================================================================
// @mintel/concept-engine — CLI Entry Point
// Simple commander-based CLI for concept generation.
// ============================================================================
import { Command } from "commander";
import * as path from "node:path";
import * as fs from "node:fs/promises";
import { existsSync } from "node:fs";
import { config as dotenvConfig } from "dotenv";
import { ConceptPipeline } from "./pipeline.js";
// Load .env from monorepo root
dotenvConfig({ path: path.resolve(process.cwd(), "../../.env") });
dotenvConfig({ path: path.resolve(process.cwd(), ".env") });
const program = new Command();
program
.name("concept")
.description("AI-powered project concept generator")
.version("1.0.0");
program
.command("run")
.description("Run the full concept pipeline")
.argument("[briefing]", "Briefing text or @path/to/file.txt")
.option("--url <url>", "Target website URL")
.option("--comments <comments>", "Additional notes")
.option("--clear-cache", "Clear crawl cache and re-crawl")
.option("--output <dir>", "Output directory", "../../out/concepts")
.option("--crawl-dir <dir>", "Crawl data directory", "../../data/crawls")
.action(async (briefingArg: string | undefined, options: any) => {
const openrouterKey =
process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!openrouterKey) {
console.error("❌ OPENROUTER_API_KEY not found in environment.");
process.exit(1);
}
let briefing = briefingArg || "";
// Handle @file references
if (briefing.startsWith("@")) {
const rawPath = briefing.substring(1);
const filePath = rawPath.startsWith("/")
? rawPath
: path.resolve(process.cwd(), rawPath);
if (!existsSync(filePath)) {
console.error(`❌ Briefing file not found: ${filePath}`);
process.exit(1);
}
briefing = await fs.readFile(filePath, "utf8");
console.log(`📄 Loaded briefing from: ${filePath}`);
}
// Auto-discover URL from briefing
let url = options.url;
if (!url && briefing) {
const urlMatch = briefing.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
url = urlMatch[0];
console.log(`🔗 Discovered URL in briefing: ${url}`);
}
}
if (!briefing && !url) {
console.error("❌ Provide a briefing text or --url");
process.exit(1);
}
const pipeline = new ConceptPipeline(
{
openrouterKey,
zyteApiKey: process.env.ZYTE_API_KEY,
outputDir: path.resolve(process.cwd(), options.output),
crawlDir: path.resolve(process.cwd(), options.crawlDir),
},
{
onStepStart: (_id, _name) => {
// Will be enhanced with Ink spinner later
},
onStepComplete: (_id, _result) => {
// Will be enhanced with Ink UI later
},
},
);
try {
await pipeline.run({
briefing,
url,
comments: options.comments,
clearCache: options.clearCache,
});
console.log("\n✨ Concept generation complete!");
} catch (err) {
console.error(`\n❌ Pipeline failed: ${(err as Error).message}`);
process.exit(1);
}
});
program
.command("analyze")
.description("Only crawl and analyze a website (no LLM)")
.argument("<url>", "Website URL to analyze")
.option("--crawl-dir <dir>", "Crawl data directory", "../../data/crawls")
.option("--clear-cache", "Clear existing crawl cache")
.action(async (url: string, options: any) => {
const { crawlSite } = await import("./scraper.js");
const { analyzeSite } = await import("./analyzer.js");
if (options.clearCache) {
const { clearCrawlCache } = await import("./scraper.js");
const domain = new URL(url).hostname;
await clearCrawlCache(
path.resolve(process.cwd(), options.crawlDir),
domain,
);
}
const pages = await crawlSite(url, {
zyteApiKey: process.env.ZYTE_API_KEY,
crawlDir: path.resolve(process.cwd(), options.crawlDir),
});
const domain = new URL(url).hostname;
const profile = analyzeSite(pages, domain);
console.log("\n📊 Site Profile:");
console.log(` Domain: ${profile.domain}`);
console.log(` Total Pages: ${profile.totalPages}`);
console.log(
` Navigation: ${profile.navigation.map((n) => n.label).join(", ")}`,
);
console.log(` Features: ${profile.existingFeatures.join(", ") || "none"}`);
console.log(` Services: ${profile.services.join(", ") || "none"}`);
console.log(
` External Domains: ${profile.externalDomains.join(", ") || "none"}`,
);
console.log(` Company: ${profile.companyInfo.name || "unbekannt"}`);
console.log(` Tax ID: ${profile.companyInfo.taxId || "unbekannt"}`);
console.log(` Colors: ${profile.colors.join(", ")}`);
console.log(` Images Found: ${profile.images.length}`);
console.log(
` Social: ${
Object.entries(profile.socialLinks)
.map(([_k, _v]) => `${_k}`)
.join(", ") || "none"
}`,
);
const outputPath = path.join(
path.resolve(process.cwd(), options.crawlDir),
domain.replace(/\./g, "-"),
"_site_profile.json",
);
console.log(`\n📦 Full profile saved to: ${outputPath}`);
});
program.parse();

View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from "vitest";
describe("concept-engine", () => {
it("should pass", () => {
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,10 @@
// ============================================================================
// @mintel/concept-engine — Public API
// ============================================================================
export { ConceptPipeline } from "./pipeline.js";
export type { PipelineCallbacks } from "./pipeline.js";
export { crawlSite, clearCrawlCache } from "./scraper.js";
export { analyzeSite } from "./analyzer.js";
export { llmRequest, llmJsonRequest, cleanJson } from "./llm-client.js";
export * from "./types.js";

View File

@@ -0,0 +1,142 @@
// ============================================================================
// LLM Client — Unified interface with model routing via OpenRouter
// ============================================================================
import axios from "axios";
interface LLMRequestOptions {
model: string;
systemPrompt: string;
userPrompt: string;
jsonMode?: boolean;
apiKey: string;
}
interface LLMResponse {
content: string;
usage: {
promptTokens: number;
completionTokens: number;
cost: number;
};
}
/**
* Clean raw LLM output to parseable JSON.
* Handles markdown fences, control chars, trailing commas.
*/
export function cleanJson(str: string): string {
let cleaned = str.replace(/```json\n?|```/g, "").trim();
// eslint-disable-next-line no-control-regex
cleaned = cleaned.replace(/[\x00-\x1f\x7f-\x9f]/gi, " ");
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
return cleaned;
}
/**
* Send a request to an LLM via OpenRouter.
*/
export async function llmRequest(
options: LLMRequestOptions,
): Promise<LLMResponse> {
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
const resp = await axios
.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
...(jsonMode ? { response_format: { type: "json_object" } } : {}),
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
timeout: 120000,
},
)
.catch((err) => {
if (err.response) {
console.error(
"OpenRouter API Error:",
JSON.stringify(err.response.data, null, 2),
);
}
throw err;
});
const content = resp.data.choices?.[0]?.message?.content;
if (!content) {
throw new Error(`LLM returned no content. Model: ${model}`);
}
let cost = 0;
const usage = resp.data.usage || {};
if (usage.cost !== undefined) {
cost = usage.cost;
} else {
// Fallback estimation
cost =
(usage.prompt_tokens || 0) * (0.1 / 1_000_000) +
(usage.completion_tokens || 0) * (0.4 / 1_000_000);
}
return {
content,
usage: {
promptTokens: usage.prompt_tokens || 0,
completionTokens: usage.completion_tokens || 0,
cost,
},
};
}
/**
* Send a request and parse the response as JSON.
*/
export async function llmJsonRequest<T = any>(
options: LLMRequestOptions,
): Promise<{ data: T; usage: LLMResponse["usage"] }> {
const response = await llmRequest({ ...options, jsonMode: true });
const cleaned = cleanJson(response.content);
let parsed: T;
try {
parsed = JSON.parse(cleaned);
} catch (e) {
throw new Error(
`Failed to parse LLM JSON response: ${(e as Error).message}\nRaw: ${cleaned.substring(0, 500)}`,
);
}
// Unwrap common LLM artifacts: {"0": {...}}, {"state": {...}}, etc.
const unwrapped = unwrapResponse(parsed);
return { data: unwrapped as T, usage: response.usage };
}
/**
* Recursively unwrap common LLM wrapping patterns.
*/
function unwrapResponse(obj: any): any {
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj;
const keys = Object.keys(obj);
if (keys.length === 1) {
const key = keys[0];
if (
key === "0" ||
key === "state" ||
key === "facts" ||
key === "result" ||
key === "data"
) {
return unwrapResponse(obj[key]);
}
}
return obj;
}

View File

@@ -0,0 +1,296 @@
// ============================================================================
// Pipeline Orchestrator
// Runs all steps sequentially, tracks state, supports re-running individual steps.
// ============================================================================
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { crawlSite, clearCrawlCache } from "./scraper.js";
import { analyzeSite } from "./analyzer.js";
import { executeResearch } from "./steps/00b-research.js";
import { executeExtract } from "./steps/01-extract.js";
import { executeSiteAudit } from "./steps/00a-site-audit.js";
import { executeAudit } from "./steps/02-audit.js";
import { executeStrategize } from "./steps/03-strategize.js";
import { executeArchitect } from "./steps/04-architect.js";
import type {
PipelineConfig,
PipelineInput,
ConceptState,
ProjectConcept,
StepResult,
} from "./types.js";
export interface PipelineCallbacks {
onStepStart?: (stepId: string, stepName: string) => void;
onStepComplete?: (stepId: string, result: StepResult) => void;
onStepError?: (stepId: string, error: string) => void;
}
/**
* The main concept pipeline orchestrator.
* Runs conceptual steps sequentially and builds the ProjectConcept.
*/
export class ConceptPipeline {
private config: PipelineConfig;
private state: ConceptState;
private callbacks: PipelineCallbacks;
constructor(config: PipelineConfig, callbacks: PipelineCallbacks = {}) {
this.config = config;
this.callbacks = callbacks;
this.state = this.createInitialState();
}
private createInitialState(): ConceptState {
return {
briefing: "",
usage: {
totalPromptTokens: 0,
totalCompletionTokens: 0,
totalCost: 0,
perStep: [],
},
};
}
/**
* Run the full concept pipeline from scratch.
*/
async run(input: PipelineInput): Promise<ProjectConcept> {
this.state.briefing = input.briefing;
this.state.url = input.url;
this.state.comments = input.comments;
// Ensure output directories
await fs.mkdir(this.config.outputDir, { recursive: true });
await fs.mkdir(this.config.crawlDir, { recursive: true });
// Step 0: Scrape & Analyze (deterministic)
if (input.url) {
if (input.clearCache) {
const domain = new URL(input.url).hostname;
await clearCrawlCache(this.config.crawlDir, domain);
}
await this.runStep(
"00-scrape",
"Scraping & Analyzing Website",
async () => {
const pages = await crawlSite(input.url!, {
zyteApiKey: this.config.zyteApiKey,
crawlDir: this.config.crawlDir,
});
const domain = new URL(input.url!).hostname;
const siteProfile = analyzeSite(pages, domain);
this.state.siteProfile = siteProfile;
this.state.crawlDir = path.join(
this.config.crawlDir,
domain.replace(/\./g, "-"),
);
// Save site profile
await fs.writeFile(
path.join(this.state.crawlDir!, "_site_profile.json"),
JSON.stringify(siteProfile, null, 2),
);
return {
success: true,
data: siteProfile,
usage: {
step: "00-scrape",
model: "none",
promptTokens: 0,
completionTokens: 0,
cost: 0,
durationMs: 0,
},
};
},
);
}
// Step 00a: Site Audit (DataForSEO)
await this.runStep(
"00a-site-audit",
"IST-Analysis (DataForSEO)",
async () => {
const result = await executeSiteAudit(this.state, this.config);
if (result.success && result.data) {
this.state.siteAudit = result.data;
}
return result;
},
);
// Step 00b: Research (real web data via journaling)
await this.runStep(
"00b-research",
"Industry & Company Research",
async () => {
const result = await executeResearch(this.state);
if (result.success && result.data) {
this.state.researchData = result.data;
}
return result;
},
);
// Step 1: Extract facts
await this.runStep(
"01-extract",
"Extracting Facts from Briefing",
async () => {
const result = await executeExtract(this.state, this.config);
if (result.success) this.state.facts = result.data;
return result;
},
);
// Step 2: Audit features
await this.runStep(
"02-audit",
"Auditing Features (Skeptical Review)",
async () => {
const result = await executeAudit(this.state, this.config);
if (result.success) this.state.auditedFacts = result.data;
return result;
},
);
// Step 3: Strategic analysis
await this.runStep("03-strategize", "Strategic Analysis", async () => {
const result = await executeStrategize(this.state, this.config);
if (result.success) {
this.state.briefingSummary = result.data.briefingSummary;
this.state.designVision = result.data.designVision;
}
return result;
});
// Step 4: Sitemap architecture
await this.runStep("04-architect", "Information Architecture", async () => {
const result = await executeArchitect(this.state, this.config);
if (result.success) {
this.state.sitemap = result.data.sitemap;
this.state.websiteTopic = result.data.websiteTopic;
}
return result;
});
const projectConcept = this.buildProjectConcept();
await this.saveState(projectConcept);
return projectConcept;
}
/**
* Run a single step with callbacks and error handling.
*/
private async runStep(
stepId: string,
stepName: string,
executor: () => Promise<StepResult>,
): Promise<void> {
this.callbacks.onStepStart?.(stepId, stepName);
console.log(`\n📍 ${stepName}...`);
try {
const result = await executor();
if (result.usage) {
this.state.usage.perStep.push(result.usage);
this.state.usage.totalPromptTokens += result.usage.promptTokens;
this.state.usage.totalCompletionTokens += result.usage.completionTokens;
this.state.usage.totalCost += result.usage.cost;
}
if (result.success) {
const cost = result.usage?.cost
? ` ($${result.usage.cost.toFixed(4)})`
: "";
const duration = result.usage?.durationMs
? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]`
: "";
console.log(`${stepName} complete${cost}${duration}`);
this.callbacks.onStepComplete?.(stepId, result);
} else {
console.error(`${stepName} failed: ${result.error}`);
this.callbacks.onStepError?.(stepId, result.error || "Unknown error");
throw new Error(result.error);
}
} catch (err) {
const errorMsg = (err as Error).message;
this.callbacks.onStepError?.(stepId, errorMsg);
throw err;
}
}
/**
* Build the final Concept object.
*/
private buildProjectConcept(): ProjectConcept {
return {
domain: this.state.siteProfile?.domain || "unknown",
timestamp: new Date().toISOString(),
briefing: this.state.briefing,
auditedFacts: this.state.auditedFacts || {},
siteProfile: this.state.siteProfile,
siteAudit: this.state.siteAudit,
researchData: this.state.researchData,
strategy: {
briefingSummary: this.state.briefingSummary || "",
designVision: this.state.designVision || "",
},
architecture: {
websiteTopic: this.state.websiteTopic || "",
sitemap: this.state.sitemap || [],
},
usage: this.state.usage,
};
}
/**
* Save the full concept generated state to disk.
*/
private async saveState(concept: ProjectConcept): Promise<void> {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const companyName = this.state.auditedFacts?.companyName || "unknown";
const stateDir = path.join(this.config.outputDir, "concepts");
await fs.mkdir(stateDir, { recursive: true });
const statePath = path.join(stateDir, `${companyName}_${timestamp}.json`);
await fs.writeFile(statePath, JSON.stringify(concept, null, 2));
console.log(`\n📦 Saved Project Concept to: ${statePath}`);
// Save debug trace
const debugPath = path.join(
stateDir,
`${companyName}_${timestamp}_debug.json`,
);
await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2));
// Print usage summary
console.log("\n──────────────────────────────────────────────");
console.log("📊 PIPELINE USAGE SUMMARY");
console.log("──────────────────────────────────────────────");
for (const step of this.state.usage.perStep) {
if (step.cost > 0) {
console.log(
` ${step.step}: ${step.model}$${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`,
);
}
}
console.log("──────────────────────────────────────────────");
console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`);
console.log(
` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`,
);
console.log("──────────────────────────────────────────────\n");
}
/** Get the current internal state (for CLI inspection). */
getState(): ConceptState {
return this.state;
}
}

View File

@@ -0,0 +1,478 @@
// ============================================================================
// Scraper — Zyte API + Local Persistence
// Crawls all pages of a website, stores them locally for reuse.
// Crawls all pages of a website, stores them locally for reuse.
// ============================================================================
import * as cheerio from "cheerio";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { existsSync } from "node:fs";
import type { CrawledPage, PageType } from "./types.js";
interface ScraperConfig {
zyteApiKey?: string;
crawlDir: string;
maxPages?: number;
}
/**
* Classify a URL pathname into a page type.
*/
function classifyPage(pathname: string): PageType {
const p = pathname.toLowerCase();
if (p === "/" || p === "" || p === "/index.html") return "home";
if (
p.includes("service") ||
p.includes("leistung") ||
p.includes("kompetenz")
)
return "service";
if (
p.includes("about") ||
p.includes("ueber") ||
p.includes("über") ||
p.includes("unternehmen")
)
return "about";
if (p.includes("contact") || p.includes("kontakt")) return "contact";
if (
p.includes("job") ||
p.includes("karriere") ||
p.includes("career") ||
p.includes("human-resources")
)
return "career";
if (
p.includes("portfolio") ||
p.includes("referenz") ||
p.includes("projekt") ||
p.includes("case-study")
)
return "portfolio";
if (
p.includes("blog") ||
p.includes("news") ||
p.includes("aktuelles") ||
p.includes("magazin")
)
return "blog";
if (
p.includes("legal") ||
p.includes("impressum") ||
p.includes("datenschutz") ||
p.includes("privacy") ||
p.includes("agb")
)
return "legal";
return "other";
}
/**
* Detect interactive features present on a page.
*/
function detectFeatures($: cheerio.CheerioAPI): string[] {
const features: string[] = [];
// Search
if (
$('input[type="search"]').length > 0 ||
$('form[role="search"]').length > 0 ||
$(".search-form, .search-box, #search, .searchbar").length > 0 ||
$('input[name="q"], input[name="s"], input[name="search"]').length > 0
) {
features.push("search");
}
// Forms (beyond search)
const formCount = $("form").length;
const searchForms = $('form[role="search"], .search-form').length;
if (formCount > searchForms) {
features.push("forms");
}
// Maps
if (
$(
'iframe[src*="google.com/maps"], iframe[src*="openstreetmap"], .map-container, #map, [data-map]',
).length > 0
) {
features.push("maps");
}
// Video
if (
$("video, iframe[src*='youtube'], iframe[src*='vimeo'], .video-container")
.length > 0
) {
features.push("video");
}
// Calendar / Events
if ($(".calendar, .event, [data-calendar]").length > 0) {
features.push("calendar");
}
// Cookie consent
if (
$(".cookie-banner, .cookie-consent, #cookie-notice, [data-cookie]").length >
0
) {
features.push("cookie-consent");
}
return features;
}
/**
* Extract all internal links from a page.
*/
function extractInternalLinks($: cheerio.CheerioAPI, origin: string): string[] {
const links: string[] = [];
$("a[href]").each((_, el) => {
const href = $(el).attr("href");
if (!href) return;
try {
const url = new URL(href, origin);
if (url.origin === origin) {
// Skip assets
if (
/\.(pdf|zip|jpg|jpeg|png|svg|webp|gif|css|js|ico|woff|woff2|ttf|eot)$/i.test(
url.pathname,
)
)
return;
// Skip anchors-only
if (url.pathname === "/" && url.hash) return;
links.push(url.pathname);
}
} catch {
// Invalid URL, skip
}
});
return [...new Set(links)];
}
/**
* Extract all images from a page.
*/
function extractImages($: cheerio.CheerioAPI, origin: string): string[] {
const images: string[] = [];
// Regular img tags
$("img[src]").each((_, el) => {
const src = $(el).attr("src");
if (src) images.push(src);
});
// CSS background images (inline styles)
$("[style*='background-image']").each((_, el) => {
const style = $(el).attr("style");
const match = style?.match(/url\(['"]?(.*?)['"]?\)/);
if (match && match[1]) {
images.push(match[1]);
}
});
// Resolve URLs to absolute
const absoluteImages: string[] = [];
for (const img of images) {
if (img.startsWith("data:image")) continue; // Skip inline base64
try {
const url = new URL(img, origin);
// Ignore small tracking pixels or generic vectors
if (url.pathname.endsWith(".svg") && !url.pathname.includes("logo"))
continue;
absoluteImages.push(url.href);
} catch {
// Invalid URL
}
}
return [...new Set(absoluteImages)];
}
/**
* Fetch a page via Zyte API with browser rendering.
*/
async function fetchWithZyte(url: string, apiKey: string): Promise<string> {
const auth = Buffer.from(`${apiKey}:`).toString("base64");
const resp = await fetch("https://api.zyte.com/v1/extract", {
method: "POST",
headers: {
Authorization: `Basic ${auth}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url,
browserHtml: true,
}),
signal: AbortSignal.timeout(60000),
});
if (!resp.ok) {
const errorText = await resp.text();
console.error(
` ❌ Zyte API error ${resp.status} for ${url}: ${errorText}`,
);
// Rate limited — wait and retry once
if (resp.status === 429) {
console.log(" ⏳ Rate limited, waiting 5s and retrying...");
await new Promise((r) => setTimeout(r, 5000));
return fetchWithZyte(url, apiKey);
}
throw new Error(`HTTP ${resp.status}: ${errorText}`);
}
const data = await resp.json();
const html = data.browserHtml || "";
if (!html) {
console.warn(` ⚠️ Zyte returned empty browserHtml for ${url}`);
}
return html;
}
/**
* Fetch a page via simple HTTP GET (fallback).
*/
async function fetchDirect(url: string): Promise<string> {
const resp = await fetch(url, {
headers: {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
},
signal: AbortSignal.timeout(30000),
}).catch(() => null);
if (!resp || !resp.ok) return "";
return await resp.text();
}
/**
* Parse an HTML string into a CrawledPage.
*/
function parsePage(html: string, url: string): CrawledPage {
const $ = cheerio.load(html);
const urlObj = new URL(url);
const title = $("title").text().trim();
const headings = $("h1, h2, h3")
.map((_, el) => $(el).text().trim())
.get()
.filter((h) => h.length > 0);
const navItems = $("nav a")
.map((_, el) => $(el).text().trim())
.get()
.filter((t) => t.length > 0 && t.length < 100);
const bodyText = $("body")
.text()
.replace(/\s+/g, " ")
.substring(0, 50000)
.trim();
const features = detectFeatures($);
const links = extractInternalLinks($, urlObj.origin);
const images = extractImages($, urlObj.origin);
const description =
$('meta[name="description"]').attr("content") || undefined;
const ogTitle = $('meta[property="og:title"]').attr("content") || undefined;
const ogImage = $('meta[property="og:image"]').attr("content") || undefined;
return {
url,
pathname: urlObj.pathname,
title,
html,
text: bodyText,
headings,
navItems,
features,
type: classifyPage(urlObj.pathname),
links,
images,
meta: { description, ogTitle, ogImage },
};
}
/**
* Crawl a website and persist all pages locally.
*
* Returns an array of CrawledPage objects.
*/
export async function crawlSite(
targetUrl: string,
config: ScraperConfig,
): Promise<CrawledPage[]> {
const urlObj = new URL(targetUrl);
const origin = urlObj.origin;
const domain = urlObj.hostname;
const domainDir = path.join(config.crawlDir, domain.replace(/\./g, "-"));
// Check for existing crawl
const metaFile = path.join(domainDir, "_crawl_meta.json");
if (existsSync(metaFile)) {
console.log(`📦 Found existing crawl for ${domain}. Loading from disk...`);
return loadCrawlFromDisk(domainDir);
}
console.log(
`🔍 Crawling ${targetUrl} via ${config.zyteApiKey ? "Zyte API" : "direct HTTP"}...`,
);
// Ensure output dir
await fs.mkdir(domainDir, { recursive: true });
const maxPages = config.maxPages || 30;
const visited = new Set<string>();
const queue: string[] = [targetUrl];
const pages: CrawledPage[] = [];
while (queue.length > 0 && visited.size < maxPages) {
const url = queue.shift()!;
const urlPath = new URL(url).pathname;
if (visited.has(urlPath)) continue;
visited.add(urlPath);
try {
console.log(` ↳ Fetching ${url} (${visited.size}/${maxPages})...`);
let html: string;
if (config.zyteApiKey) {
html = await fetchWithZyte(url, config.zyteApiKey);
} else {
html = await fetchDirect(url);
}
if (!html || html.length < 100) {
console.warn(` ⚠️ Empty/tiny response for ${url}, skipping.`);
continue;
}
const page = parsePage(html, url);
pages.push(page);
// Save HTML + metadata to disk
const safeName =
urlPath === "/"
? "index"
: urlPath.replace(/\//g, "_").replace(/^_/, "");
await fs.writeFile(path.join(domainDir, `${safeName}.html`), html);
await fs.writeFile(
path.join(domainDir, `${safeName}.meta.json`),
JSON.stringify(
{
url: page.url,
pathname: page.pathname,
title: page.title,
type: page.type,
headings: page.headings,
navItems: page.navItems,
features: page.features,
links: page.links,
images: page.images,
meta: page.meta,
},
null,
2,
),
);
// Discover new links
for (const link of page.links) {
if (!visited.has(link)) {
const fullUrl = `${origin}${link}`;
queue.push(fullUrl);
}
}
} catch (err) {
console.warn(` ⚠️ Failed to fetch ${url}: ${(err as Error).message}`);
}
}
// Save crawl metadata
await fs.writeFile(
metaFile,
JSON.stringify(
{
domain,
crawledAt: new Date().toISOString(),
totalPages: pages.length,
urls: pages.map((p) => p.url),
},
null,
2,
),
);
console.log(
`✅ Crawled ${pages.length} pages for ${domain}. Saved to ${domainDir}`,
);
return pages;
}
/**
* Load a previously crawled site from disk.
*/
async function loadCrawlFromDisk(domainDir: string): Promise<CrawledPage[]> {
const files = await fs.readdir(domainDir);
const metaFiles = files.filter(
(f) => f.endsWith(".meta.json") && f !== "_crawl_meta.json",
);
const pages: CrawledPage[] = [];
for (const metaFile of metaFiles) {
const baseName = metaFile.replace(".meta.json", "");
const htmlFile = `${baseName}.html`;
const meta = JSON.parse(
await fs.readFile(path.join(domainDir, metaFile), "utf8"),
);
let html = "";
if (files.includes(htmlFile)) {
html = await fs.readFile(path.join(domainDir, htmlFile), "utf8");
}
const text = html
? cheerio
.load(html)("body")
.text()
.replace(/\s+/g, " ")
.substring(0, 50000)
.trim()
: "";
pages.push({
url: meta.url,
pathname: meta.pathname,
title: meta.title,
html,
text,
headings: meta.headings || [],
navItems: meta.navItems || [],
features: meta.features || [],
type: meta.type || "other",
links: meta.links || [],
images: meta.images || [],
meta: meta.meta || {},
});
}
console.log(` 📂 Loaded ${pages.length} cached pages from disk.`);
return pages;
}
/**
* Delete a cached crawl to force re-crawl.
*/
export async function clearCrawlCache(
crawlDir: string,
domain: string,
): Promise<void> {
const domainDir = path.join(crawlDir, domain.replace(/\./g, "-"));
if (existsSync(domainDir)) {
await fs.rm(domainDir, { recursive: true, force: true });
console.log(`🧹 Cleared crawl cache for ${domain}`);
}
}

View File

@@ -0,0 +1,65 @@
// ============================================================================
// Step 00a: Site Audit (DataForSEO + AI)
// ============================================================================
import { PageAuditor } from "@mintel/page-audit";
import type { ConceptState, StepResult, PipelineConfig } from "../types.js";
export async function executeSiteAudit(
state: ConceptState,
config: PipelineConfig,
): Promise<StepResult> {
const startTime = Date.now();
if (!state.url) {
return {
success: true,
data: null,
usage: { step: "00a-site-audit", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: Date.now() - startTime },
};
}
try {
const login = process.env.DATA_FOR_SEO_LOGIN || process.env.DATA_FOR_SEO_API_KEY?.split(":")?.[0];
const password = process.env.DATA_FOR_SEO_PASSWORD || process.env.DATA_FOR_SEO_API_KEY?.split(":")?.slice(1)?.join(":");
if (!login || !password) {
console.warn(" ⚠️ Site Audit skipped: DataForSEO credentials missing from environment.");
return {
success: true,
data: null,
usage: { step: "00a-site-audit", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: Date.now() - startTime },
};
}
const auditor = new PageAuditor({
dataForSeoLogin: login,
dataForSeoPassword: password,
openrouterKey: config.openrouterKey,
outputDir: config.outputDir ? `${config.outputDir}/audits` : undefined,
});
// Run audit (max 20 pages for the estimation phase to keep it fast)
const result = await auditor.audit(state.url, { maxPages: 20 });
return {
success: true,
data: result,
usage: {
step: "00a-site-audit",
model: "dataforseo",
cost: 0, // DataForSEO cost tracking could be added later
promptTokens: 0,
completionTokens: 0,
durationMs: Date.now() - startTime,
},
};
} catch (err: any) {
console.warn(` ⚠️ Site Audit failed, skipping: ${err.message}`);
return {
success: true,
data: null,
usage: { step: "00a-site-audit", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: Date.now() - startTime },
};
}
}

View File

@@ -0,0 +1,121 @@
// ============================================================================
// Step 00b: Research — Industry Research via @mintel/journaling (No LLM hallus)
// Uses Serper API for real web search results about the industry/company.
// ============================================================================
import type { ConceptState, StepResult } from "../types.js";
interface ResearchResult {
companyContext: string[];
industryInsights: string[];
competitorInfo: string[];
}
/**
* Research the company and industry using real web search data.
* Uses @mintel/journaling's ResearchAgent — results are grounded in real sources.
*
* NOTE: The journaling package can cause unhandled rejections that crash the process.
* We wrap each call in an additional safety layer.
*/
export async function executeResearch(
state: ConceptState,
): Promise<StepResult<ResearchResult>> {
const startTime = Date.now();
const companyName = state.siteProfile?.companyInfo?.name || "";
const websiteTopic = state.siteProfile?.services?.slice(0, 3).join(", ") || "";
const domain = state.siteProfile?.domain || "";
if (!companyName && !websiteTopic && !domain) {
return {
success: true,
data: { companyContext: [], industryInsights: [], competitorInfo: [] },
usage: { step: "00b-research", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: 0 },
};
}
// Safety wrapper: catch ANY unhandled rejections during this step
const safeCall = <T>(fn: () => Promise<T>, fallback: T): Promise<T> => {
return new Promise<T>((resolve) => {
const handler = (err: any) => {
console.warn(` ⚠️ Unhandled rejection caught in research: ${err?.message || err}`);
process.removeListener("unhandledRejection", handler);
resolve(fallback);
};
process.on("unhandledRejection", handler);
fn()
.then((result) => {
process.removeListener("unhandledRejection", handler);
resolve(result);
})
.catch((err) => {
process.removeListener("unhandledRejection", handler);
console.warn(` ⚠️ Research call failed: ${err?.message || err}`);
resolve(fallback);
});
});
};
try {
const { ResearchAgent } = await import("@mintel/journaling");
const agent = new ResearchAgent(process.env.OPENROUTER_API_KEY || "");
const results: ResearchResult = {
companyContext: [],
industryInsights: [],
competitorInfo: [],
};
// 1. Research the company itself
if (companyName || domain) {
const searchQuery = companyName
? `${companyName} ${websiteTopic} Unternehmen`
: `site:${domain}`;
console.log(` 🔍 Researching: "${searchQuery}"...`);
const facts = await safeCall(
() => agent.researchTopic(searchQuery),
[] as any[],
);
results.companyContext = (facts || [])
.filter((f: any) => f?.fact || f?.value || f?.text || f?.statement)
.map((f: any) => f.fact || f.value || f.text || f.statement)
.slice(0, 5);
}
// 2. Industry research
if (websiteTopic) {
console.log(` 🔍 Researching industry: "${websiteTopic}"...`);
const insights = await safeCall(
() => agent.researchCompetitors(websiteTopic),
[] as any[],
);
results.industryInsights = (insights || []).slice(0, 5);
}
const totalFacts = results.companyContext.length + results.industryInsights.length + results.competitorInfo.length;
console.log(` 📊 Research found ${totalFacts} data points.`);
return {
success: true,
data: results,
usage: {
step: "00b-research",
model: "serper/datacommons",
promptTokens: 0,
completionTokens: 0,
cost: 0,
durationMs: Date.now() - startTime,
},
};
} catch (err) {
console.warn(` ⚠️ Research step skipped: ${(err as Error).message}`);
return {
success: true,
data: { companyContext: [], industryInsights: [], competitorInfo: [] },
usage: { step: "00b-research", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: Date.now() - startTime },
};
}
}

View File

@@ -0,0 +1,108 @@
// ============================================================================
// Step 01: Extract — Briefing Fact Extraction (Gemini Flash)
// ============================================================================
import { llmJsonRequest } from "../llm-client.js";
import type { ConceptState, StepResult, PipelineConfig } from "../types.js";
import { DEFAULT_MODELS } from "../types.js";
export async function executeExtract(
state: ConceptState,
config: PipelineConfig,
): Promise<StepResult> {
const models = { ...DEFAULT_MODELS, ...config.modelsOverride };
const startTime = Date.now();
// Build site context from the deterministic analyzer
const siteContext = state.siteProfile
? `
EXISTING WEBSITE ANALYSIS (FACTS — verifiably crawled, NOT guessed):
- Domain: ${state.siteProfile.domain}
- Total pages crawled: ${state.siteProfile.totalPages}
- Navigation items: ${state.siteProfile.navigation.map((n) => n.label).join(", ") || "nicht erkannt"}
- Existing features: ${state.siteProfile.existingFeatures.join(", ") || "keine"}
- Services / Kompetenzen: ${state.siteProfile.services.join(" | ") || "keine"}
- Employee count (from website text): ${(state.siteProfile as any).employeeCount || "nicht genannt"}
- Company name: ${state.siteProfile.companyInfo.name || "unbekannt"}
- Address: ${state.siteProfile.companyInfo.address || "unbekannt"}
- Tax ID (USt-ID): ${state.siteProfile.companyInfo.taxId || "unbekannt"}
- HRB: ${state.siteProfile.companyInfo.registerNumber || "unbekannt"}
- Managing Director: ${state.siteProfile.companyInfo.managingDirector || "unbekannt"}
- External related domains (HAVE OWN WEBSITES — DO NOT include as sub-pages!): ${state.siteProfile.externalDomains.join(", ") || "keine"}
- Social links: ${Object.entries(state.siteProfile.socialLinks).map(([k, v]) => `${k}: ${v}`).join(", ") || "keine"}
`
: "No existing website data available.";
const systemPrompt = `
You are a precision fact extractor. Your only job: extract verifiable facts from the BRIEFING.
Output language: GERMAN (strict).
Output format: flat JSON at root level. No nesting except arrays.
### CRITICAL RULES:
1. "employeeCount": take from SITE ANALYSIS if available. Only override if briefing states something more specific.
2. External domains (e.g. "etib-ing.com") have their OWN website. NEVER include them as sub-pages.
3. Videos (Messefilm, Imagefilm) are CONTENT ASSETS, not pages.
4. If existing site already has search, include "search" in functions.
5. DO NOT invent pages not mentioned in briefing or existing navigation.
### CONSERVATIVE RULE:
- simple lists (Jobs, Referenzen, Messen) = pages, NOT features
- Assume "page" as default. Only add "feature" for complex interactive systems.
### OUTPUT FORMAT:
{
"companyName": string,
"companyAddress": string,
"personName": string,
"email": string,
"existingWebsite": string,
"websiteTopic": string, // MAX 3 words
"isRelaunch": boolean,
"employeeCount": string, // from site analysis, e.g. "über 50"
"pages": string[], // ALL pages: ["Startseite", "Über Uns", "Leistungen", ...]
"functions": string[], // search, forms, maps, video, cookie_consent, etc.
"assets": string[], // existing_website, logo, media, photos, videos
"deadline": string,
"targetAudience": string,
"cmsSetup": boolean,
"multilang": boolean
}
BANNED OUTPUT KEYS: "selectedPages", "otherPages", "features", "apiSystems" — use pages[] and functions[] ONLY.
`;
const userPrompt = `BRIEFING (TRUTH SOURCE):
${state.briefing}
COMMENTS:
${state.comments || "keine"}
${siteContext}`;
try {
const { data, usage } = await llmJsonRequest({
model: models.flash,
systemPrompt,
userPrompt,
apiKey: config.openrouterKey,
});
return {
success: true,
data,
usage: {
step: "01-extract",
model: models.flash,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
cost: usage.cost,
durationMs: Date.now() - startTime,
},
};
} catch (err) {
return {
success: false,
error: `Extract step failed: ${(err as Error).message}`,
};
}
}

View File

@@ -0,0 +1,110 @@
// ============================================================================
// Step 02: Audit — Feature Auditor + Skeptical Review (Gemini Flash)
// ============================================================================
import { llmJsonRequest } from "../llm-client.js";
import type { ConceptState, StepResult, PipelineConfig } from "../types.js";
import { DEFAULT_MODELS } from "../types.js";
export async function executeAudit(
state: ConceptState,
config: PipelineConfig,
): Promise<StepResult> {
const models = { ...DEFAULT_MODELS, ...config.modelsOverride };
const startTime = Date.now();
if (!state.facts) {
return { success: false, error: "No facts from Step 01 available." };
}
const systemPrompt = `
You are a "Strict Cost Controller". Your mission is to prevent over-billing.
Review the extracted FEATURES against the BRIEFING and the EXISTING SITE ANALYSIS.
### RULE OF THUMB:
- A "Feature" (1.500 €) is ONLY justified for complex, dynamic systems (logic, database, CMS-driven management, advanced filtering).
- Simple lists, information sections, or static descriptions (e.g., "Messen", "Team", "Historie", "Jobs" as mere text) are ALWAYS "Pages" (600 €).
- If the briefing doesn't explicitly mention "Management System", "Filterable Database", or "Client Login", it is a PAGE.
### ADDITIONAL CHECKS:
1. If any feature maps to an entity that has its own external website (listed in EXTERNAL_DOMAINS), remove it entirely — it's out of scope.
2. Videos are ASSETS not pages. Remove any video-related entries from pages.
3. If the existing site has features (search, forms, etc.), ensure they are in the functions list.
### MISSION:
Return the corrected 'features', 'otherPages', and 'functions' arrays.
### OUTPUT FORMAT:
{
"features": string[],
"otherPages": string[],
"functions": string[],
"removedItems": [{ "item": string, "reason": string }],
"addedItems": [{ "item": string, "reason": string }]
}
`;
const userPrompt = `
EXTRACTED FACTS:
${JSON.stringify(state.facts, null, 2)}
BRIEFING:
${state.briefing}
EXTERNAL DOMAINS (have own websites, OUT OF SCOPE):
${state.siteProfile?.externalDomains?.join(", ") || "none"}
EXISTING FEATURES ON CURRENT SITE:
${state.siteProfile?.existingFeatures?.join(", ") || "none"}
`;
try {
const { data, usage } = await llmJsonRequest({
model: models.flash,
systemPrompt,
userPrompt,
apiKey: config.openrouterKey,
});
// Apply audit results to facts
const auditedFacts = { ...state.facts };
auditedFacts.features = data.features || [];
auditedFacts.otherPages = [
...new Set([...(auditedFacts.otherPages || []), ...(data.otherPages || [])]),
];
if (data.functions) {
auditedFacts.functions = [
...new Set([...(auditedFacts.functions || []), ...data.functions]),
];
}
// Log changes
if (data.removedItems?.length) {
console.log(" 📉 Audit removed:");
for (const item of data.removedItems) {
console.log(` - ${item.item}: ${item.reason}`);
}
}
if (data.addedItems?.length) {
console.log(" 📈 Audit added:");
for (const item of data.addedItems) {
console.log(` + ${item.item}: ${item.reason}`);
}
}
return {
success: true,
data: auditedFacts,
usage: {
step: "02-audit",
model: models.flash,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
cost: usage.cost,
durationMs: Date.now() - startTime,
},
};
} catch (err) {
return { success: false, error: `Audit step failed: ${(err as Error).message}` };
}
}

View File

@@ -0,0 +1,99 @@
// ============================================================================
// Step 03: Strategize — Briefing Summary + Design Vision (Gemini Pro)
// ============================================================================
import { llmJsonRequest } from "../llm-client.js";
import type { ConceptState, StepResult, PipelineConfig } from "../types.js";
import { DEFAULT_MODELS } from "../types.js";
export async function executeStrategize(
state: ConceptState,
config: PipelineConfig,
): Promise<StepResult> {
const models = { ...DEFAULT_MODELS, ...config.modelsOverride };
const startTime = Date.now();
if (!state.auditedFacts) {
return { success: false, error: "No audited facts from Step 02 available." };
}
const systemPrompt = `
You are a high-end Digital Architect. Your goal is to make the CUSTOMER feel 100% understood.
Analyze the BRIEFING and the EXISTING WEBSITE context.
### OBJECTIVE:
1. **briefingSummary**: Ein sachlicher, tiefgehender Überblick der Unternehmenslage.
- STIL: Keine Ich-Form. Keine Marketing-Floskeln. Nutze präzise Fachbegriffe. Sei prägnant.
- FORM: EXAKT ZWEI ABSÄTZE. Insgesamt ca. 6 Sätze.
- INHALT: Status Quo, was der Kunde will, welcher Sprung notwendig ist.
- ABSOLUTE REGEL: Keine Halluzinationen. Keine namentlichen Nennungen von Personen.
- RELAUNCH-REGEL: Wenn isRelaunch=true, NICHT sagen "keine digitale Präsenz". Es GIBT eine Seite.
- SORGLOS BETRIEB: MUSS erwähnt werden als Teil des Gesamtpakets.
2. **designVision**: Ein abstraktes, strategisches Konzept.
- STIL: Rein konzeptionell. Keine Umsetzungsschritte. Keine Ich-Form. Sei prägnant.
- FORM: EXAKT ZWEI ABSÄTZE. Insgesamt ca. 4 Sätze.
- DATENSCHUTZ: KEINERLEI namentliche Nennungen.
- FOKUS: Welche strategische Wirkung soll erzielt werden?
### RULES:
- NO "wir/unser". NO "Ich/Mein". Objective, fact-oriented narrative.
- NO marketing lingo. NO "innovativ", "revolutionär", "state-of-the-art".
- NO hallucinations about features not in the briefing.
- NO "SEO-Standards zur Fachkräftesicherung" or "B2B-Nutzerströme" — das ist Schwachsinn.
Use specific industry terms from the briefing (e.g. "Kabeltiefbau", "HDD-Bohrverfahren").
- LANGUAGE: Professional German. Simple but expert-level.
### OUTPUT FORMAT:
{
"briefingSummary": string,
"designVision": string
}
`;
const userPrompt = `
BRIEFING (TRUTH SOURCE):
${state.briefing}
EXISTING WEBSITE DATA:
- Services: ${state.siteProfile?.services?.join(", ") || "unbekannt"}
- Navigation: ${state.siteProfile?.navigation?.map((n) => n.label).join(", ") || "unbekannt"}
- Company: ${state.auditedFacts.companyName || "unbekannt"}
EXTRACTED & AUDITED FACTS:
${JSON.stringify(state.auditedFacts, null, 2)}
${state.siteAudit?.report ? `
TECHNICAL SITE AUDIT (IST-Analyse):
Health: ${state.siteAudit.report.overallHealth} (SEO: ${state.siteAudit.report.seoScore}, UX: ${state.siteAudit.report.uxScore}, Perf: ${state.siteAudit.report.performanceScore})
- Executive Summary: ${state.siteAudit.report.executiveSummary}
- Strengths: ${state.siteAudit.report.strengths.join(", ")}
- Critical Issues: ${state.siteAudit.report.criticalIssues.join(", ")}
- Quick Wins: ${state.siteAudit.report.quickWins.join(", ")}
` : ""}
`;
try {
const { data, usage } = await llmJsonRequest({
model: models.pro,
systemPrompt,
userPrompt,
apiKey: config.openrouterKey,
});
return {
success: true,
data,
usage: {
step: "03-strategize",
model: models.pro,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
cost: usage.cost,
durationMs: Date.now() - startTime,
},
};
} catch (err) {
return { success: false, error: `Strategize step failed: ${(err as Error).message}` };
}
}

View File

@@ -0,0 +1,133 @@
// ============================================================================
// Step 04: Architect — Sitemap & Information Architecture (Gemini Pro)
// ============================================================================
import { llmJsonRequest } from "../llm-client.js";
import type { ConceptState, StepResult, PipelineConfig } from "../types.js";
import { DEFAULT_MODELS } from "../types.js";
export async function executeArchitect(
state: ConceptState,
config: PipelineConfig,
): Promise<StepResult> {
const models = { ...DEFAULT_MODELS, ...config.modelsOverride };
const startTime = Date.now();
if (!state.auditedFacts) {
return { success: false, error: "No audited facts available." };
}
// Build navigation constraint from the real site
const existingNav = state.siteProfile?.navigation?.map((n) => n.label).join(", ") || "unbekannt";
const existingServices = state.siteProfile?.services?.join(", ") || "unbekannt";
const externalDomains = state.siteProfile?.externalDomains?.join(", ") || "keine";
const systemPrompt = `
Du bist ein Senior UX Architekt. Erstelle einen ECHTEN SEITENBAUM für die neue Website.
Regelwerk für den Output:
### SEITENBAUM-REGELN:
1. KEIN MARKETINGSPRECH als Kategoriename. Gültige Kategorien sind nur die echten Navigationspunkte der Website.
ERLAUBT: "Startseite", "Leistungen", "Über uns", "Karriere", "Referenzen", "Kontakt", "Rechtliches"
VERBOTEN: "Kern-Präsenz", "Vertrauen", "Business Areas", "Digitaler Auftritt"
2. LEISTUNGEN muss in ECHTE UNTERSEITEN aufgeteilt werden — nicht eine einzige "Leistungen"-Seite.
Jede Kompetenz aus dem existierenden Leistungsspektrum = eine eigene Seite.
Beispiel statt:
{ category: "Leistungen", pages: [{ title: "Leistungen", desc: "..." }] }
So:
{ category: "Leistungen", pages: [
{ title: "Kabeltiefbau", desc: "Mittelspannung, Niederspannung, Kabelpflugarbeiten..." },
{ title: "Horizontalspülbohrungen", desc: "HDD in allen Bodenklassen..." },
{ title: "Elektromontagen", desc: "Bis 110 kV, Glasfaserkabelmontagen..." },
{ title: "Planung & Dokumentation", desc: "Genehmigungs- und Ausführungsplanung, Vermessung..." }
]}
3. SEITENTITEL: Kurz, klar, faktisch. Kein Werbejargon.
ERLAUBT: "Kabeltiefbau", "Über uns", "Karriere"
VERBOTEN: "Unsere Expertise", "Kompetenzspektrum", "Community"
4. Gruppe die Leistungen nach dem ECHTEN Kompetenzkatalog der bestehenden Site — nicht erfinden.
5. Keine doppelten Seiten. Keine Phantomseiten.
6. Videos = Content-Assets, keine eigene Seite.
7. Entitäten mit eigener Domain (${externalDomains}) = NICHT als Seite. Nur als Teaser/Link wenn nötig.
### KONTEXT:
Bestehende Navigation: ${existingNav}
Bestehende Services: ${existingServices}
Externe Domains (haben eigene Website): ${externalDomains}
Angeforderte zusätzliche Seiten aus Briefing: ${(state.auditedFacts as any)?.pages?.join(", ") || "keine spezifischen"}
### OUTPUT FORMAT (JSON):
{
"websiteTopic": string, // MAX 3 Wörter, beschreibend
"sitemap": [
{
"category": string, // Echter Nav-Eintrag. KEIN Marketingsprech.
"pages": [
{ "title": string, "desc": string } // Echte Unterseite, 1-2 Sätze Zweck
]
}
]
}
`;
const userPrompt = `
BRIEFING:
${state.briefing}
FAKTEN (aus Extraktion):
${JSON.stringify({ facts: state.auditedFacts, strategy: { briefingSummary: state.briefingSummary } }, null, 2)}
Erstelle den Seitenbaum. Baue die Leistungen DETAILLIERT aus — echte Unterseiten pro Kompetenzbereich.
`;
try {
const { data, usage } = await llmJsonRequest({
model: models.pro,
systemPrompt,
userPrompt,
apiKey: config.openrouterKey,
});
// Normalize sitemap structure
let sitemap = data.sitemap;
if (sitemap && !Array.isArray(sitemap)) {
if (sitemap.categories) sitemap = sitemap.categories;
else {
const entries = Object.entries(sitemap);
if (entries.every(([, v]) => Array.isArray(v))) {
sitemap = entries.map(([category, pages]) => ({ category, pages }));
}
}
}
if (Array.isArray(sitemap)) {
sitemap = sitemap.map((cat: any) => ({
category: cat.category || cat.kategorie || cat.Kategorie || "Allgemein",
pages: (cat.pages || cat.seiten || []).map((page: any) => ({
title: page.title || page.titel || "Seite",
desc: page.desc || page.beschreibung || page.description || "",
})),
}));
}
return {
success: true,
data: { websiteTopic: data.websiteTopic, sitemap },
usage: {
step: "04-architect",
model: models.pro,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
cost: usage.cost,
durationMs: Date.now() - startTime,
},
};
} catch (err) {
return { success: false, error: `Architect step failed: ${(err as Error).message}` };
}
}

View File

@@ -0,0 +1,233 @@
// ============================================================================
// @mintel/concept-engine — Core Type Definitions
// ============================================================================
/** Page types recognized during crawling */
export type PageType =
| "home"
| "service"
| "about"
| "contact"
| "career"
| "portfolio"
| "blog"
| "legal"
| "other";
/** A single crawled page with extracted metadata */
export interface CrawledPage {
url: string;
pathname: string;
title: string;
html: string;
text: string;
headings: string[];
navItems: string[];
features: string[];
type: PageType;
links: string[];
images: string[];
meta: {
description?: string;
ogTitle?: string;
ogImage?: string;
};
}
/** Navigation item extracted from <nav> elements */
export interface NavItem {
label: string;
href: string;
children?: NavItem[];
}
/** Company info extracted from Impressum / footer */
export interface CompanyInfo {
name?: string;
address?: string;
phone?: string;
email?: string;
taxId?: string;
registerNumber?: string;
managingDirector?: string;
}
/** A page in the site inventory */
export interface PageInventoryItem {
url: string;
pathname: string;
title: string;
type: PageType;
headings: string[];
services: string[];
hasSearch: boolean;
hasForms: boolean;
hasMap: boolean;
hasVideo: boolean;
contentSummary: string;
}
/** Full site profile — deterministic, no LLM involved */
export interface SiteProfile {
domain: string;
crawledAt: string;
totalPages: number;
navigation: NavItem[];
existingFeatures: string[];
services: string[];
companyInfo: CompanyInfo;
pageInventory: PageInventoryItem[];
colors: string[];
socialLinks: Record<string, string>;
externalDomains: string[];
images: string[];
employeeCount: string | null;
}
/** Configuration for the estimation pipeline */
export interface PipelineConfig {
openrouterKey: string;
zyteApiKey?: string;
outputDir: string;
crawlDir: string;
modelsOverride?: Partial<ModelConfig>;
}
/** Model routing configuration */
export interface ModelConfig {
flash: string;
pro: string;
opus: string;
}
export const DEFAULT_MODELS: ModelConfig = {
flash: "google/gemini-3-flash-preview",
pro: "google/gemini-3.1-pro-preview",
opus: "anthropic/claude-opus-4-6",
};
/** Input for a pipeline run */
export interface PipelineInput {
briefing: string;
url?: string;
budget?: string;
comments?: string;
clearCache?: boolean;
}
/** State that flows through all concept pipeline steps */
export interface ConceptState {
// Input
briefing: string;
url?: string;
comments?: string;
// Output: Scrape & Analyze
siteProfile?: SiteProfile;
crawlDir?: string;
// Output: Site Audit
siteAudit?: any;
// Output: Research
researchData?: any;
// Output: Extract
facts?: Record<string, any>;
// Output: Audit
auditedFacts?: Record<string, any>;
// Output: Strategy
briefingSummary?: string;
designVision?: string;
// Output: Architecture
sitemap?: SitemapCategory[];
websiteTopic?: string;
// Cost tracking
usage: UsageStats;
}
/** Final output of the Concept Engine */
export interface ProjectConcept {
domain: string;
timestamp: string;
briefing: string;
auditedFacts: Record<string, any>;
siteProfile?: SiteProfile;
siteAudit?: any;
researchData?: any;
strategy: {
briefingSummary: string;
designVision: string;
};
architecture: {
websiteTopic: string;
sitemap: SitemapCategory[];
};
usage: UsageStats;
}
export interface SitemapCategory {
category: string;
pages: { title: string; desc: string }[];
}
export interface UsageStats {
totalPromptTokens: number;
totalCompletionTokens: number;
totalCost: number;
perStep: StepUsage[];
}
export interface StepUsage {
step: string;
model: string;
promptTokens: number;
completionTokens: number;
cost: number;
durationMs: number;
}
/** Result of a single pipeline step */
export interface StepResult<T = any> {
success: boolean;
data?: T;
error?: string;
usage?: StepUsage;
}
/** Validation result from the deterministic validator */
export interface ValidationResult {
passed: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
}
export interface ValidationError {
code: string;
message: string;
field?: string;
expected?: any;
actual?: any;
}
export interface ValidationWarning {
code: string;
message: string;
suggestion?: string;
}
/** Step definition for the concept pipeline */
export interface PipelineStep {
id: string;
name: string;
description: string;
model: "flash" | "pro" | "opus" | "none";
execute: (
state: ConceptState,
config: PipelineConfig,
) => Promise<StepResult>;
}

View File

@@ -0,0 +1,28 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"lib": [
"ES2022",
"DOM"
],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts"
]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/cli.ts"],
format: ["esm"],
dts: true,
clean: true,
target: "es2022",
});

View File

@@ -1,7 +1,7 @@
{
"name": "@mintel/content-engine",
"version": "1.8.16",
"private": true,
"version": "1.9.8",
"private": false,
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -30,5 +30,9 @@
"@types/node": "^20.0.0",
"tsup": "^8.3.5",
"typescript": "^5.0.0"
},
"repository": {
"type": "git",
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
}
}

View File

@@ -52,11 +52,11 @@ interface Insertion {
// Model configuration: specialized models for different tasks
const MODELS = {
// Structured JSON output, research planning, diagram models: {
STRUCTURED: "google/gemini-2.5-flash",
ROUTING: "google/gemini-2.5-flash",
CONTENT: "google/gemini-2.5-pro",
STRUCTURED: "google/gemini-3-flash-preview",
ROUTING: "google/gemini-3-flash-preview",
CONTENT: "google/gemini-3.1-pro-preview",
// Mermaid diagram generation - User requested Pro
DIAGRAM: "google/gemini-2.5-pro",
DIAGRAM: "google/gemini-3.1-pro-preview",
} as const;
/** Strip markdown fences that some models wrap around JSON despite response_format */
@@ -831,12 +831,12 @@ Return ONLY the JSON.`,
const componentsContext =
components.length > 0
? `\n\nAvailable Components:\n` +
components
.map(
(c) =>
`- <${c.name}>: ${c.description}\n Example: ${c.usageExample}`,
)
.join("\n")
components
.map(
(c) =>
`- <${c.name}>: ${c.description}\n Example: ${c.usageExample}`,
)
.join("\n")
: "";
const response = await this.openai.chat.completions.create({

View File

@@ -17,6 +17,7 @@ export interface OptimizationTask {
availableComponents?: ComponentDefinition[];
instructions?: string;
internalLinks?: { title: string; slug: string }[];
customSources?: string[];
}
export interface OptimizeFileOptions {
@@ -211,7 +212,47 @@ export class AiBlogPostOrchestrator {
console.log(`✅ Saved optimized file to: ${finalPath}`);
}
private async generateVisualPrompt(content: string): Promise<string> {
async generateSlug(
content: string,
title?: string,
instructions?: string,
): Promise<string> {
const response = await this.openai.chat.completions.create({
model: "google/gemini-3-flash-preview",
messages: [
{
role: "system",
content: `You generate SEO-optimized URL slugs for B2B blog posts based on the provided content.
Return ONLY a JSON object with a single string field "slug".
Example: {"slug": "how-to-optimize-react-performance"}
Rules: Use lowercase letters, numbers, and hyphens only. No special characters. Keep it concise (2-5 words).`,
},
{
role: "user",
content: `Title: ${title || "Unknown"}\n\nContent:\n${content.slice(0, 3000)}...${instructions ? `\n\nEDITOR INSTRUCTIONS:\nPlease strictly follow these instructions from the editor when generating the slug:\n${instructions}` : ""}`,
},
],
response_format: { type: "json_object" },
});
try {
const parsed = JSON.parse(
response.choices[0].message.content || '{"slug": ""}',
);
const slug = parsed.slug || "new-post";
return slug
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
} catch {
return "new-post";
}
}
public async generateVisualPrompt(
content: string,
instructions?: string,
): Promise<string> {
const response = await this.openai.chat.completions.create({
model: this.model,
messages: [
@@ -227,7 +268,10 @@ FOCUS: The core metaphor or technical concept of the article.
Example output: "A complex network of glowing fiber optic nodes forming a recursive pyramid structure, technical blue lineart style."`,
},
{ role: "user", content: content.slice(0, 5000) },
{
role: "user",
content: `${content.slice(0, 5000)}${instructions ? `\n\nEDITOR INSTRUCTIONS:\nPlease strictly follow these instructions from the editor when generating the visual prompt:\n${instructions}` : ""}`,
},
],
max_tokens: 100,
});
@@ -303,6 +347,7 @@ Example output: "A complex network of glowing fiber optic nodes forming a recurs
);
const realPosts = await this.researchAgent.fetchRealSocialPosts(
task.content.slice(0, 500),
task.customSources,
);
socialPosts.push(...realPosts);
}
@@ -320,7 +365,7 @@ Example output: "A complex network of glowing fiber optic nodes forming a recurs
private async identifyTopics(content: string): Promise<string[]> {
const response = await this.openai.chat.completions.create({
model: "google/gemini-2.5-flash", // fast structured model for topic extraction
model: "google/gemini-3-flash-preview", // fast structured model for topic extraction
messages: [
{
role: "system",
@@ -470,7 +515,6 @@ BLOG POST BEST PRACTICES (MANDATORY):
- MEME DIVERSITY: Du MUSST ZWINGEND für jedes Meme (sofern passend) abwechslungsreiche Templates nutzen. Um dies zu garantieren, wurde für diesen Artikel das folgende Template ausgewählt: '${forcedMeme}'. Du MUSST EXAKT DIESES TEMPLATE NUTZEN. Versuche nicht, es durch ein Standard-Template wie 'drake' zu ersetzen!
- Zitat-Varianten: Wenn du Organisationen oder Studien zitierst, nutze ArticleQuote (mit isCompany=true für Firmen). Für Personen lass isCompany weg.
- Füge zwingend ein prägnantes 'TL;DR' ganz am Anfang ein.
- Füge ein sauberes TableOfContents ein.
- Verwende unsere Komponenten stilvoll für Visualisierungen.
- Agiere als hochprofessioneller Digital Architect und entferne alte MDX-Metadaten im Body.
- Fazit: Schließe JEDEN Artikel ZWINGEND mit einem starken, klaren 'Fazit' ab.

View File

@@ -1,30 +0,0 @@
{
"name": "customer-manager",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.16",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "app",
"name": "customer manager"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"dependencies": {
"@mintel/directus-extension-toolkit": "workspace:*"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}

View File

@@ -1,14 +0,0 @@
import { defineModule } from '@directus/extensions-sdk';
import ModuleComponent from './module.vue';
export default defineModule({
id: 'customer-manager',
name: 'Customer Manager',
icon: 'supervisor_account',
routes: [
{
path: '',
component: ModuleComponent,
},
],
});

View File

@@ -1,538 +0,0 @@
<template>
<MintelManagerLayout
title="Customer Manager"
:item-title="selectedItem?.company?.name || 'Kunde wählen'"
:is-empty="!selectedItem"
empty-title="Kunde auswählen"
empty-icon="handshake"
:notice="notice"
@close-notice="notice = null"
>
<template #navigation>
<v-list nav>
<v-list-item @click="openCreateDrawer" clickable>
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow text="Neuen Kunden verlinken" />
</v-list-item-content>
</v-list-item>
<v-divider />
<v-list-item
v-for="item in items"
:key="item.id"
:active="selectedItem?.id === item.id"
class="nav-item"
clickable
@click="selectItem(item)"
>
<v-list-item-icon><v-icon name="business" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="item.company?.name" />
</v-list-item-content>
</v-list-item>
</v-list>
</template>
<template #subtitle>
<template v-if="selectedItem">
{{ clientUsers.length }} Portal-Nutzer &middot; {{ selectedItem.company?.domain }}
</template>
</template>
<template #actions>
<v-button secondary rounded icon v-tooltip.bottom="'Kunden-Verlinkung bearbeiten'" @click="openEditDrawer">
<v-icon name="edit" />
</v-button>
<div @click="onDebugClick" style="display: inline-block; border: 2px solid lime;">
<v-button primary @click="openCreateClientUser">
Portal-Nutzer hinzufügen
</v-button>
</div>
<button style="background: red; color: white; padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="onDebugClick">DEBUG</button>
<button style="background: blue; color: white; padding: 8px 16px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="openCreateClientUser">NATIVE: Portal-Nutzer</button>
</template>
<template #empty-state>
Wähle einen Kunden aus der Liste oder
<v-button x-small @click="openCreateDrawer">verlinke eine neue Firma</v-button>.
<button id="debug-click-test" style="background: red; color: white; padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="onDebugClick">DEBUG CLICK</button>
</template>
<!-- Main Content: Client Users Table -->
<v-table
:headers="tableHeaders"
:items="clientUsers"
:loading="loading"
class="clickable-table"
fixed-header
@click:row="onRowClick"
>
<template #[`item.name`]="{ item }">
<div class="user-cell">
<v-avatar :name="item.first_name" x-small />
<span class="user-name">{{ item.first_name }} {{ item.last_name }}</span>
</div>
</template>
<template #[`item.last_invited`]="{ item }">
<span v-if="item.last_invited" class="status-date">
{{ formatDate(item.last_invited) }}
</span>
<v-chip v-else x-small>Noch nie</v-chip>
</template>
</v-table>
<!-- Drawer: Customer (Link) Form -->
<v-drawer
v-model="drawerActive"
:title="isEditing ? 'Kunden-Verlinkung bearbeiten' : 'Kunden verlinken'"
icon="handshake"
@cancel="drawerActive = false"
>
<div v-if="drawerActive" class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Organisation / Firma</span>
<MintelSelect
v-model="form.company"
:items="companyOptions"
placeholder="Firma auswählen..."
allow-add
@add="openQuickAdd('company')"
/>
</div>
<div class="field">
<span class="label">Haupt-Ansprechpartner (optional)</span>
<MintelSelect
v-model="form.contact_person"
:items="peopleOptions"
placeholder="Person auswählen..."
allow-add
@add="openQuickAdd('person')"
/>
</div>
<div class="field">
<span class="label">Status</span>
<v-select
v-model="form.status"
:items="[
{ text: 'Aktiv', value: 'active' },
{ text: 'Inaktiv', value: 'inactive' }
]"
/>
</div>
<div class="field">
<span class="label">Notizen</span>
<v-textarea v-model="form.notes" placeholder="Besonderheiten zu diesem Kunden..." />
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="saving" @click="saveItem">Speichern</v-button>
</div>
</div>
</v-drawer>
<!-- Drawer: Client User Form -->
<v-drawer
v-model="drawerUserActive"
:title="isEditingUser ? 'Portal-Nutzer bearbeiten' : 'Neuen Portal-Nutzer anlegen'"
icon="person"
@cancel="drawerUserActive = false"
>
<div v-if="drawerUserActive" class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Vorname</span>
<v-input v-model="userForm.first_name" placeholder="Vorname" autofocus />
</div>
<div class="field">
<span class="label">Nachname</span>
<v-input v-model="userForm.last_name" placeholder="Nachname" />
</div>
<div class="field">
<span class="label">E-Mail</span>
<v-input v-model="userForm.email" placeholder="E-Mail Adresse" type="email" />
</div>
<div class="field">
<span class="label">Zentrale Person (Verknüpfung)</span>
<v-select
v-model="userForm.person"
:items="peopleOptions"
placeholder="Master-Person auswählen..."
/>
</div>
<v-divider v-if="isEditingUser" />
<div v-if="isEditingUser" class="field">
<span class="label">Temporäres Passwort</span>
<v-input v-model="userForm.temporary_password" readonly class="password-input" />
<p class="field-note">Wird beim Senden der Zugangsdaten automatisch generiert.</p>
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="saving" @click="saveClientUser">Daten speichern</v-button>
<template v-if="isEditingUser">
<v-divider />
<v-button
v-tooltip.bottom="'Generiert PW, speichert es und sendet E-Mail'"
secondary
block
:loading="invitingId === userForm.id"
@click="inviteUser(userForm)"
>
<v-icon name="send" left /> Zugangsdaten senden
</v-button>
</template>
</div>
</div>
</v-drawer>
<!-- Drawer: Quick Add Company -->
<v-drawer
v-model="quickAddCompanyActive"
title="Firma schnell anlegen"
icon="business"
@cancel="quickAddCompanyActive = false"
>
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Firmenname</span>
<v-input v-model="quickCompanyForm.name" placeholder="z.B. Schmidt GmbH" autofocus />
</div>
<div class="field">
<span class="label">Domain / Website</span>
<v-input v-model="quickCompanyForm.domain" placeholder="example.com" />
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="savingQuick" @click="saveQuickCompany">Firma speichern</v-button>
</div>
</div>
</v-drawer>
<!-- Drawer: Quick Add Person -->
<v-drawer
v-model="quickAddPersonActive"
title="Person schnell anlegen"
icon="person"
@cancel="quickAddPersonActive = false"
>
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Vorname</span>
<v-input v-model="quickPersonForm.first_name" placeholder="Vorname" autofocus />
</div>
<div class="field">
<span class="label">Nachname</span>
<v-input v-model="quickPersonForm.last_name" placeholder="Nachname" />
</div>
<div class="field">
<span class="label">E-Mail</span>
<v-input v-model="quickPersonForm.email" placeholder="email@example.com" type="email" />
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="savingQuick" @click="saveQuickPerson">Person speichern</v-button>
</div>
</div>
</v-drawer>
</MintelManagerLayout>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, computed, watch } from 'vue';
import { useApi } from '@directus/extensions-sdk';
import { useRoute } from 'vue-router';
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
const api = useApi();
const route = useRoute();
function onDebugClick() {
console.log("=== [Customer Manager] DEBUG CLICK TRAPPED ===");
alert("Interactivity OK!");
}
const items = ref<any[]>([]);
const selectedItem = ref<any>(null);
const clientUsers = ref<any[]>([]);
const loading = ref(false);
const saving = ref(false);
const invitingId = ref<string | null>(null);
const notice = ref<{ type: string; message: string } | null>(null);
const companies = ref<any[]>([]);
const people = ref<any[]>([]);
const drawerActive = ref(false);
const isEditing = ref(false);
const form = ref({ id: null, company: null, contact_person: null, status: 'active', notes: '' });
const drawerUserActive = ref(false);
const isEditingUser = ref(false);
const userForm = ref({ id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' });
const quickAddCompanyActive = ref(false);
const quickAddPersonActive = ref(false);
const savingQuick = ref(false);
const quickCompanyForm = ref({ name: '', domain: '' });
const quickPersonForm = ref({ first_name: '', last_name: '', email: '' });
const tableHeaders = [
{ text: 'Name', value: 'name', sortable: true },
{ text: 'E-Mail', value: 'email', sortable: true },
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
];
const companyOptions = computed(() => companies.value.map(c => ({ text: c.name, value: c.id })));
const peopleOptions = computed(() => people.value.map(p => ({ text: `${p.first_name} ${p.last_name} (${p.email})`, value: p.id })));
async function fetchData() {
loading.value = true;
try {
const [custResp, compResp, peopleResp] = await Promise.all([
api.get('/items/customers', { params: { fields: ['*', 'company.*', 'contact_person.*'], sort: 'company.name' } }),
api.get('/items/companies', { params: { sort: 'name' } }),
api.get('/items/people', { params: { sort: 'last_name' } })
]);
items.value = custResp.data.data;
companies.value = compResp.data.data;
people.value = peopleResp.data.data;
} finally {
loading.value = false;
}
}
async function selectItem(item: any) {
selectedItem.value = item;
loading.value = true;
try {
const res = await api.get('/items/client_users', {
params: {
filter: { company: { _eq: item.company.id } },
fields: ['*', 'person.*'],
sort: 'first_name',
},
});
clientUsers.value = res.data.data;
} finally {
loading.value = false;
}
}
function openCreateDrawer() {
isEditing.value = false;
form.value = { id: null, company: null, contact_person: null, status: 'active', notes: '' };
drawerActive.value = true;
}
function openEditDrawer() {
if (!selectedItem.value) return;
isEditing.value = true;
form.value = {
id: selectedItem.value.id,
company: selectedItem.value.company?.id || selectedItem.value.company,
contact_person: selectedItem.value.contact_person?.id || selectedItem.value.contact_person,
status: selectedItem.value.status,
notes: selectedItem.value.notes
};
drawerActive.value = true;
}
async function saveItem() {
if (!form.value.company) {
notice.value = { type: 'danger', message: 'Bitte wählen Sie eine Firma aus.' };
return;
}
saving.value = true;
try {
if (isEditing.value) {
await api.patch(`/items/customers/${form.value.id}`, form.value);
notice.value = { type: 'success', message: 'Kunde aktualisiert!' };
} else {
await api.post('/items/customers', form.value);
notice.value = { type: 'success', message: 'Neuer Kunde verlinkt!' };
}
drawerActive.value = false;
await fetchData();
if (form.value.id) {
const updated = items.value.find(i => i.id === form.value.id);
if (updated) selectItem(updated);
}
} catch (e: any) {
notice.value = {
type: 'danger',
message: e.response?.data?.errors?.[0]?.message || e.message || 'Speichern fehlgeschlagen'
};
} finally {
saving.value = false;
}
}
// Client User Actions
function openCreateClientUser() {
isEditingUser.value = false;
userForm.value = { id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' };
drawerUserActive.value = true;
}
function onRowClick(event: any) {
const item = event?.item || event;
if (item && item.id) {
userForm.value = {
id: item.id,
first_name: item.first_name,
last_name: item.last_name,
email: item.email,
person: item.person?.id || item.person,
temporary_password: item.temporary_password
};
isEditingUser.value = true;
drawerUserActive.value = true;
}
}
async function saveClientUser() {
if (!userForm.value.email || !selectedItem.value) return;
saving.value = true;
try {
const payload = {
first_name: userForm.value.first_name,
last_name: userForm.value.last_name,
email: userForm.value.email,
person: userForm.value.person,
company: selectedItem.value.company.id
};
if (isEditingUser.value) {
await api.patch(`/items/client_users/${userForm.value.id}`, payload);
} else {
await api.post('/items/client_users', payload);
}
drawerUserActive.value = false;
await selectItem(selectedItem.value);
} finally {
saving.value = false;
}
}
async function inviteUser(user: any) {
invitingId.value = user.id;
try {
await api.post(`/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501`, [user.id]);
notice.value = { type: 'success', message: `Zugangsdaten versendet. 📧` };
await selectItem(selectedItem.value);
} finally {
invitingId.value = null;
}
}
function openQuickAdd(type: 'company' | 'person') {
if (type === 'company') {
quickCompanyForm.value = { name: '', domain: '' };
quickAddCompanyActive.value = true;
} else {
quickPersonForm.value = { first_name: '', last_name: '', email: '' };
quickAddPersonActive.value = true;
}
}
async function saveQuickCompany() {
if (!quickCompanyForm.value.name) return;
savingQuick.value = true;
try {
const res = await api.post('/items/companies', quickCompanyForm.value);
await fetchData();
form.value.company = res.data.data.id;
quickAddCompanyActive.value = false;
notice.value = { type: 'success', message: 'Firma angelegt und ausgewählt.' };
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} finally {
savingQuick.value = false;
}
}
async function saveQuickPerson() {
if (!quickPersonForm.value.first_name || !quickPersonForm.value.last_name) return;
savingQuick.value = true;
try {
const res = await api.post('/items/people', quickPersonForm.value);
await fetchData();
form.value.contact_person = res.data.data.id;
quickAddPersonActive.value = false;
notice.value = { type: 'success', message: 'Person angelegt und ausgewählt.' };
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} finally {
savingQuick.value = false;
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
function handleDeepLink() {
if (route.query.create === 'true') {
// Only open if not already open to avoid resetting form if user is already typing
if (!drawerActive.value) {
openCreateDrawer();
}
if (route.query.company) {
form.value.company = route.query.company as any;
}
if (route.query.contact_person) {
form.value.contact_person = route.query.contact_person as any;
}
}
}
watch(() => route.query.create, (newVal) => {
if (newVal === 'true') handleDeepLink();
});
onMounted(async () => {
await fetchData();
handleDeepLink();
});
</script>
<style scoped>
.user-cell { display: flex; align-items: center; gap: 12px; }
.user-name { font-weight: 600; }
.status-date { font-size: 12px; color: var(--theme--foreground-subdued); }
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
.form-section { display: flex; flex-direction: column; gap: 20px; }
.field { display: flex; flex-direction: column; gap: 8px; }
.label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
.field-note { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px; }
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
.password-input :deep(textarea) {
font-family: var(--family-monospace);
font-weight: 800;
color: var(--theme--primary) !important;
background: var(--theme--background-subdued) !important;
}
.clickable-table :deep(tbody tr) { cursor: pointer; transition: background-color 0.2s ease; }
.clickable-table :deep(tbody tr:hover) { background-color: var(--theme--background-subdued) !important; }
</style>

View File

@@ -1,31 +0,0 @@
{
"name": "@mintel/directus-extension-toolkit",
"version": "1.8.16",
"description": "Shared toolkit for Directus extensions in the Mintel ecosystem",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"files": [
"dist"
],
"exports": {
".": {
"import": "./dist/index.js"
}
},
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@vitejs/plugin-vue": "^6.0.4",
"typescript": "^5.0.0",
"vite": "^5.0.0",
"vue": "^3.4.0"
},
"peerDependencies": {
"@directus/extensions-sdk": "*",
"vue": "^3.4.0"
}
}

View File

@@ -1,102 +0,0 @@
<template>
<private-view :title="title">
<template #navigation>
<slot name="navigation" />
</template>
<template #title-outer:after>
<v-notice v-if="notice" :type="notice.type" @close="$emit('close-notice')" dismissible>
{{ notice.message }}
</v-notice>
</template>
<div class="mintel-manager-layout">
<div v-if="isEmpty" class="empty-state">
<v-info :title="emptyTitle" :icon="emptyIcon" center>
<slot name="empty-state" />
</v-info>
</div>
<template v-else>
<header class="mintel-header">
<div class="header-left">
<h1 class="mintel-title">{{ itemTitle }}</h1>
<p class="mintel-subtitle">
<slot name="subtitle" />
</p>
</div>
<div class="header-right">
<slot name="actions" />
</div>
</header>
<v-divider />
<div class="mintel-content">
<slot />
</div>
</template>
</div>
</private-view>
</template>
<script setup lang="ts">
defineProps<{
title: string;
itemTitle?: string;
isEmpty?: boolean;
emptyTitle?: string;
emptyIcon?: string;
notice?: { type: string; message: string } | null;
}>();
defineEmits(['close-notice']);
</script>
<style scoped>
.mintel-manager-layout {
padding: 32px;
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.mintel-header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.mintel-title {
font-size: 24px;
font-weight: 800;
margin-bottom: 4px;
color: var(--theme--foreground);
}
.mintel-subtitle {
color: var(--theme--foreground-subdued);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.header-right {
display: flex;
gap: 12px;
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.mintel-content {
margin-top: 32px;
}
</style>

View File

@@ -1,62 +0,0 @@
<template>
<div class="mintel-select">
<v-select
:model-value="modelValue"
:items="items"
:placeholder="placeholder"
:searchable="searchable"
:show-deselect="showDeselect"
@update:model-value="$emit('update:modelValue', $event)"
/>
<v-button v-if="allowAdd" secondary rounded icon x-small class="add-button" @click="$emit('add')">
<v-icon name="add" />
</v-button>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
modelValue: {
type: [String, Number],
default: null
},
items: {
type: Array as () => Array<{ text: string; value: string | number }>,
required: true
},
placeholder: {
type: String,
default: 'Auswählen...'
},
searchable: {
type: Boolean,
default: true
},
showDeselect: {
type: Boolean,
default: true
},
allowAdd: {
type: Boolean,
default: false
}
});
defineEmits(['update:modelValue', 'add']);
</script>
<style scoped>
.mintel-select {
display: flex;
align-items: center;
gap: 8px;
}
.mintel-select :deep(.v-select) {
flex: 1;
}
.add-button {
flex-shrink: 0;
}
</style>

View File

@@ -1,84 +0,0 @@
<template>
<div class="mintel-stat-card" @click="$emit('click')">
<div class="stat-icon">
<v-icon :name="icon" large />
</div>
<div class="stat-content">
<span class="stat-label">{{ label }}</span>
<span class="stat-value">{{ value }}</span>
</div>
<v-icon name="chevron_right" class="arrow" />
</div>
</template>
<script setup lang="ts">
defineProps<{
label: string;
value: string | number;
icon: string;
}>();
defineEmits(['click']);
</script>
<style scoped>
.mintel-stat-card {
background: var(--theme--background-normal);
border: 1px solid var(--theme--border);
padding: 24px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 20px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.mintel-stat-card:hover {
border-color: var(--theme--primary);
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
}
.stat-icon {
width: 56px;
height: 56px;
background: var(--theme--background-subdued);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme--primary);
}
.stat-content {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--theme--foreground-subdued);
letter-spacing: 0.5px;
}
.stat-value {
font-size: 28px;
font-weight: 800;
color: var(--theme--foreground);
}
.arrow {
position: absolute;
right: 24px;
opacity: 0.2;
}
.mintel-stat-card:hover .arrow {
opacity: 1;
color: var(--theme--primary);
}
</style>

View File

@@ -1,3 +0,0 @@
export { default as MintelSelect } from './MintelSelect.vue';
export { default as MintelManagerLayout } from './MintelManagerLayout.vue';
export { default as MintelStatCard } from './MintelStatCard.vue';

View File

@@ -1,24 +0,0 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: resolve('src/index.ts'),
name: 'MintelDirectusToolkit',
fileName: 'index',
formats: ['es']
},
rollupOptions: {
external: ['vue', '@directus/extensions-sdk'],
output: {
globals: {
vue: 'Vue',
'@directus/extensions-sdk': 'DirectusExtensionsSDK'
}
}
}
}
});

View File

@@ -1,9 +1,9 @@
{
"name": "@mintel/eslint-config",
"version": "1.8.16",
"version": "1.9.8",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
},
"type": "module",
"main": "index.js",
@@ -25,5 +25,9 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"typescript-eslint": "^8.54.0"
},
"repository": {
"type": "git",
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
}
}

View File

@@ -0,0 +1,37 @@
{
"name": "@mintel/estimation-engine",
"version": "1.9.8",
"private": true,
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"estimate": "./dist/cli.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsup src/index.ts src/cli.ts --format esm --dts --clean",
"dev": "tsup src/index.ts src/cli.ts --format esm --watch --dts",
"lint": "eslint src",
"estimate": "tsx src/cli.ts"
},
"dependencies": {
"@mintel/concept-engine": "workspace:*",
"axios": "^1.6.0",
"commander": "^12.0.0",
"dotenv": "^17.3.1"
},
"devDependencies": {
"@mintel/tsconfig": "workspace:*",
"@types/node": "^20.0.0",
"tsup": "^8.3.5",
"tsx": "^4.7.0",
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,62 @@
import { config as dotenvConfig } from "dotenv";
import * as path from "node:path";
import * as fs from "node:fs/promises";
import { EstimationPipeline } from "./pipeline.js";
dotenvConfig({ path: path.resolve(process.cwd(), "../../.env") });
const briefing = await fs.readFile(
path.resolve(process.cwd(), "../../data/briefings/etib.txt"),
"utf8",
);
console.log(`Briefing loaded: ${briefing.length} chars`);
const pipeline = new EstimationPipeline(
{
openrouterKey: process.env.OPENROUTER_API_KEY || "",
zyteApiKey: process.env.ZYTE_API_KEY,
outputDir: path.resolve(process.cwd(), "../../out/estimations"),
crawlDir: path.resolve(process.cwd(), "../../data/crawls"),
},
{
onStepStart: (id, _name) => console.log(`[CB] Starting: ${id}`),
onStepComplete: (id) => console.log(`[CB] Done: ${id}`),
onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`),
},
);
try {
const result = await pipeline.run({
concept: {
strategy: {
briefingSummary: briefing,
projectGoals: [],
targetAudience: [],
coreMessage: "",
designVision: "",
uniqueValueProposition: "",
competitorAnalysis: "",
},
architecture: {
sitemap: [],
recommendedTechStack: [],
integrations: [],
websiteTopic: "",
dataModels: [],
},
auditedFacts: {
companyName: "E-TIB",
},
} as any,
});
console.log("\n✨ Pipeline complete!");
console.log(
"Validation:",
result.validationResult?.passed ? "PASSED" : "FAILED",
);
} catch (err: any) {
console.error("\n❌ Pipeline failed:", err.message);
console.error(err.stack);
}

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env node
// ============================================================================
// @mintel/estimation-engine — CLI Entry Point
// ============================================================================
import { Command } from "commander";
import * as path from "node:path";
import * as fs from "node:fs/promises";
import { existsSync } from "node:fs";
import { config as dotenvConfig } from "dotenv";
import { EstimationPipeline } from "./pipeline.js";
import type { ProjectConcept } from "@mintel/concept-engine";
// Load .env from monorepo root
dotenvConfig({ path: path.resolve(process.cwd(), "../../.env") });
dotenvConfig({ path: path.resolve(process.cwd(), ".env") });
const program = new Command();
program
.name("estimate")
.description("AI-powered project estimation engine")
.version("1.0.0");
program
.command("run")
.description("Run the financial estimation pipeline from a concept file")
.argument("<concept-file>", "Path to the ProjectConcept JSON file")
.option("--budget <budget>", "Budget constraint (e.g. '15.000 €')")
.option("--output <dir>", "Output directory", "../../out/estimations")
.action(async (conceptFile: string, options: any) => {
const openrouterKey =
process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!openrouterKey) {
console.error("❌ OPENROUTER_API_KEY not found in environment.");
process.exit(1);
}
const filePath = path.resolve(process.cwd(), conceptFile);
if (!existsSync(filePath)) {
console.error(`❌ Concept file not found: ${filePath}`);
process.exit(1);
}
console.log(`📄 Loading concept from: ${filePath}`);
const rawConcept = await fs.readFile(filePath, "utf8");
const concept = JSON.parse(rawConcept) as ProjectConcept;
const pipeline = new EstimationPipeline(
{
openrouterKey,
outputDir: path.resolve(process.cwd(), options.output),
crawlDir: "", // No longer needed here
},
{
onStepStart: (_id, _name) => {},
onStepComplete: (_id, _result) => {},
},
);
try {
const result = await pipeline.run({
concept,
budget: options.budget,
});
console.log("\n✨ Estimation complete!");
if (result.validationResult && !result.validationResult.passed) {
console.log(
`\n⚠ ${result.validationResult.errors.length} validation issues found.`,
);
console.log(" Review the output JSON and re-run problematic steps.");
}
} catch (err) {
console.error(`\n❌ Pipeline failed: ${(err as Error).message}`);
process.exit(1);
}
});
program.parse();

View File

@@ -0,0 +1,9 @@
// ============================================================================
// @mintel/estimation-engine — Public API
// ============================================================================
export { EstimationPipeline } from "./pipeline.js";
export type { PipelineCallbacks } from "./pipeline.js";
export { validateEstimation } from "./validators.js";
export { llmRequest, llmJsonRequest, cleanJson } from "./llm-client.js";
export * from "./types.js";

View File

@@ -0,0 +1,132 @@
// ============================================================================
// LLM Client — Unified interface with model routing via OpenRouter
// ============================================================================
import axios from "axios";
interface LLMRequestOptions {
model: string;
systemPrompt: string;
userPrompt: string;
jsonMode?: boolean;
apiKey: string;
}
interface LLMResponse {
content: string;
usage: {
promptTokens: number;
completionTokens: number;
cost: number;
};
}
/**
* Clean raw LLM output to parseable JSON.
* Handles markdown fences, control chars, trailing commas.
*/
export function cleanJson(str: string): string {
let cleaned = str.replace(/```json\n?|```/g, "").trim();
// eslint-disable-next-line no-control-regex
cleaned = cleaned.replace(/[\x00-\x1f\x7f-\x9f]/gi, " ");
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
return cleaned;
}
/**
* Send a request to an LLM via OpenRouter.
*/
export async function llmRequest(
options: LLMRequestOptions,
): Promise<LLMResponse> {
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
const resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
...(jsonMode ? { response_format: { type: "json_object" } } : {}),
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
timeout: 120000,
},
);
const content = resp.data.choices?.[0]?.message?.content;
if (!content) {
throw new Error(`LLM returned no content. Model: ${model}`);
}
let cost = 0;
const usage = resp.data.usage || {};
if (usage.cost !== undefined) {
cost = usage.cost;
} else {
// Fallback estimation
cost =
(usage.prompt_tokens || 0) * (0.1 / 1_000_000) +
(usage.completion_tokens || 0) * (0.4 / 1_000_000);
}
return {
content,
usage: {
promptTokens: usage.prompt_tokens || 0,
completionTokens: usage.completion_tokens || 0,
cost,
},
};
}
/**
* Send a request and parse the response as JSON.
*/
export async function llmJsonRequest<T = any>(
options: LLMRequestOptions,
): Promise<{ data: T; usage: LLMResponse["usage"] }> {
const response = await llmRequest({ ...options, jsonMode: true });
const cleaned = cleanJson(response.content);
let parsed: T;
try {
parsed = JSON.parse(cleaned);
} catch (e) {
throw new Error(
`Failed to parse LLM JSON response: ${(e as Error).message}\nRaw: ${cleaned.substring(0, 500)}`,
);
}
// Unwrap common LLM artifacts: {"0": {...}}, {"state": {...}}, etc.
const unwrapped = unwrapResponse(parsed);
return { data: unwrapped as T, usage: response.usage };
}
/**
* Recursively unwrap common LLM wrapping patterns.
*/
function unwrapResponse(obj: any): any {
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj;
const keys = Object.keys(obj);
if (keys.length === 1) {
const key = keys[0];
if (
key === "0" ||
key === "state" ||
key === "facts" ||
key === "result" ||
key === "data"
) {
return unwrapResponse(obj[key]);
}
}
return obj;
}

View File

@@ -0,0 +1,256 @@
// ============================================================================
// Pipeline Orchestrator
// Runs all steps sequentially, tracks state, supports re-running individual steps.
// ============================================================================
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { validateEstimation } from "./validators.js";
import { executeSynthesize } from "./steps/05-synthesize.js";
import { executeCritique } from "./steps/06-critique.js";
import type {
PipelineConfig,
PipelineInput,
EstimationState,
StepResult,
} from "./types.js";
export interface PipelineCallbacks {
onStepStart?: (stepId: string, stepName: string) => void;
onStepComplete?: (stepId: string, result: StepResult) => void;
onStepError?: (stepId: string, error: string) => void;
}
/**
* The main estimation pipeline orchestrator.
* Runs steps sequentially, persists state between steps, supports re-entry.
*/
export class EstimationPipeline {
private config: PipelineConfig;
private state: EstimationState;
private callbacks: PipelineCallbacks;
constructor(config: PipelineConfig, callbacks: PipelineCallbacks = {}) {
this.config = config;
this.callbacks = callbacks;
this.state = this.createInitialState();
}
private createInitialState(): EstimationState {
return {
concept: null as any, // Will be set in run()
usage: {
totalPromptTokens: 0,
totalCompletionTokens: 0,
totalCost: 0,
perStep: [],
},
};
}
/**
* Run the full estimation pipeline from a completed project concept.
*/
async run(input: PipelineInput): Promise<EstimationState> {
this.state.concept = input.concept;
this.state.budget = input.budget;
// Ensure output directories
await fs.mkdir(this.config.outputDir, { recursive: true });
// Step 5: Position synthesis
await this.runStep("05-synthesize", "Position Descriptions", async () => {
const result = await executeSynthesize(this.state, this.config);
if (result.success) this.state.positionDescriptions = result.data;
return result;
});
// Step 6: Quality critique
await this.runStep(
"06-critique",
"Quality Gate (Industrial Critic)",
async () => {
const result = await executeCritique(this.state, this.config);
if (result.success) {
this.state.critiquePassed = result.data.passed;
this.state.critiqueErrors =
result.data.errors?.map((e: any) => `${e.field}: ${e.issue}`) || [];
// Apply corrections
if (result.data.corrections) {
const corrections = result.data.corrections;
// Note: We only correct the positionDescriptions since briefing/design/sitemap are locked in the concept phase.
// If the critique suggests changes to those, it should be a warning or failure.
if (corrections.positionDescriptions) {
this.state.positionDescriptions = {
...this.state.positionDescriptions,
...corrections.positionDescriptions,
};
}
}
}
return result;
},
);
// Step 7: Deterministic validation
await this.runStep("07-validate", "Deterministic Validation", async () => {
// Build the merged form state first
this.state.formState = this.buildFormState();
const validationResult = validateEstimation(this.state);
this.state.validationResult = validationResult;
if (!validationResult.passed) {
console.log("\n⚠ Validation Issues:");
for (const error of validationResult.errors) {
console.log(` ❌ [${error.code}] ${error.message}`);
}
}
if (validationResult.warnings.length > 0) {
console.log("\n⚡ Warnings:");
for (const warning of validationResult.warnings) {
console.log(` ⚡ [${warning.code}] ${warning.message}`);
if (warning.suggestion) console.log(`${warning.suggestion}`);
}
}
return {
success: true,
data: validationResult,
usage: {
step: "07-validate",
model: "none",
promptTokens: 0,
completionTokens: 0,
cost: 0,
durationMs: 0,
},
};
});
// Save final state
await this.saveState();
return this.state;
}
/**
* Run a single step with callbacks and error handling.
*/
private async runStep(
stepId: string,
stepName: string,
executor: () => Promise<StepResult>,
): Promise<void> {
this.callbacks.onStepStart?.(stepId, stepName);
console.log(`\n📍 ${stepName}...`);
try {
const result = await executor();
if (result.usage) {
this.state.usage.perStep.push(result.usage);
this.state.usage.totalPromptTokens += result.usage.promptTokens;
this.state.usage.totalCompletionTokens += result.usage.completionTokens;
this.state.usage.totalCost += result.usage.cost;
}
if (result.success) {
const cost = result.usage?.cost
? ` ($${result.usage.cost.toFixed(4)})`
: "";
const duration = result.usage?.durationMs
? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]`
: "";
console.log(`${stepName} complete${cost}${duration}`);
this.callbacks.onStepComplete?.(stepId, result);
} else {
console.error(`${stepName} failed: ${result.error}`);
this.callbacks.onStepError?.(stepId, result.error || "Unknown error");
throw new Error(result.error);
}
} catch (err) {
const errorMsg = (err as Error).message;
this.callbacks.onStepError?.(stepId, errorMsg);
throw err;
}
}
/**
* Build the final FormState compatible with @mintel/pdf.
*/
private buildFormState(): Record<string, any> {
const facts = this.state.concept.auditedFacts || {};
return {
projectType: "website",
...facts,
briefingSummary: this.state.concept.strategy.briefingSummary || "",
designVision: this.state.concept.strategy.designVision || "",
sitemap: this.state.concept.architecture.sitemap || [],
positionDescriptions: this.state.positionDescriptions || {},
websiteTopic:
this.state.concept.architecture.websiteTopic ||
facts.websiteTopic ||
"",
statusQuo: facts.isRelaunch ? "Relaunch" : "Neuentwicklung",
name: facts.personName || "",
email: facts.email || "",
};
}
/**
* Save the full state to disk for later re-use.
*/
private async saveState(): Promise<void> {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const companyName =
this.state.concept.auditedFacts?.companyName || "unknown";
// Save full state
const stateDir = path.join(this.config.outputDir, "json");
await fs.mkdir(stateDir, { recursive: true });
const statePath = path.join(stateDir, `${companyName}_${timestamp}.json`);
await fs.writeFile(
statePath,
JSON.stringify(this.state.formState, null, 2),
);
console.log(`\n📦 Saved state to: ${statePath}`);
// Save full pipeline state (for debugging / re-entry)
const debugPath = path.join(
stateDir,
`${companyName}_${timestamp}_debug.json`,
);
await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2));
// Print usage summary
console.log("\n──────────────────────────────────────────────");
console.log("📊 PIPELINE USAGE SUMMARY");
console.log("──────────────────────────────────────────────");
for (const step of this.state.usage.perStep) {
if (step.cost > 0) {
console.log(
` ${step.step}: ${step.model}$${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`,
);
}
}
console.log("──────────────────────────────────────────────");
console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`);
console.log(
` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`,
);
console.log("──────────────────────────────────────────────\n");
}
/** Get the current state (for CLI inspection). */
getState(): EstimationState {
return this.state;
}
/** Load a saved state from JSON. */
async loadState(jsonPath: string): Promise<void> {
const raw = await fs.readFile(jsonPath, "utf8");
const formState = JSON.parse(raw);
this.state.formState = formState;
}
}

View File

@@ -0,0 +1,95 @@
// ============================================================================
// Step 05: Synthesize — Position Descriptions (Gemini Pro)
// ============================================================================
import { llmJsonRequest } from "../llm-client.js";
import type { EstimationState, StepResult, PipelineConfig } from "../types.js";
import { DEFAULT_MODELS } from "../types.js";
export async function executeSynthesize(
state: EstimationState,
config: PipelineConfig,
): Promise<StepResult> {
const models = { ...DEFAULT_MODELS, ...config.modelsOverride };
const startTime = Date.now();
if (!state.concept?.auditedFacts || !state.concept?.architecture?.sitemap) {
return { success: false, error: "Missing audited facts or sitemap." };
}
const facts = state.concept.auditedFacts;
// Determine which positions are required
const requiredPositions = [
"Das technische Fundament",
(facts.selectedPages?.length || 0) + (facts.otherPages?.length || 0) > 0
? "Individuelle Seiten"
: null,
facts.features?.length > 0 ? "System-Module (Features)" : null,
facts.functions?.length > 0 ? "Logik-Funktionen" : null,
facts.apiSystems?.length > 0 ? "Schnittstellen (API)" : null,
facts.cmsSetup ? "Inhalts-Verwaltung" : null,
facts.multilang ? "Mehrsprachigkeit" : null,
"Inhaltliche Initial-Pflege",
"Sorglos Betrieb",
].filter(Boolean);
const systemPrompt = `
You are a Senior Solution Architect. Write position descriptions for a professional B2B quote.
### REQUIRED POSITIONS (STRICT — ONLY DESCRIBE THESE):
${requiredPositions.map((p) => `"${p}"`).join(", ")}
### RULES (STRICT):
1. NO FIRST PERSON: NEVER "Ich", "Mein", "Wir", "Unser". Lead with nouns or passive verbs.
2. QUANTITY PARITY: Description MUST list EXACTLY the number of items matching 'qty'.
3. CMS GUARD: If cmsSetup=false, do NOT mention "CMS", "Inhaltsverwaltung". Use "Plattform-Struktur".
4. TONE: "Erstellung von...", "Anbindung der...", "Bereitstellung von...". Technical, high-density.
5. PAGES: List actual page names. NO implementation notes in parentheses.
6. HARD SPECIFICS: Use industry terms from the briefing (e.g. "Kabeltiefbau", "110 kV").
7. KEYS: Return EXACTLY the keys from REQUIRED POSITIONS.
8. NO AGB: NEVER mention "AGB" or "Geschäftsbedingungen".
9. Sorglos Betrieb: "Inklusive 1 Jahr technischer Betrieb, Hosting, SSL, Sicherheits-Updates, Monitoring und techn. Support."
10. Inhaltliche Initial-Pflege: Refers to DATENSÄTZE (datasets like products, references), NOT Seiten.
Use "Datensätze" in the description, not "Seiten".
11. Mehrsprachigkeit: This is a +20% markup on the subtotal. NOT an API. NOT a Schnittstelle.
### EXAMPLES:
- GOOD: "Erstellung der Seiten: Startseite, Über uns, Leistungen, Kontakt."
- GOOD: "Native API-Anbindung an Google Maps mit individueller Standort-Visualisierung."
- BAD: "Ich richte dir das CMS ein."
- BAD: "Verschiedene Funktionen" (too generic — name the things!)
### DATA CONTEXT:
${JSON.stringify({ facts, sitemap: state.concept.architecture.sitemap, strategy: { briefingSummary: state.concept.strategy.briefingSummary } }, null, 2)}
### OUTPUT FORMAT:
{
"positionDescriptions": { "Das technische Fundament": string, ... }
}
`;
try {
const { data, usage } = await llmJsonRequest({
model: models.pro,
systemPrompt,
userPrompt: state.concept.briefing,
apiKey: config.openrouterKey,
});
return {
success: true,
data: data.positionDescriptions || data,
usage: {
step: "05-synthesize",
model: models.pro,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
cost: usage.cost,
durationMs: Date.now() - startTime,
},
};
} catch (err) {
return { success: false, error: `Synthesize step failed: ${(err as Error).message}` };
}
}

View File

@@ -0,0 +1,99 @@
// ============================================================================
// Step 06: Critique — Industrial Critic Quality Gate (Claude Opus)
// ============================================================================
import { llmJsonRequest } from "../llm-client.js";
import type { EstimationState, StepResult, PipelineConfig } from "../types.js";
import { DEFAULT_MODELS } from "../types.js";
export async function executeCritique(
state: EstimationState,
config: PipelineConfig,
): Promise<StepResult> {
const models = { ...DEFAULT_MODELS, ...config.modelsOverride };
const startTime = Date.now();
const currentState = {
facts: state.concept?.auditedFacts,
briefingSummary: state.concept?.strategy?.briefingSummary,
designVision: state.concept?.strategy?.designVision,
sitemap: state.concept?.architecture?.sitemap,
positionDescriptions: state.positionDescriptions,
siteProfile: state.concept?.siteProfile
? {
existingFeatures: state.concept.siteProfile.existingFeatures,
services: state.concept.siteProfile.services,
externalDomains: state.concept.siteProfile.externalDomains,
navigation: state.concept.siteProfile.navigation,
totalPages: state.concept.siteProfile.totalPages,
}
: null,
};
const systemPrompt = `
You are the "Industrial Critic" — the final quality gate for a professional B2B estimation.
Your job is to find EVERY error, hallucination, and inconsistency before this goes to the client.
### CRITICAL ERROR CHECKLIST (FAIL IF ANY FOUND):
1. HALLUCINATION: FAIL if names, software versions, or details not in the BRIEFING are used.
- "Sie", "Ansprechpartner" for personName when an actual name exists = FAIL.
2. LOGIC CONFLICT: FAIL if isRelaunch=true but text claims "no website exists".
3. IMPLEMENTATION FLUFF: FAIL if "React", "Next.js", "TypeScript", "Tailwind" are mentioned.
4. GENERICISM: FAIL if text could apply to ANY company. Must use specific industry terms.
5. NAMEN-VERBOT: FAIL if personal names in briefingSummary or designVision.
6. CMS-LEAKAGE: FAIL if cmsSetup=false but descriptions mention "CMS", "Inhaltsverwaltung".
7. AGB BAN: FAIL if "AGB" or "Geschäftsbedingungen" appear anywhere.
8. LENGTH: briefingSummary ~6 sentences, designVision ~4 sentences. Shorten if too wordy.
9. LEGAL SAFETY: FAIL if "rechtssicher" is used. Use "Standard-konform" instead.
10. BULLSHIT DETECTOR: FAIL if jargon like "SEO-Standards zur Fachkräftesicherung",
"B2B-Nutzerströme", "Digitale Konvergenzstrategie" or similar meaningless buzzwords are used.
The text must make SENSE to a construction industry CEO.
11. PAGE STRUCTURE: FAIL if the sitemap contains:
- Videos as pages (Messefilm, Imagefilm)
- Internal functions as pages (Verwaltung)
- Entities with their own domains as sub-pages (check externalDomains!)
12. SORGLOS-BETRIEB: FAIL if not mentioned in the summary or position descriptions.
13. TONE: FAIL if "wir/unser" or "Ich/Mein" in position descriptions. FAIL if marketing fluff.
14. MULTILANG: FAIL if Mehrsprachigkeit is described as an API or Schnittstelle.
15. INITIAL-PFLEGE: FAIL if described in terms of "Seiten" instead of "Datensätze".
### MISSION:
Return corrected fields ONLY for fields with issues. If everything passes, return empty corrections.
### OUTPUT FORMAT:
{
"passed": boolean,
"errors": [{ "field": string, "issue": string, "severity": "critical" | "warning" }],
"corrections": {
"briefingSummary"?: string,
"designVision"?: string,
"positionDescriptions"?: Record<string, string>,
"sitemap"?: array
}
}
`;
try {
const { data, usage } = await llmJsonRequest({
model: models.opus,
systemPrompt,
userPrompt: `BRIEFING_TRUTH:\n${state.concept?.briefing}\n\nCURRENT_STATE:\n${JSON.stringify(currentState, null, 2)}`,
apiKey: config.openrouterKey,
});
return {
success: true,
data,
usage: {
step: "06-critique",
model: models.opus,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
cost: usage.cost,
durationMs: Date.now() - startTime,
},
};
} catch (err) {
return { success: false, error: `Critique step failed: ${(err as Error).message}` };
}
}

View File

@@ -0,0 +1,113 @@
// ============================================================================
// @mintel/estimation-engine — Core Type Definitions
// ============================================================================
import type { ProjectConcept } from "@mintel/concept-engine";
/** Configuration for the estimation pipeline */
export interface PipelineConfig {
openrouterKey: string;
zyteApiKey?: string;
outputDir: string;
crawlDir: string;
modelsOverride?: Partial<ModelConfig>;
}
/** Model routing configuration */
export interface ModelConfig {
flash: string;
pro: string;
opus: string;
}
export const DEFAULT_MODELS: ModelConfig = {
flash: "google/gemini-3-flash-preview",
pro: "google/gemini-3.1-pro-preview",
opus: "anthropic/claude-opus-4-6",
};
/** Input for the estimation pipeline */
export interface PipelineInput {
concept: ProjectConcept;
budget?: string;
}
/** State that flows through all pipeline steps */
export interface EstimationState {
// Input
concept: ProjectConcept;
budget?: string;
// Step 5 output: Position Synthesis
positionDescriptions?: Record<string, string>;
// Step 6 output: Critique
critiquePassed?: boolean;
critiqueErrors?: string[];
// Step 7 output: Validation
validationResult?: ValidationResult;
// Final merged form state for PDF generation
formState?: Record<string, any>;
// Cost tracking
usage: UsageStats;
}
export interface UsageStats {
totalPromptTokens: number;
totalCompletionTokens: number;
totalCost: number;
perStep: StepUsage[];
}
export interface StepUsage {
step: string;
model: string;
promptTokens: number;
completionTokens: number;
cost: number;
durationMs: number;
}
/** Result of a single pipeline step */
export interface StepResult<T = any> {
success: boolean;
data?: T;
error?: string;
usage?: StepUsage;
}
/** Validation result from the deterministic validator */
export interface ValidationResult {
passed: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
}
export interface ValidationError {
code: string;
message: string;
field?: string;
expected?: any;
actual?: any;
}
export interface ValidationWarning {
code: string;
message: string;
suggestion?: string;
}
/** Step definition for the pipeline */
export interface PipelineStep {
id: string;
name: string;
description: string;
model: "flash" | "pro" | "opus" | "none";
execute: (
state: EstimationState,
config: PipelineConfig,
) => Promise<StepResult>;
}

View File

@@ -0,0 +1,436 @@
// ============================================================================
// Validators — Deterministic Math & Logic Checks (NO LLM!)
// Catches all the issues reported by the user programmatically.
// ============================================================================
import type {
EstimationState,
ValidationResult,
ValidationError,
ValidationWarning,
} from "./types.js";
/**
* Run all deterministic validation checks on the final estimation state.
*/
export function validateEstimation(state: EstimationState): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
if (!state.formState) {
return {
passed: false,
errors: [
{
code: "NO_FORM_STATE",
message: "No form state available for validation.",
},
],
warnings: [],
};
}
const fs = state.formState;
// 1. PAGE COUNT PARITY
validatePageCountParity(fs, errors);
// 2. SORGLOS-BETRIEB IN SUMMARY
validateSorglosBetrieb(fs, errors, warnings);
// 3. NO VIDEOS AS PAGES
validateNoVideosAsPages(fs, errors);
// 4. EXTERNAL DOMAINS NOT AS PAGES
validateExternalDomains(fs, state.concept?.siteProfile, errors);
// 5. SERVICE COVERAGE
validateServiceCoverage(fs, state.concept?.siteProfile, warnings);
// 6. EXISTING FEATURE DETECTION
validateExistingFeatures(fs, state.concept?.siteProfile, warnings);
// 7. MULTILANG LABEL CORRECTNESS
validateMultilangLabeling(fs, errors);
// 8. INITIAL-PFLEGE UNITS
validateInitialPflegeUnits(fs, warnings);
// 9. SITEMAP vs PAGE LIST CONSISTENCY
validateSitemapConsistency(fs, errors);
return {
passed: errors.length === 0,
errors,
warnings,
};
}
/**
* 1. Page count: the "Individuelle Seiten" position description must mention
* roughly the same number of pages as the sitemap contains.
* "er berechnet 15 Seiten nennt aber nur 11"
*
* NOTE: fs.pages (from auditedFacts) is a conceptual list of page groups
* (e.g. "Leistungen") while the sitemap expands those into sub-pages.
* Therefore we do NOT compare fs.pages.length to the sitemap count.
* Instead, we verify that the position description text lists the right count.
*/
function validatePageCountParity(
fs: Record<string, any>,
errors: ValidationError[],
): void {
// Count pages listed in the sitemap (the source of truth)
let sitemapPageCount = 0;
if (Array.isArray(fs.sitemap)) {
for (const cat of fs.sitemap) {
sitemapPageCount += (cat.pages || []).length;
}
}
if (sitemapPageCount === 0) return;
// Extract page names mentioned in the "Individuelle Seiten" position description
const positions = fs.positionDescriptions || {};
const pagesDesc =
positions["Individuelle Seiten"] ||
positions["2. Individuelle Seiten"] ||
"";
if (!pagesDesc) return;
const descStr = typeof pagesDesc === "string" ? pagesDesc : "";
// Count distinct page names mentioned (split by comma)
// We avoid splitting by "&" or "und" because actual page names like
// "Wartung & Störungsdienst" or "Genehmigungs- und Ausführungsplanung" contain them.
const afterColon = descStr.includes(":")
? descStr.split(":").slice(1).join(":")
: descStr;
const segments = afterColon
.split(/,/)
.map((s: string) => s.replace(/\.$/, "").trim())
.filter((s: string) => s.length > 2);
// Handle consolidated references like "Leistungen (6 Unterseiten)" or "(inkl. Messen)"
let mentionedCount = 0;
for (const seg of segments) {
const subPageMatch = seg.match(/\((\d+)\s+(?:Unter)?[Ss]eiten?\)/);
if (subPageMatch) {
mentionedCount += parseInt(subPageMatch[1], 10);
} else if (seg.match(/\(inkl\.\s+/)) {
// "Unternehmen (inkl. Messen)" = 2 pages
mentionedCount += 2;
} else {
mentionedCount += 1;
}
}
if (mentionedCount > 0 && Math.abs(mentionedCount - sitemapPageCount) > 2) {
errors.push({
code: "PAGE_COUNT_MISMATCH",
message: `Seiten-Beschreibung nennt ~${mentionedCount} Seiten, aber ${sitemapPageCount} Seiten in der Sitemap.`,
field: "positionDescriptions.Individuelle Seiten",
expected: sitemapPageCount,
actual: mentionedCount,
});
}
}
/**
* 2. Sorglos-Betrieb must be included in summary.
* "Zusammenfassung der Schätzung hat Sorglos-Betrieb nicht miteingenommen"
*/
function validateSorglosBetrieb(
fs: Record<string, any>,
errors: ValidationError[],
_warnings: ValidationWarning[],
): void {
const positions = fs.positionDescriptions || {};
const hasPosition = Object.keys(positions).some(
(k) =>
k.toLowerCase().includes("sorglos") ||
k.toLowerCase().includes("betrieb") ||
k.toLowerCase().includes("pflege"),
);
if (!hasPosition) {
errors.push({
code: "MISSING_SORGLOS_BETRIEB",
message: "Der Sorglos-Betrieb fehlt in den Position-Beschreibungen.",
field: "positionDescriptions",
});
}
}
/**
* 3. Videos must not be treated as separate pages.
* "Er hat Videos als eigene Seite aufgenommen"
*/
function validateNoVideosAsPages(
fs: Record<string, any>,
errors: ValidationError[],
): void {
const allPages = [...(fs.selectedPages || []), ...(fs.otherPages || [])];
const sitemapPages = Array.isArray(fs.sitemap)
? fs.sitemap.flatMap((cat: any) =>
(cat.pages || []).map((p: any) => p.title),
)
: [];
const allPageNames = [...allPages, ...sitemapPages];
const videoKeywords = ["video", "film", "messefilm", "imagefilm", "clip"];
for (const pageName of allPageNames) {
const lower = (typeof pageName === "string" ? pageName : "").toLowerCase();
if (
videoKeywords.some(
(kw) => lower.includes(kw) && !lower.includes("leistung"),
)
) {
errors.push({
code: "VIDEO_AS_PAGE",
message: `"${pageName}" ist ein Video-Asset, keine eigene Seite.`,
field: "sitemap",
});
}
}
}
/**
* 4. External sister-company domains must not be proposed as sub-pages.
* "er hat ingenieursgesellschaft als seite integriert, die haben aber eine eigene website"
*/
function validateExternalDomains(
fs: Record<string, any>,
siteProfile: any,
errors: ValidationError[],
): void {
if (!siteProfile?.externalDomains?.length) return;
const sitemapPages = Array.isArray(fs.sitemap)
? fs.sitemap.flatMap((cat: any) =>
(cat.pages || []).map((p: any) => p.title || ""),
)
: [];
for (const extDomain of siteProfile.externalDomains) {
// Extract base name (e.g. "etib-ing" from "etib-ing.com")
const baseName = extDomain
.replace(/^www\./, "")
.split(".")[0]
.toLowerCase();
for (const pageTitle of sitemapPages) {
const lower = pageTitle.toLowerCase();
// Check if the page title references the external company
if (
lower.includes(baseName) ||
(lower.includes("ingenieur") && extDomain.includes("ing"))
) {
errors.push({
code: "EXTERNAL_DOMAIN_AS_PAGE",
message: `"${pageTitle}" hat eine eigene Website (${extDomain}) und darf nicht als Unterseite vorgeschlagen werden.`,
field: "sitemap",
});
}
}
}
}
/**
* 5. Services from the existing site should be covered.
* "er hat leistungen ausgelassen die ganz klar auf der kompetenz seite genannt werden"
*/
function validateServiceCoverage(
fs: Record<string, any>,
siteProfile: any,
warnings: ValidationWarning[],
): void {
if (!siteProfile?.services?.length) return;
const allContent = JSON.stringify(fs).toLowerCase();
for (const service of siteProfile.services) {
const keywords = service
.toLowerCase()
.split(/[\s,&-]+/)
.filter((w: string) => w.length > 4);
const isCovered = keywords.some((kw: string) => allContent.includes(kw));
if (!isCovered && service.length > 5) {
warnings.push({
code: "MISSING_SERVICE",
message: `Bestehende Leistung "${service}" ist nicht in der Schätzung berücksichtigt.`,
suggestion: `Prüfen ob "${service}" im Briefing gewünscht ist und ggf. in die Seitenstruktur aufnehmen.`,
});
}
}
}
/**
* 6. Existing features (search, forms) must be acknowledged.
* "er hat die suchfunktion nicht bemerkt, die gibts schon auf der seite"
*/
function validateExistingFeatures(
fs: Record<string, any>,
siteProfile: any,
warnings: ValidationWarning[],
): void {
if (!siteProfile?.existingFeatures?.length) return;
const functions = fs.functions || [];
const features = fs.features || [];
const allSelected = [...functions, ...features];
for (const existingFeature of siteProfile.existingFeatures) {
if (existingFeature === "cookie-consent") continue; // Standard, don't flag
if (existingFeature === "video") continue; // Usually an asset, not a feature
const isMapped = allSelected.some(
(f: string) => f.toLowerCase() === existingFeature.toLowerCase(),
);
if (!isMapped) {
warnings.push({
code: "EXISTING_FEATURE_IGNORED",
message: `Die bestehende Suchfunktion/Feature "${existingFeature}" wurde auf der aktuellen Website erkannt, aber nicht in der Schätzung berücksichtigt.`,
suggestion: `"${existingFeature}" als Function oder Feature aufnehmen, da es bereits existiert und der Kunde es erwartet.`,
});
}
}
}
/**
* 7. Multilang +20% must not be labeled as API.
* "die +20% beziehen sich nicht auf API"
*/
function validateMultilangLabeling(
fs: Record<string, any>,
errors: ValidationError[],
): void {
const positions = fs.positionDescriptions || {};
for (const [key, desc] of Object.entries(positions)) {
if (
key.toLowerCase().includes("api") ||
key.toLowerCase().includes("schnittstelle")
) {
const descStr = typeof desc === "string" ? desc : "";
if (
descStr.toLowerCase().includes("mehrsprach") ||
descStr.toLowerCase().includes("multilang") ||
descStr.toLowerCase().includes("20%")
) {
errors.push({
code: "MULTILANG_WRONG_POSITION",
message: `Mehrsprachigkeit (+20%) ist unter "${key}" eingeordnet, gehört aber nicht zu API/Schnittstellen.`,
field: key,
});
}
}
}
}
/**
* 8. Initial-Pflege should refer to "Datensätze" not "Seiten".
* "Initialpflege => 100€/Stk => damit sind keine Seiten sondern Datensätze"
*/
function validateInitialPflegeUnits(
fs: Record<string, any>,
warnings: ValidationWarning[],
): void {
const positions = fs.positionDescriptions || {};
for (const [key, desc] of Object.entries(positions)) {
if (
key.toLowerCase().includes("pflege") ||
key.toLowerCase().includes("initial")
) {
const descStr = typeof desc === "string" ? desc : "";
if (
descStr.toLowerCase().includes("seiten") &&
!descStr.toLowerCase().includes("datensätz")
) {
warnings.push({
code: "INITIALPFLEGE_WRONG_UNIT",
message: `"${key}" spricht von "Seiten", aber gemeint sind Datensätze (z.B. Produkte, Referenzen).`,
suggestion: `Beschreibung auf "Datensätze" statt "Seiten" ändern.`,
});
}
}
}
}
/**
* 9. Position descriptions must match calculated quantities.
*/
/**
* 9. Position descriptions must match calculated quantities.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function validatePositionDescriptionsMath(
fs: Record<string, any>,
errors: ValidationError[],
): void {
const positions = fs.positionDescriptions || {};
// Check pages description mentions correct count
const pagesDesc =
positions["Individuelle Seiten"] ||
positions["2. Individuelle Seiten"] ||
"";
if (pagesDesc) {
// Use the sitemap as the authoritative source of truth for page count
let sitemapPageCount = 0;
if (Array.isArray(fs.sitemap)) {
for (const cat of fs.sitemap) {
sitemapPageCount += (cat.pages || []).length;
}
}
// Count how many page names are mentioned in the description
const descStr = typeof pagesDesc === "string" ? pagesDesc : "";
const mentionedPages = descStr
.split(/,|und|&/)
.filter((s: string) => s.trim().length > 2);
if (
sitemapPageCount > 0 &&
mentionedPages.length > 0 &&
Math.abs(mentionedPages.length - sitemapPageCount) > 2
) {
errors.push({
code: "PAGES_DESC_COUNT_MISMATCH",
message: `Seiten-Beschreibung nennt ~${mentionedPages.length} Seiten, aber ${sitemapPageCount} in der Sitemap.`,
field: "positionDescriptions.Individuelle Seiten",
expected: sitemapPageCount,
actual: mentionedPages.length,
});
}
}
}
/**
* 10. Sitemap categories should be consistent with selected pages/features.
*/
function validateSitemapConsistency(
fs: Record<string, any>,
errors: ValidationError[],
): void {
if (!Array.isArray(fs.sitemap)) return;
const sitemapTitles = fs.sitemap.flatMap((cat: any) =>
(cat.pages || []).map((p: any) => (p.title || "").toLowerCase()),
);
// Check for "Verwaltung" page (hallucinated management page)
for (const title of sitemapTitles) {
if (title.includes("verwaltung") && !title.includes("inhalt")) {
errors.push({
code: "HALLUCINATED_MANAGEMENT_PAGE",
message: `"Verwaltung" als Seite ist vermutlich halluziniert. Verwaltung ist typischerweise eine interne Funktion, keine öffentliche Webseite.`,
field: "sitemap",
});
}
}
}

View File

@@ -0,0 +1,14 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

View File

@@ -1,27 +0,0 @@
{
"name": "feedback-commander",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.16",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "app",
"name": "feedback commander"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}

View File

@@ -1,14 +0,0 @@
import { defineModule } from '@directus/extensions-sdk';
import ModuleComponent from './module.vue';
export default defineModule({
id: 'feedback-commander',
name: 'Feedback Commander',
icon: 'view_kanban',
routes: [
{
path: '',
component: ModuleComponent,
},
],
});

View File

@@ -1,731 +0,0 @@
<template>
<private-view title="Feedback Commander">
<template #headline>
<v-breadcrumb :items="[{ name: 'Feedback', to: '/feedback-commander' }]" />
</template>
<template #title-outer:after>
<v-chip v-if="loading" label color="blue" small>Loading...</v-chip>
<v-chip v-else-if="fetchError" label color="red" small>Fetch Error</v-chip>
<v-chip v-else label color="green" small>{{ items.length }} Items</v-chip>
</template>
<template #navigation>
<div class="sidebar-header">
<v-text-overflow text="Websites" class="header-text" />
</div>
<v-list nav>
<v-list-item
:active="currentProject === 'all'"
@click="currentProject = 'all'"
clickable
>
<v-list-item-icon><v-icon name="language" /></v-list-item-icon>
<v-list-item-content><v-text-overflow text="All Projects" /></v-list-item-content>
</v-list-item>
<v-list-item
v-for="project in projects"
:key="project"
:active="currentProject === project"
@click="currentProject = project"
clickable
>
<v-list-item-icon><v-icon name="public" color="var(--primary)" /></v-list-item-icon>
<v-list-item-content><v-text-overflow :text="project || 'Unknown'" /></v-list-item-content>
</v-list-item>
</v-list>
</template>
<div class="feedback-container">
<div v-if="!items.length && !loading && !fetchError" class="empty-state">
<v-info icon="inbox" title="Clean Inbox" center>
All feedback has been processed. Great job!
</v-info>
</div>
<div v-if="fetchError" class="empty-state">
<v-info icon="error" title="Fetch Failed" :description="fetchError" center />
<v-button @click="fetchData" secondary small>Retry</v-button>
</div>
<div class="operational-layout" v-else-if="items.length">
<!-- Detailed Triage Lane -->
<aside class="triage-lane">
<div class="lane-header">
<v-select
v-model="currentStatusFilter"
:items="statusOptions"
small
placeholder="Status Filter"
/>
</div>
<div class="lane-content scrollbar">
<TransitionGroup name="list">
<div
v-for="item in filteredItems"
:key="item.id"
class="feedback-card"
:class="{ active: selectedItem?.id === item.id }"
@click="selectItem(item)"
>
<div class="card-status-bar" :style="{ background: getStatusColor(item.status || 'open') }"></div>
<div class="card-body">
<header class="card-header">
<span class="card-user">{{ item.user_name }}</span>
<span class="card-date">{{ formatDate(item.date_created || item.id) }}</span>
</header>
<div class="card-text">{{ item.text }}</div>
<footer class="card-footer">
<div class="meta-tags">
<v-chip x-small outline>{{ item.company?.name || item.project }}</v-chip>
<v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small />
</div>
<v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small />
</footer>
</div>
</div>
</TransitionGroup>
</div>
</aside>
<!-- Elaborated Master-Detail Desk -->
<main class="processing-desk scrollbar">
<Transition name="fade" mode="out-in">
<div v-if="selectedItem" :key="selectedItem.id" class="desk-content">
<header class="desk-header">
<div class="headline-group">
<div class="status-indicator">
<div class="status-dot" :style="{ background: getStatusColor(selectedItem.status || 'open') }"></div>
<span class="status-text">{{ capitalize(selectedItem.status || 'open') }}</span>
</div>
<h2>{{ selectedItem.user_name }}'s Submission</h2>
</div>
<div class="header-actions">
<v-button primary @click="openDeepLink(selectedItem)">
<v-icon name="open_in_new" left /> Open & Highlight
</v-button>
<v-select
v-model="selectedItem.status"
:items="statuses"
inline
@update:model-value="updateStatus"
/>
</div>
</header>
<div class="desk-grid">
<!-- Message Container -->
<div class="main-column">
<v-card class="content-card">
<v-card-title>
<v-icon name="format_quote" left />
Feedback Content
</v-card-title>
<v-card-text class="feedback-body">
<div v-if="selectedItem.screenshot" class="visual-proof">
<label class="proof-label"><v-icon name="photo" x-small /> Element Snapshot</label>
<img :src="getAssetUrl(selectedItem.screenshot)" class="screenshot-img" />
</div>
<div class="main-text">{{ selectedItem.text }}</div>
</v-card-text>
</v-card>
<section class="reply-section">
<div class="section-divider">
<v-divider />
<span class="divider-label">Internal Communication</span>
<v-divider />
</div>
<div class="thread">
<TransitionGroup name="thread-list">
<div v-for="reply in comments" :key="reply.id" class="reply-bubble">
<header class="reply-header">
<span class="reply-user">{{ reply.user_name || 'System' }}</span>
<span v-if="reply.person" class="reply-person">({{ reply.person.first_name }} {{ reply.person.last_name }})</span>
<span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span>
</header>
<div class="reply-text">{{ reply.text }}</div>
</div>
</TransitionGroup>
<div v-if="!comments.length" class="empty-state-mini">
<v-icon name="auto_awesome" small /> No replies yet. Start the thread.
</div>
</div>
<div class="composer">
<v-textarea v-model="replyText" placeholder="Compose internal response..." auto-grow />
<div class="composer-actions">
<v-button secondary :loading="sending" @click="sendReply">Post Reply</v-button>
</div>
</div>
</section>
</div>
<!-- Technical Sidebar -->
<aside class="meta-column">
<v-card class="meta-card">
<v-card-title>Context</v-card-title>
<v-card-text class="meta-list">
<div class="meta-item">
<label><v-icon name="business" x-small /> Organisation / Firma</label>
<strong>{{ selectedItem.company?.name || selectedItem.project }}</strong>
</div>
<div v-if="selectedItem.person" class="meta-item">
<label><v-icon name="person" x-small /> Zentrale Person</label>
<strong>{{ selectedItem.person.first_name }} {{ selectedItem.person.last_name }}</strong>
</div>
<div class="meta-item">
<label><v-icon name="link" x-small /> Source Path</label>
<span class="truncate-path" :title="selectedItem.url">{{ formatUrl(selectedItem.url) }}</span>
<v-button icon small @click="openExternal(selectedItem.url)"><v-icon name="launch" /></v-button>
</div>
<v-divider />
<div class="meta-item">
<label><v-icon name="layers" x-small /> Element Trace</label>
<code class="trace-code">{{ selectedItem.selector || 'Body' }}</code>
</div>
<div class="meta-item">
<label><v-icon name="location_searching" x-small /> Precise Mark</label>
<span class="coords">X: {{ Math.round(selectedItem.x) }}px / Y: {{ Math.round(selectedItem.y) }}px</span>
</div>
<div class="meta-item">
<label><v-icon name="fingerprint" x-small /> Reference ID</label>
<code class="id-code">{{ selectedItem.id }}</code>
</div>
</v-card-text>
</v-card>
<div class="help-box">
<v-icon name="help_outline" x-small />
<span>Click "Open & Highlight" to jump directly to this element on the live site.</span>
</div>
</aside>
</div>
</div>
<div v-else class="no-selection-desk">
<v-info icon="touch_app" title="Select Feedback" center>
Choose an entry from the triage list to view details and process.
</v-info>
</div>
</Transition>
</main>
</div>
</div>
</private-view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const api = useApi();
const items = ref([]);
const comments = ref([]);
const loading = ref(true);
const fetchError = ref(null);
const sending = ref(false);
const selectedItem = ref(null);
const currentProject = ref('all');
const currentStatusFilter = ref('open');
const replyText = ref('');
const statuses = [
{ text: 'Open', value: 'open', icon: 'warning', color: '#E91E63' },
{ text: 'In Progress', value: 'in_progress', icon: 'play_arrow', color: '#2196F3' },
{ text: 'Resolved', value: 'resolved', icon: 'check_circle', color: '#4CAF50' }
];
const statusOptions = [
{ text: 'All Statuses', value: 'all' },
...statuses
];
const projects = computed(() => {
const projSet = new Set(items.value.map(i => i.company?.name || i.project).filter(Boolean));
return Array.from(projSet).sort();
});
const filteredItems = computed(() => {
return items.value.filter(item => {
const projectName = item.company?.name || item.project;
const matchProject = currentProject.value === 'all' || projectName === currentProject.value;
const status = item.status || 'open';
const matchStatus = currentStatusFilter.value === 'all' || status === currentStatusFilter.value;
return matchProject && matchStatus;
});
});
async function fetchData() {
loading.value = true;
fetchError.value = null;
try {
const response = await api.get('/items/visual_feedback', {
params: {
sort: '-date_created,-id',
limit: 300,
fields: ['*', 'company.*', 'person.*']
}
});
items.value = response.data.data;
} catch (e: any) {
fetchError.value = e.message;
} finally {
loading.value = false;
}
}
async function selectItem(item) {
selectedItem.value = null;
setTimeout(async () => {
selectedItem.value = item;
comments.value = [];
try {
const response = await api.get('/items/visual_feedback_comments', {
params: {
filter: { feedback_id: { _eq: item.id } },
sort: '-date_created,-id',
fields: ['*', 'person.*']
}
});
comments.value = response.data.data;
} catch (e) {
console.error(e);
}
}, 10);
}
async function updateStatus(val) {
if (!selectedItem.value) return;
try {
await api.patch(`/items/visual_feedback/${selectedItem.value.id}`, {
status: val
});
fetchData();
} catch (e) {
console.error(e);
}
}
async function sendReply() {
if (!replyText.value.trim() || !selectedItem.value) return;
sending.value = true;
try {
const response = await api.post('/items/visual_feedback_comments', {
feedback_id: selectedItem.value.id,
user_name: 'Operator',
text: replyText.value
});
comments.value.unshift(response.data.data);
replyText.value = '';
} catch (e) {
console.error(e);
} finally {
sending.value = false;
}
}
function formatDate(dateStr) {
if (!dateStr || typeof dateStr === 'number') return 'Legacy';
return new Date(dateStr).toLocaleDateString() + ' ' + new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatUrl(url) {
if (!url) return '';
return url.replace(/^https?:\/\//, '');
}
function capitalize(s) {
return s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ');
}
function getDeepLinkUrl(item) {
if (!item || !item.url) return '';
try {
const url = new URL(item.url);
url.searchParams.set('fb_id', item.id);
return url.toString();
} catch (e) {
return item.url + '?fb_id=' + item.id;
}
}
function openDeepLink(item) {
const url = getDeepLinkUrl(item);
if (url) window.open(url, '_blank');
}
function openExternal(url) {
if (url) window.open(url, '_blank');
}
function getAssetUrl(id) {
if (!id) return '';
return `/assets/${id}`;
}
function getStatusColor(status) {
const s = statuses.find(st => st.value === status);
return s ? s.color : 'var(--foreground-subdued)';
}
onMounted(() => {
fetchData();
});
</script>
<style scoped>
.feedback-container {
height: calc(100vh - 64px);
display: flex;
flex-direction: column;
background: var(--background-subdued);
}
.operational-layout {
display: flex;
height: 100%;
}
/* Triage Lane Polish */
.triage-lane {
width: 360px;
height: 100%;
display: flex;
flex-direction: column;
background: var(--background-normal);
border-right: 1px solid var(--border-normal);
box-shadow: 2px 0 8px rgba(0,0,0,0.02);
}
.lane-header {
padding: 16px;
background: var(--background-normal);
border-bottom: 1px solid var(--border-normal);
}
.lane-content {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.feedback-card {
background: var(--background-normal);
border: 1px solid var(--border-subdued);
border-radius: 8px;
display: flex;
overflow: hidden;
cursor: pointer;
transition: all var(--transition);
}
.feedback-card:hover {
border-color: var(--border-normal);
background: var(--background-subdued);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.04);
}
.feedback-card.active {
border-color: var(--primary);
background: var(--background-accent);
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.1);
}
.card-status-bar {
width: 4px;
}
.card-body {
flex: 1;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-header {
display: flex;
justify-content: space-between;
font-size: 11px;
}
.card-user { font-weight: bold; color: var(--foreground-normal); }
.card-date { color: var(--foreground-subdued); }
.card-text {
font-size: 13px;
line-height: 1.5;
color: var(--foreground-normal);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.meta-tags {
display: flex;
gap: 8px;
align-items: center;
}
/* Processing Desk Refinement */
.processing-desk {
flex: 1;
height: 100%;
overflow-y: auto;
padding: 32px;
}
.desk-content {
max-width: 1100px;
margin: 0 auto;
}
.desk-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 32px;
border-bottom: 2px solid var(--border-normal);
padding-bottom: 20px;
}
.headline-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
color: var(--foreground-subdued);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-text { letter-spacing: 0.5px; }
.header-actions {
display: flex;
gap: 16px;
align-items: center;
}
.desk-grid {
display: grid;
grid-template-columns: 1fr 300px;
gap: 24px;
align-items: start;
}
.content-card {
border-radius: 12px;
overflow: hidden;
}
.feedback-body {
font-size: 18px;
line-height: 1.6;
padding: 24px;
color: var(--foreground-normal);
display: flex;
flex-direction: column;
gap: 20px;
}
.visual-proof {
display: flex;
flex-direction: column;
gap: 8px;
}
.proof-label {
font-size: 10px;
text-transform: uppercase;
font-weight: 800;
color: var(--foreground-subdued);
letter-spacing: 0.5px;
}
.screenshot-img {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border-normal);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
background: var(--background-subdued);
}
.main-text {
white-space: pre-wrap;
}
.reply-section {
margin-top: 40px;
}
.section-divider {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.divider-label {
font-size: 11px;
text-transform: uppercase;
font-weight: 800;
color: var(--foreground-subdued);
white-space: nowrap;
letter-spacing: 1px;
}
.thread {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 32px;
}
.reply-bubble {
padding: 16px;
border-radius: 12px;
background: var(--background-normal);
border: 1px solid var(--border-subdued);
}
.reply-header {
display: flex;
justify-content: space-between;
font-size: 11px;
margin-bottom: 8px;
}
.reply-user { font-weight: 800; color: var(--primary); }
.reply-date { color: var(--foreground-subdued); }
.reply-text { font-size: 14px; line-height: 1.5; }
.composer {
background: var(--background-normal);
border: 1px solid var(--border-normal);
border-radius: 12px;
padding: 16px;
}
.composer-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.meta-card {
border-radius: 12px;
}
.meta-list {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.meta-item {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.meta-item label {
font-size: 10px;
text-transform: uppercase;
font-weight: bold;
color: var(--foreground-subdued);
display: flex;
align-items: center;
gap: 4px;
}
.truncate-path {
color: var(--primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trace-code, .id-code {
background: var(--background-subdued);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
word-break: break-all;
}
.coords { font-weight: bold; font-family: var(--family-monospace); }
.help-box {
margin-top: 20px;
padding: 16px;
background: rgba(var(--primary-rgb), 0.05);
border-radius: 12px;
font-size: 12px;
color: var(--primary);
display: flex;
gap: 8px;
line-height: 1.4;
}
.no-selection-desk {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state-mini {
text-align: center;
padding: 24px;
font-size: 12px;
color: var(--foreground-subdued);
background: var(--background-subdued);
border-radius: 12px;
border: 1px dashed var(--border-normal);
}
/* Animations */
.list-enter-active, .list-leave-active { transition: all 0.3s ease; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(-20px); }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.fade-enter-from { opacity: 0; transform: translateY(10px); }
.fade-leave-to { opacity: 0; transform: translateY(-10px); }
.thread-list-enter-active { transition: all 0.4s ease; transform-origin: top; }
.thread-list-enter-from { opacity: 0; transform: scaleY(0.9); }
.scrollbar::-webkit-scrollbar { width: 6px; }
.scrollbar::-webkit-scrollbar-track { background: transparent; }
.scrollbar::-webkit-scrollbar-thumb { background: var(--border-subdued); border-radius: 3px; }
.scrollbar::-webkit-scrollbar-thumb:hover { background: var(--border-normal); }
</style>

View File

@@ -3,6 +3,7 @@ import { NextConfig } from "next";
const nextConfig: NextConfig = {
basePath: '/gatekeeper',
output: 'standalone',
};
export default mintelNextConfig(nextConfig);

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/gatekeeper",
"version": "1.8.16",
"version": "1.9.8",
"private": true,
"type": "module",
"scripts": {
@@ -12,13 +12,11 @@
},
"dependencies": {
"@mintel/next-utils": "workspace:*",
"clsx": "^2.1.1",
"framer-motion": "^11.18.2",
"lucide-react": "^0.474.0",
"next": "16.1.6",
"next-intl": "^4.8.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0"
"react-dom": "^19.0.0"
},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",

View File

@@ -82,7 +82,7 @@ export async function GET(req: NextRequest) {
// Traefik ForwardAuth headers
const gatekeeperUrl =
process.env.NEXT_PUBLIC_BASE_URL || `${proto}://gatekeeper.${host}`;
process.env.GATEKEEPER_ORIGIN || process.env.NEXT_PUBLIC_BASE_URL || `${proto}://gatekeeper.${host}`;
const absoluteOriginalUrl = `${proto}://${host}${originalUrl}`;
const loginUrl = `${gatekeeperUrl}/login?redirect=${encodeURIComponent(absoluteOriginalUrl)}`;

View File

@@ -8,7 +8,7 @@
}
body {
@apply bg-white text-slate-800 font-serif antialiased selection:bg-slate-900 selection:text-white;
@apply bg-[#f5f5f7] text-black/80 font-serif antialiased selection:bg-black/10 selection:text-black;
line-height: 1.6;
}
@@ -18,15 +18,15 @@
h4,
h5,
h6 {
@apply font-sans font-bold text-slate-900 tracking-tighter;
@apply font-sans font-bold text-black tracking-tighter;
}
p {
@apply mb-4 text-base leading-relaxed text-slate-700;
@apply mb-4 text-base leading-relaxed text-black/50;
}
a {
@apply text-slate-900 hover:text-slate-700 transition-colors no-underline;
@apply text-black/50 hover:text-black transition-colors no-underline;
}
}
@@ -36,34 +36,58 @@
}
.btn {
@apply inline-flex items-center justify-center px-6 py-3 border border-slate-200 bg-white text-slate-600 font-sans font-bold text-sm uppercase tracking-widest rounded-full transition-all duration-500 ease-industrial hover:border-slate-400 hover:text-slate-900 hover:bg-slate-50 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-slate-100 active:translate-y-0 active:shadow-sm;
@apply inline-flex items-center justify-center px-6 py-3 border border-black/10 bg-white text-black/60 font-sans font-bold text-sm uppercase tracking-widest rounded-full transition-all duration-500 ease-industrial hover:border-black/20 hover:text-black hover:bg-white hover:-translate-y-0.5 hover:shadow-xl hover:shadow-black/5 active:translate-y-0 active:shadow-sm;
}
.btn-primary {
@apply border-slate-900 text-slate-900 hover:bg-slate-900 hover:text-white;
@apply border-black bg-black text-white hover:bg-black/85 hover:text-white;
}
}
/* Custom scrollbar */
/* Custom scrollbar - light theme */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
background: #f5f5f7;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
background: #d1d1d6;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
background: #b0b0b8;
}
/* Animations */
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shake {
0%,
100% {
@@ -79,6 +103,15 @@
}
}
.animate-fade-in {
animation: fade-in 0.8s ease-out forwards;
}
.animate-slide-up {
animation: slide-up 0.8s ease-out 0.2s forwards;
opacity: 0;
}
.animate-shake {
animation: shake 0.2s ease-in-out 0s 2;
}

View File

@@ -13,6 +13,17 @@ const newsreader = Newsreader({
export const metadata: Metadata = {
title: "Gatekeeper | Access Control",
description: "Mintel Infrastructure Protection",
openGraph: {
title: "Gatekeeper | Access Control",
description: "Mintel Infrastructure Protection",
siteName: "Mintel Gatekeeper",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "Gatekeeper | Access Control",
description: "Mintel Infrastructure Protection",
},
};
export default function RootLayout({

View File

@@ -1,7 +1,9 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { ArrowRight, ShieldCheck } from "lucide-react";
import { ShieldCheck } from "lucide-react";
import Image from "next/image";
import { GateScene } from "../../components/gate-scene";
import { AnimatedLoginForm } from "../../components/animated-login-form";
interface LoginPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
@@ -17,8 +19,8 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
async function login(formData: FormData) {
"use server";
const email = (formData.get("email") as string || "").trim();
const password = (formData.get("password") as string || "").trim();
const email = ((formData.get("email") as string) || "").trim();
const password = ((formData.get("password") as string) || "").trim();
const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel";
const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL;
@@ -116,7 +118,9 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
}
if (userIdentity) {
console.log(`[Login] Success: ${userIdentity} | Redirect: ${targetRedirect}`);
console.log(
`[Login] Success: ${userIdentity} | Redirect: ${targetRedirect}`,
);
const cookieStore = await cookies();
// Store identity in the cookie (simplified for now, ideally signed)
const sessionValue = JSON.stringify({
@@ -127,7 +131,9 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
const isDev = process.env.NODE_ENV === "development";
console.log(`[Login] Setting Cookie: ${authCookieName} | Domain: ${cookieDomain || "Default"}`);
console.log(
`[Login] Setting Cookie: ${authCookieName} | Domain: ${cookieDomain || "Default"}`,
);
cookieStore.set(authCookieName, sessionValue, {
httpOnly: true,
@@ -145,99 +151,81 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
}
return (
<div className="min-h-screen flex items-center justify-center relative bg-white font-serif antialiased overflow-hidden">
{/* Background Decor - Signature mintel.me style */}
<div
className="absolute inset-0 pointer-events-none opacity-[0.03] scale-[1.01]"
style={{
backgroundImage: `linear-gradient(to right, #000 1px, transparent 1px), linear-gradient(to bottom, #000 1px, transparent 1px)`,
backgroundSize: "clamp(30px, 8vw, 40px) clamp(30px, 8vw, 40px)",
}}
/>
<div className="min-h-screen flex items-center justify-center relative bg-[#f5f5f7] font-serif antialiased overflow-hidden selection:bg-black/10 selection:text-black">
{/* 3D Digital Gate Background */}
<GateScene />
<main className="relative z-10 w-full max-w-sm px-8 sm:px-6">
<div className="space-y-12 sm:space-y-16 animate-fade-in">
{/* Top Icon Box - Signature mintel.me Black Square */}
<main className="relative z-10 w-full max-w-[380px] px-6 sm:px-4 pb-24 sm:pb-32 pointer-events-auto">
<div className="space-y-10 animate-fade-in">
{/* Top Icon Box */}
<div className="flex justify-center">
<div className="w-16 h-16 bg-black rounded-xl flex items-center justify-center shadow-xl shadow-slate-100 hover:scale-105 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] rotate-2 hover:rotate-0">
<a
href="https://mintel.me"
target="_blank"
rel="noopener noreferrer"
className="w-14 h-14 bg-white rounded-2xl flex items-center justify-center shadow-lg shadow-black/[0.06] hover:scale-105 hover:shadow-black/10 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] border border-black/[0.06] hover:border-black/10"
>
<Image
src="/icon-white.svg"
src="/gatekeeper/icon-white.svg"
alt="Mintel"
width={32}
height={32}
className="w-8 h-8"
style={{ filter: "invert(1)" }}
width={28}
height={28}
className="w-7 h-7 opacity-80"
unoptimized
/>
</div>
</a>
</div>
<div className="space-y-12 animate-slide-up">
<div className="text-center space-y-4">
<h1 className="text-xs font-sans font-bold uppercase tracking-[0.4em] text-slate-900 border-b border-slate-50 pb-4 inline-block mx-auto min-w-[200px]">
{projectName} <span className="text-slate-300">Gatekeeper</span>
<div className="space-y-8 animate-slide-up">
<div className="text-center space-y-3">
<h1 className="text-[11px] font-sans font-bold uppercase tracking-[0.5em] text-black/80 pb-3 inline-block mx-auto min-w-[220px]">
{projectName} <span className="text-black/30">Gatekeeper</span>
</h1>
<p className="text-[10px] text-slate-400 font-sans uppercase tracking-widest italic flex items-center justify-center gap-2">
<span className="w-1 h-1 bg-slate-200 rounded-full" />
Infrastructure Protection
<span className="w-1 h-1 bg-slate-200 rounded-full" />
</p>
<div className="flex items-center justify-center gap-4">
<div className="h-px w-8 bg-gradient-to-r from-transparent to-black/10" />
<p className="text-[8px] text-black/30 font-sans uppercase tracking-[0.35em] font-semibold">
Infrastructure Protection
</p>
<div className="h-px w-8 bg-gradient-to-l from-transparent to-black/10" />
</div>
</div>
{error && (
<div className="bg-red-50 text-red-600 px-5 py-3 rounded-2xl text-[9px] font-sans font-bold uppercase tracking-widest flex items-center gap-3 border border-red-100 animate-shake">
<ShieldCheck className="w-4 h-4" />
<div className="bg-red-50 backdrop-blur-md text-red-600 px-5 py-4 rounded-2xl text-[9px] font-sans font-bold uppercase tracking-widest flex items-center gap-3 border border-red-200 animate-shake">
<ShieldCheck className="w-4 h-4 text-red-500/70" />
<span>Access Denied. Try Again.</span>
</div>
)}
<form action={login} className="space-y-4">
<input type="hidden" name="redirect" value={redirectUrl} />
{/* The Animated Framer Motion Form */}
<AnimatedLoginForm
redirectUrl={redirectUrl}
loginAction={login}
projectName={projectName}
/>
<div className="space-y-2">
<div className="relative group">
<input
type="email"
name="email"
placeholder="EMAIL (OPTIONAL)"
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-[10px] font-sans font-bold tracking-[0.2em] uppercase placeholder:text-slate-300 shadow-sm shadow-slate-50"
/>
</div>
<div className="relative group">
<input
type="password"
name="password"
required
autoFocus
autoComplete="current-password"
placeholder="ACCESS CODE"
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-sm font-sans font-bold tracking-[0.3em] uppercase placeholder:text-slate-300 placeholder:tracking-widest shadow-sm shadow-slate-50"
/>
</div>
</div>
<button
type="submit"
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100 flex items-center justify-center"
{/* Bottom Section */}
<div className="pt-4 sm:pt-6 flex flex-col items-center gap-5">
<div className="h-px w-16 bg-gradient-to-r from-transparent via-black/10 to-transparent" />
<a
href="https://mintel.me"
target="_blank"
rel="noopener noreferrer"
className="opacity-30 transition-opacity hover:opacity-60"
>
Unlock Access
<ArrowRight className="ml-3 w-3 h-3 group-hover:translate-x-1 transition-transform" />
</button>
</form>
{/* Bottom Section - Full Branding Parity */}
<div className="pt-12 sm:pt-20 flex flex-col items-center gap-6 sm:gap-8">
<div className="h-px w-8 bg-slate-100" />
<div className="opacity-80 transition-opacity hover:opacity-100">
<Image
src="/logo-black.svg"
src="/gatekeeper/logo-white.svg"
alt={projectName}
width={140}
height={40}
className="h-7 sm:h-auto grayscale contrast-125 w-auto"
width={120}
height={36}
className="h-5 sm:h-6 w-auto"
style={{ filter: "invert(1)" }}
unoptimized
/>
</div>
<p className="text-[8px] font-sans font-bold text-slate-300 uppercase tracking-[0.4em] sm:tracking-[0.5em] text-center">
&copy; 2026 MINTEL
</a>
<p className="text-[7px] font-sans font-semibold text-black/25 uppercase tracking-[0.5em] text-center">
&copy; {new Date().getFullYear()} MINTEL
</p>
</div>
</div>

View File

@@ -0,0 +1,126 @@
import { ImageResponse } from "next/og";
export const runtime = "edge";
// Image metadata
export const alt = "Gatekeeper Infrastructure Protection";
export const size = {
width: 1200,
height: 630,
};
export const contentType = "image/png";
export default async function Image() {
const projectName = process.env.PROJECT_NAME || "MINTEL";
return new ImageResponse(
<div
style={{
background: "linear-gradient(to bottom, #020617, #0f172a)",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
position: "relative",
fontFamily: "Inter, sans-serif",
}}
>
{/* Subtle Background Pattern matching the industrial look */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage:
"radial-gradient(circle at 50% 50%, #334155 1px, transparent 1px)",
backgroundSize: "40px 40px",
opacity: 0.1,
}}
/>
{/* Central Card Element */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "rgba(15, 23, 42, 0.6)",
border: "1px solid rgba(51, 65, 85, 0.4)",
borderRadius: "32px",
padding: "80px",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
}}
>
{/* Top Icon Box */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100px",
height: "100px",
background: "#000",
borderRadius: "24px",
border: "2px solid #334155",
boxShadow: "0 20px 40px rgba(0,0,0,0.8)",
marginBottom: "40px",
transform: "rotate(2deg)",
}}
>
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
</div>
{/* Project Name & Typography */}
<div
style={{
display: "flex",
fontSize: "64px",
fontWeight: 900,
letterSpacing: "0.2em",
color: "white",
textTransform: "uppercase",
marginBottom: "16px",
}}
>
{projectName}{" "}
<span style={{ color: "#64748b", marginLeft: "10px" }}>
GATEKEEPER
</span>
</div>
<div
style={{
display: "flex",
fontSize: "24px",
fontWeight: 600,
letterSpacing: "0.4em",
color: "#94a3b8",
textTransform: "uppercase",
}}
>
Infrastructure Protection
</div>
</div>
</div>,
{
...size,
},
);
}

View File

@@ -0,0 +1,283 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import { ArrowRight, Lock, Shield, Fingerprint } from "lucide-react";
interface AnimatedLoginFormProps {
redirectUrl: string;
loginAction: (formData: FormData) => Promise<void>;
projectName: string;
}
export function AnimatedLoginForm({
redirectUrl,
loginAction,
}: AnimatedLoginFormProps) {
const [isFocused, setIsFocused] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [mounted, setMounted] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const beamRef = useRef<HTMLDivElement>(null);
// Mouse tracking refs (no re-render)
const mouse = useRef({ x: 0, y: 0 });
const angle = useRef(0);
const tilt = useRef({ x: 0, y: 0 });
useEffect(() => {
setMounted(true);
}, []);
// Single rAF loop: iridescent border + perspective tilt
useEffect(() => {
let animId: number;
const onMouseMove = (e: MouseEvent) => {
mouse.current = { x: e.clientX, y: e.clientY };
};
window.addEventListener("mousemove", onMouseMove);
const animate = () => {
if (!wrapperRef.current || !beamRef.current) {
animId = requestAnimationFrame(animate);
return;
}
const rect = wrapperRef.current.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = mouse.current.x - cx;
const dy = mouse.current.y - cy;
// Angle from form center to mouse → positions the bright highlight
const targetAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
// Lerp angle smoothly (shortest path)
let diff = targetAngle - angle.current;
while (diff > 180) diff -= 360;
while (diff < -180) diff += 360;
angle.current += diff * 0.06;
// Intensity: slightly stronger on focus
const intensity = isFocused ? 1 : 0.7;
// Mouse-aligned iridescent conic gradient
// The "hotspot" (brightest white) faces the mouse
beamRef.current.style.background = `conic-gradient(from ${angle.current}deg at 50% 50%,
rgba(255,255,255,${1.0 * intensity}) 0deg,
rgba(200,210,255,${0.8 * intensity}) 20deg,
rgba(255,200,230,${0.7 * intensity}) 45deg,
rgba(150,160,180,${0.6 * intensity}) 80deg,
rgba(40,40,50,${0.5 * intensity}) 160deg,
rgba(20,20,30,${0.4 * intensity}) 200deg,
rgba(140,150,170,${0.5 * intensity}) 280deg,
rgba(210,225,255,${0.7 * intensity}) 320deg,
rgba(255,255,255,${1.0 * intensity}) 360deg)`;
// Subtle perspective tilt — max ±4deg
const maxTilt = 4;
const normX = dx / (rect.width * 2);
const normY = dy / (rect.height * 2);
const targetTiltY = normX * maxTilt;
const targetTiltX = -normY * maxTilt;
tilt.current.x += (targetTiltX - tilt.current.x) * 0.08;
tilt.current.y += (targetTiltY - tilt.current.y) * 0.08;
wrapperRef.current.style.transform = `perspective(800px) rotateX(${tilt.current.x}deg) rotateY(${tilt.current.y}deg)`;
animId = requestAnimationFrame(animate);
};
animId = requestAnimationFrame(animate);
return () => {
window.removeEventListener("mousemove", onMouseMove);
cancelAnimationFrame(animId);
};
}, [isFocused]);
const handleSubmit = async (formData: FormData) => {
setIsSubmitting(true);
try {
await loginAction(formData);
} finally {
setIsSubmitting(false);
}
};
return (
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.8, ease: [0.23, 1, 0.32, 1], delay: 0.3 }}
className="relative"
style={{ willChange: "transform" }}
>
{/* Outer wrapper for tilt (refs need non-motion div) */}
<div
ref={wrapperRef}
style={{ willChange: "transform", transformStyle: "preserve-3d" }}
className="relative"
>
{/* ── Always-on iridescent beam border ── */}
<div className="absolute -inset-[1.5px] rounded-[28px] z-0 overflow-hidden">
{/* Sharp edge layer */}
<div
ref={beamRef}
className="absolute inset-0 rounded-[28px]"
style={{ filter: "blur(0.4px)" }}
/>
{/* Soft glow bloom */}
<div
className="absolute inset-0 rounded-[28px]"
style={{
background: "inherit",
filter: "blur(15px)",
opacity: 0.5,
}}
/>
</div>
{/* ── Glassmorphism card — high-fidelity glossy light ── */}
<div
className="relative z-10 bg-white/70 backdrop-blur-3xl rounded-3xl p-7 sm:p-9"
style={{
boxShadow:
/* Razor-sharp inner border highlight */ "inset 0 0 0 1px rgba(255,255,255,0.7), " +
/* Top gloss edge */ "inset 0 1.5px 0.5px rgba(255,255,255,1), " +
/* Secondary soft top gloss */ "inset 0 4px 10px rgba(255,255,255,0.4), " +
/* Bottom inner shadow */ "inset 0 -1px 1px rgba(0,0,0,0.05), " +
/* Outer drop shadows for depth */ "0 25px 50px -12px rgba(0,0,0,0.08), 0 4px 8px rgba(0,0,0,0.02)",
}}
>
{/* Subtle surface "sheen" gradient */}
<div className="absolute inset-0 rounded-3xl bg-gradient-to-br from-white/40 via-transparent to-black/[0.02] pointer-events-none" />
{/* Shield icon header */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.5, duration: 0.6 }}
className="flex justify-center mb-6"
>
<div className="relative">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-black/[0.04] to-black/[0.08] border border-black/[0.06] flex items-center justify-center backdrop-blur-sm">
<Shield className="w-5 h-5 text-black/40" />
</div>
{mounted && (
<div className="absolute -inset-2 rounded-2xl border border-black/[0.04] animate-ping opacity-30" />
)}
</div>
</motion.div>
<form
action={handleSubmit}
className="space-y-5 relative z-10"
onFocus={() => setIsFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsFocused(false);
}
}}
>
<input type="hidden" name="redirect" value={redirectUrl} />
<div className="space-y-3">
{/* Email Input */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6, duration: 0.5 }}
className="relative group"
>
<Fingerprint className="absolute left-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-black/20 group-focus-within:text-black/50 transition-colors duration-300 pointer-events-none" />
<input
type="email"
name="email"
placeholder="Identity (optional)"
onFocus={() => setIsFocused(true)}
className="w-full bg-black/[0.02] border border-black/[0.06] rounded-2xl pl-11 pr-4 py-3.5 focus:outline-none focus:border-black/15 focus:bg-white/80 transition-all duration-300 text-[11px] font-sans font-medium tracking-[0.08em] placeholder:text-black/25 placeholder:normal-case placeholder:tracking-normal text-black/70"
/>
<div className="absolute bottom-0 left-4 right-4 h-px bg-gradient-to-r from-transparent via-black/0 to-transparent group-focus-within:via-black/10 transition-all duration-500" />
</motion.div>
{/* Password Input */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.7, duration: 0.5 }}
className="relative group"
>
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-black/20 group-focus-within:text-black/50 transition-colors duration-300 pointer-events-none" />
<input
type="password"
name="password"
required
autoFocus
autoComplete="current-password"
placeholder="Access code"
onFocus={() => setIsFocused(true)}
className="w-full bg-black/[0.02] border border-black/[0.06] rounded-2xl pl-11 pr-4 py-3.5 focus:outline-none focus:border-black/15 focus:bg-white/80 transition-all duration-300 text-[13px] font-sans font-medium tracking-[0.15em] placeholder:text-black/25 placeholder:tracking-normal placeholder:text-[11px] placeholder:font-normal text-black/80"
/>
<div className="absolute bottom-0 left-4 right-4 h-px bg-gradient-to-r from-transparent via-black/0 to-transparent group-focus-within:via-black/10 transition-all duration-500" />
</motion.div>
</div>
{/* Submit Button */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8, duration: 0.5 }}
>
<motion.button
type="submit"
disabled={isSubmitting}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.98 }}
className={`relative w-full py-4 rounded-2xl text-[10px] font-bold tracking-[0.3em] uppercase flex items-center justify-center overflow-hidden transition-all duration-300 ${
isSubmitting
? "bg-black/5 text-black/25 cursor-not-allowed"
: "bg-black text-white hover:bg-black/85 shadow-lg shadow-black/10"
}`}
>
{!isSubmitting && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full hover:translate-x-full transition-transform duration-700" />
)}
{isSubmitting ? (
<span className="flex items-center gap-3 relative z-10">
<motion.div
animate={{ rotate: 360 }}
transition={{
repeat: Infinity,
duration: 1,
ease: "linear",
}}
className="w-4 h-4 border-2 border-white/20 border-t-white/70 rounded-full"
/>
Authenticating...
</span>
) : (
<span className="flex items-center gap-3 relative z-10">
Unlock Access
<ArrowRight className="w-4 h-4" />
</span>
)}
</motion.button>
</motion.div>
</form>
{/* Security badge */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.0, duration: 0.5 }}
className="mt-5 flex items-center justify-center gap-2 text-[8px] font-sans font-semibold text-black/25 uppercase tracking-[0.3em]"
>
<div className="w-1 h-1 rounded-full bg-black/20 animate-pulse" />
Encrypted Connection
</motion.div>
</div>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useEffect, useRef } from "react";
export function GateScene() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const mouse = useRef({ x: -1000, y: -1000 });
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const FONT_SIZE = 13;
const COL_GAP = 18;
const ROW_GAP = 20;
interface Cell {
char: string;
alpha: number;
targetAlpha: number;
speed: number;
nextChange: number;
}
let cells: Cell[][] = [];
let cols = 0;
let rows = 0;
let animId: number;
const init = () => {
cols = Math.ceil(canvas.width / COL_GAP);
rows = Math.ceil(canvas.height / ROW_GAP);
cells = Array.from({ length: cols }, () =>
Array.from({ length: rows }, () => ({
char: Math.random() > 0.5 ? "1" : "0",
alpha: Math.random() * 0.08,
targetAlpha: Math.random() * 0.15,
speed: 0.008 + Math.random() * 0.02,
nextChange: Math.floor(Math.random() * 80),
})),
);
};
const onMouseMove = (e: MouseEvent) => {
mouse.current = { x: e.clientX, y: e.clientY };
};
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
init();
};
let frame = 0;
const draw = () => {
frame++;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = `${FONT_SIZE}px 'Courier New', monospace`;
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) {
const cell = cells[c][r];
const x = c * COL_GAP;
const y = r * ROW_GAP + FONT_SIZE;
// Mouse proximity influence
const dx = mouse.current.x - x;
const dy = mouse.current.y - y;
const distSq = dx * dx + dy * dy;
const proximity = Math.max(0, 1 - Math.sqrt(distSq) / 250);
// Nudge alpha toward target
cell.alpha += (cell.targetAlpha - cell.alpha) * cell.speed;
// More aggressive random behavior
if (frame >= cell.nextChange) {
cell.targetAlpha = Math.random() * 0.25; // Higher max alpha
cell.speed = 0.01 + Math.random() * 0.03; // Faster transitions
cell.nextChange = frame + 20 + Math.floor(Math.random() * 80); // More frequent changes
// Higher flip probability near mouse
const flipProb = 0.3 + proximity * 0.5;
if (Math.random() < flipProb) {
cell.char = cell.char === "0" ? "1" : "0";
}
}
const a = Math.min(0.4, cell.alpha + proximity * 0.35);
if (a < 0.01) continue;
// Dark chars on light background
ctx.fillStyle = `rgba(0, 0, 0, ${a})`;
ctx.fillText(cell.char, x, y);
}
}
animId = requestAnimationFrame(draw);
};
resize();
window.addEventListener("resize", resize);
window.addEventListener("mousemove", onMouseMove);
animId = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener("resize", resize);
window.removeEventListener("mousemove", onMouseMove);
};
}, []);
return (
<div className="absolute inset-0 pointer-events-none z-0 bg-[#f5f5f7]">
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
<div
className="absolute inset-0 pointer-events-none"
style={{
background:
"radial-gradient(ellipse 55% 65% at 50% 50%, rgba(245,245,247,0.7) 0%, transparent 100%)",
}}
/>
</div>
);
}

View File

@@ -0,0 +1,19 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable pnpm && pnpm install
COPY tsconfig.json ./
COPY src ./src
RUN pnpm build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/package.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
# Use node to run the compiled index.js
ENTRYPOINT ["node", "dist/index.js"]

View File

@@ -0,0 +1,20 @@
{
"name": "@mintel/gitea-mcp",
"version": "1.9.8",
"description": "Native Gitea MCP server for 100% Antigravity compatibility",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.5.0",
"zod": "^3.23.8",
"axios": "^1.7.2"
},
"devDependencies": {
"typescript": "^5.5.3",
"@types/node": "^20.14.10"
}
}

View File

@@ -0,0 +1,266 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
SubscribeRequestSchema,
UnsubscribeRequestSchema,
Tool,
Resource,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import axios from "axios";
const GITEA_HOST = process.env.GITEA_HOST || "https://git.infra.mintel.me";
const GITEA_ACCESS_TOKEN = process.env.GITEA_ACCESS_TOKEN;
if (!GITEA_ACCESS_TOKEN) {
console.error("Error: GITEA_ACCESS_TOKEN environment variable is required");
process.exit(1);
}
const giteaClient = axios.create({
baseURL: `${GITEA_HOST.replace(/\/$/, '')}/api/v1`,
headers: {
Authorization: `token ${GITEA_ACCESS_TOKEN}`,
},
});
const LIST_PIPELINES_TOOL: Tool = {
name: "gitea_list_pipelines",
description: "List recent action runs (pipelines) for a specific repository",
inputSchema: {
type: "object",
properties: {
owner: { type: "string", description: "Repository owner (e.g., 'mmintel')" },
repo: { type: "string", description: "Repository name (e.g., 'at-mintel')" },
limit: { type: "number", description: "Number of runs to fetch (default: 5)" },
},
required: ["owner", "repo"],
},
};
const GET_PIPELINE_LOGS_TOOL: Tool = {
name: "gitea_get_pipeline_logs",
description: "Get detailed logs for a specific pipeline run or job",
inputSchema: {
type: "object",
properties: {
owner: { type: "string", description: "Repository owner" },
repo: { type: "string", description: "Repository name" },
run_id: { type: "number", description: "ID of the action run" },
},
required: ["owner", "repo", "run_id"],
},
};
// Subscription State
const subscriptions = new Set<string>();
const runStatusCache = new Map<string, string>(); // uri -> status
const server = new Server(
{
name: "gitea-mcp-native",
version: "1.0.0",
},
{
capabilities: {
tools: {},
resources: { subscribe: true }, // Enable subscriptions
},
}
);
// --- Tools ---
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [LIST_PIPELINES_TOOL, GET_PIPELINE_LOGS_TOOL],
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "gitea_list_pipelines") {
// ... (Keeping exact same implementation as before for brevity)
const { owner, repo, limit = 5 } = request.params.arguments as any;
try {
const runsResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs`, {
params: { limit },
});
const runs = (runsResponse.data.workflow_runs || []) as any[];
const enhancedRuns = await Promise.all(
runs.map(async (run: any) => {
try {
const jobsResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run.id}/jobs`);
const jobs = (jobsResponse.data.jobs || []) as any[];
return {
id: run.id,
name: run.name,
status: run.status,
created_at: run.created_at,
jobs: jobs.map((job: any) => ({
id: job.id,
name: job.name,
status: job.status,
conclusion: job.conclusion
}))
};
} catch (e) {
return { id: run.id, name: run.name, status: run.status, created_at: run.created_at, jobs: [] };
}
})
);
return {
content: [{ type: "text", text: JSON.stringify(enhancedRuns, null, 2) }],
};
} catch (error: any) {
return { isError: true, content: [{ type: "text", text: `Error fetching pipelines: ${error.message}` }] };
}
}
if (request.params.name === "gitea_get_pipeline_logs") {
const { owner, repo, run_id } = request.params.arguments as any;
try {
const jobsResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}/jobs`);
const jobs = (jobsResponse.data.jobs || []) as any[];
const logs = jobs.map((job: any) => ({
job_id: job.id,
job_name: job.name,
status: job.status,
conclusion: job.conclusion,
steps: (job.steps || []).map((step: any) => ({
name: step.name,
status: step.status,
conclusion: step.conclusion
}))
}));
return { content: [{ type: "text", text: JSON.stringify(logs, null, 2) }] };
} catch (error: any) {
return { isError: true, content: [{ type: "text", text: `Error: ${error.message}` }] };
}
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
// --- Resources & Subscriptions ---
// We will expose a dynamic resource URI pattern: gitea://runs/{owner}/{repo}/{run_id}
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: "gitea://runs",
name: "Gitea Pipeline Runs",
description: "Dynamic resource for subscribing to pipeline runs. Format: gitea://runs/{owner}/{repo}/{run_id}",
mimeType: "application/json",
}
],
};
});
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const uri = request.params.uri;
const match = uri.match(/^gitea:\/\/runs\/([^\/]+)\/([^\/]+)\/(\d+)$/);
if (!match) {
throw new Error(`Invalid resource URI. Must be gitea://runs/{owner}/{repo}/{run_id}`);
}
const [, owner, repo, run_id] = match;
try {
const runResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}`);
const jobsResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}/jobs`);
const resourceContent = {
run: runResponse.data,
jobs: jobsResponse.data
};
// Update internal cache when read
runStatusCache.set(uri, runResponse.data.status);
return {
contents: [
{
uri,
mimeType: "application/json",
text: JSON.stringify(resourceContent, null, 2),
},
],
};
} catch (error: any) {
throw new Error(`Failed to read Gitea resource: ${error.message}`);
}
});
server.setRequestHandler(SubscribeRequestSchema, async (request) => {
const uri = request.params.uri;
if (!uri.startsWith("gitea://runs/")) {
throw new Error("Only gitea://runs resources can be subscribed to");
}
subscriptions.add(uri);
console.error(`Client subscribed to ${uri}`);
return {};
});
server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
const uri = request.params.uri;
subscriptions.delete(uri);
console.error(`Client unsubscribed from ${uri}`);
return {};
});
// The server polling mechanism that pushes updates to subscribed clients
async function pollSubscriptions() {
for (const uri of subscriptions) {
const match = uri.match(/^gitea:\/\/runs\/([^\/]+)\/([^\/]+)\/(\d+)$/);
if (!match) continue;
const [, owner, repo, run_id] = match;
try {
const runResponse = await giteaClient.get(`/repos/${owner}/${repo}/actions/runs/${run_id}`);
const currentStatus = runResponse.data.status;
const prevStatus = runStatusCache.get(uri);
// If status changed (e.g. running -> completed), notify client
if (prevStatus !== currentStatus) {
runStatusCache.set(uri, currentStatus);
server.notification({
method: "notifications/resources/updated",
params: { uri }
});
console.error(`Pushed update for ${uri}: ${prevStatus} -> ${currentStatus}`);
// Auto-unsubscribe if completed/failed so we don't poll forever?
// Let the client decide, or we can handle it here if requested.
}
} catch (e) {
console.error(`Error polling subscription ${uri}:`, e);
}
}
// Poll every 5 seconds
setTimeout(pollSubscriptions, 5000);
}
async function run() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Gitea MCP Native Server running on stdio");
// Start the background poller
pollSubscriptions();
}
run().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});

Some files were not shown because too many files have changed in this diff Show More