Compare commits

..

124 Commits

Author SHA1 Message Date
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
590ae6f69b chore: sync versions to v1.8.16
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Failing after 29s
Monorepo Pipeline / 🧹 Lint (push) Failing after 21s
Monorepo Pipeline / 🏗️ Build (push) Failing after 8s
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:24:30 +01:00
2a169f1dfc feat(image-processor): switch to OpenRouter Vision for smart crop and remove heavy models 2026-02-22 23:24:22 +01:00
1bbe89c879 chore: sync versions to v1.8.15
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 4s
Monorepo Pipeline / 🧪 Test (push) Successful in 5m30s
Monorepo Pipeline / 🏗️ Build (push) Successful in 7m42s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m5s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 1m4s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 1m31s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 59s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m52s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 4m32s
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been cancelled
2026-02-22 23:07:34 +01:00
554ca81c9b chore(image-processor): fix tfjs-node cross compile arch flags 2026-02-22 23:07:32 +01:00
aac0fe81b9 fix(image-service): enforce arm64 cpu architecture for tfjs-node in dockerfile
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 4m59s
Monorepo Pipeline / 🧹 Lint (push) Successful in 6m11s
Monorepo Pipeline / 🏗️ Build (push) Successful in 9m49s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 2m13s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m6s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 1m26s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 23s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 6m17s
Monorepo Pipeline / 🐳 Build Image Processor (push) Successful in 16m2s
2026-02-22 22:44:03 +01:00
ada1e9c717 fix(image-service): force rebuild tfjs-node for container architecture in Dockerfile
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 5m5s
Monorepo Pipeline / 🧹 Lint (push) Successful in 6m36s
Monorepo Pipeline / 🏗️ Build (push) Successful in 10m21s
Monorepo Pipeline / 🐳 Build Image Processor (push) Successful in 5m10s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 1m56s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 2m38s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 1m25s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 7m34s
Monorepo Pipeline / 🚀 Release (push) Successful in 9m13s
2026-02-22 22:29:25 +01:00
4d295d10d1 chore: sync versions to v1.8.12
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 3m55s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m11s
Monorepo Pipeline / 🏗️ Build (push) Successful in 5m59s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 1m12s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 1m39s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 1m29s
Monorepo Pipeline / 🐳 Build Image Processor (push) Successful in 5m35s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 6m14s
Monorepo Pipeline / 🚀 Release (push) Successful in 7m4s
2026-02-22 22:14:44 +01:00
c00f4e5ea5 fix(image-service): resolve next.js build crash and strict TS lint warnings for ci deploy 2026-02-22 22:14:35 +01:00
5f7a254fcb chore: sync versions to v1.8.11
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 6s
Monorepo Pipeline / 🏗️ Build (push) Failing after 2m56s
Monorepo Pipeline / 🧪 Test (push) Successful in 4m2s
Monorepo Pipeline / 🧹 Lint (push) Failing after 4m37s
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 21:59:19 +01:00
21c0c778f9 feat(image-service): standalone processor 2026-02-22 21:59:14 +01:00
4f6d62a85c fix(image-service): Remove tfjs-node from pnpm rebuild to preserve ARM64 binary
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Failing after 59s
Monorepo Pipeline / 🧪 Test (push) Successful in 2m0s
Monorepo Pipeline / 🏗️ Build (push) Failing after 4m13s
Monorepo Pipeline / 🚀 Release (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 21:21:45 +01:00
7d9604a65a chore: sync versions to v1.8.6
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧹 Lint (push) Failing after 4m37s
Monorepo Pipeline / 🧪 Test (push) Successful in 4m37s
Monorepo Pipeline / 🏗️ Build (push) Failing after 2m16s
Monorepo Pipeline / 🚀 Release (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 18:53:51 +01:00
b3d089ac6d feat(content-engine): enhance content pruning rule in orchestrator
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been cancelled
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (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
2026-02-22 18:53:17 +01:00
baecc9c83c feat: content engine 2026-02-22 18:33:58 +01:00
d5632b009a feat(content-engine): add autonomous validation layer to actively detect and correct hallucinated meme templates without user intervention
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m0s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m46s
Monorepo Pipeline / 🏗️ Build (push) Successful in 4m49s
Monorepo Pipeline / 🚀 Release (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 18:23:44 +01:00
90a9e34c7e fix(journaling): enforce stricter LLM evaluation rules for YouTube video selection
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 2m53s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m29s
Monorepo Pipeline / 🧹 Lint (push) Successful in 3m37s
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-22 18:07:50 +01:00
99f040cfb0 feat(ai): forcefully randomize meme templates and expand B2B YouTube channels
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 2m38s
Monorepo Pipeline / 🏗️ Build (push) Successful in 6m23s
Monorepo Pipeline / 🚀 Release (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 17:55:09 +01:00
02bffbc67f feat(journaling): implement secondary LLM validation for YouTube video selection
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m6s
Monorepo Pipeline / 🧹 Lint (push) Failing after 3m2s
Monorepo Pipeline / 🏗️ Build (push) Successful in 5m24s
Monorepo Pipeline / 🚀 Release (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 17:43:37 +01:00
f4507ef121 fix(journaling): optimize serper video search queries to prevent MDX hallucination
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 59s
Monorepo Pipeline / 🧹 Lint (push) Failing after 2m0s
Monorepo Pipeline / 🏗️ Build (push) Successful in 5m9s
Monorepo Pipeline / 🚀 Release (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 17:35:38 +01:00
3a1a88db89 feat: content engine
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m12s
Monorepo Pipeline / 🧪 Test (push) Successful in 2m59s
Monorepo Pipeline / 🏗️ Build (push) Successful in 6m52s
Monorepo Pipeline / 🚀 Release (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-22 02:39:27 +01:00
a9adb2eff7 fix(ci): disable provenance to prevent manifest unknown on pull
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m0s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m23s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m59s
Monorepo Pipeline / 🚀 Release (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-21 20:51:12 +01:00
a50b8d6393 feat: content engine 2026-02-21 19:08:06 +01:00
3f1c37813a fix(ci): bypass buildx cache and ignore pnpm store to resolve EOF corruption
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 58s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m15s
Monorepo Pipeline / 🏗️ Build (push) Successful in 4m14s
Monorepo Pipeline / 🚀 Release (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-21 15:30:59 +01:00
8f32c80801 chore: optimize cms startup, refactor scripts and implement real-time dev mode 2026-02-16 18:23:38 +01:00
67750c886e chore: ignore and untrack cms-infra uploads 2026-02-15 18:45:57 +01:00
9fe9a74e71 chore: ignore and untrack generated directus extensions 2026-02-15 18:45:05 +01:00
92fe089619 chore: fix syntax error in pdf-library build script
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 22s
Monorepo Pipeline / 🧹 Lint (push) Successful in 3m45s
Monorepo Pipeline / 🧪 Test (push) Successful in 3m45s
Monorepo Pipeline / 🏗️ Build (push) Successful in 4m47s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 55s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 56s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 55s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m24s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 5m2s
2026-02-15 17:52:06 +01:00
7dcef0bc28 chore: fix unrelated lint errors to unblock release CI
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 52s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m0s
Monorepo Pipeline / 🧪 Test (push) Successful in 4m2s
Monorepo Pipeline / 🚀 Release (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 6s
2026-02-15 17:51:59 +01:00
2ba091f738 chore: sync versions to v1.8.10
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🚀 Release (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 / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
2026-02-15 17:49:11 +01:00
5757c1172b fix(next-feedback): strengthen embedded detection to prevent record-mode conflicts
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🏗️ Build (push) Has started running
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🚀 Release (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 / 🧪 Test (push) Has been cancelled
2026-02-15 17:49:07 +01:00
e7d5798857 feat(next-feedback): implement transparent embedded isolation check
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Failing after 13m2s
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
Monorepo Pipeline / 🚀 Release (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
2026-02-15 17:05:32 +01:00
29a414f385 fix(qa): resolve lint errors and unused variables across packages
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 7m9s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m18s
Monorepo Pipeline / 🏗️ Build (push) Successful in 8m33s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 53s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 57s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 55s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m22s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 4m53s
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 5s
2026-02-14 15:34:54 +01:00
69764e42c6 fix(pipeline): improve prioritization to prevent redundant branch and tag runs
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🏗️ Build (push) Failing after 23m30s
Monorepo Pipeline / 🧪 Test (push) Failing after 24m32s
Monorepo Pipeline / 🧹 Lint (push) Failing after 24m34s
Monorepo Pipeline / 🚀 Release (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
2026-02-14 14:00:08 +01:00
d69ade6268 chore: update lockfile and commit all pending release fixes
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 2m2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m14s
Monorepo Pipeline / 🚀 Release (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-14 13:57:46 +01:00
ceaf3ae3ea chore: sync versions to 1.8.4
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 4s
Monorepo Pipeline / 🧹 Lint (push) Failing after 26s
Monorepo Pipeline / 🧪 Test (push) Failing after 27s
Monorepo Pipeline / 🏗️ Build (push) Failing after 25s
Monorepo Pipeline / 🚀 Release (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-14 13:39:39 +01:00
169cb83f69 fix(pipeline): allow all tags and chore commits for releases
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m5s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m13s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m53s
Monorepo Pipeline / 🚀 Release (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-14 13:39:26 +01:00
f831a7e67e chore(next-feedback): bump to 1.8.4 and export FeedbackOverlay from root
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 27s
Monorepo Pipeline / 🧪 Test (push) Successful in 57s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m53s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m23s
Monorepo Pipeline / 🚀 Release (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-14 12:48:52 +01:00
cb4ffcaeda feat(next-feedback): convert FeedbackOverlay to controlled component
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 50s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m46s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m46s
Monorepo Pipeline / 🚀 Release (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 5s
2026-02-14 02:05:02 +01:00
9b1f3fb7e8 feat(next-feedback): add onActiveChange prop for controlled activation
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m1s
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (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-14 02:03:13 +01:00
f48d89c368 chore: comprehensive commit of all debugging, infrastructure, and extension fixes
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 6s
Monorepo Pipeline / 🧪 Test (push) Successful in 56s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m22s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m51s
Monorepo Pipeline / 🚀 Release (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
Summary of changes:
- Corrected Directus extensions to use 'vue-router' for 'useRouter' instead of '@directus/extensions-sdk' (Fixed runtime crash).
- Standardized extension folder structure and moved built extensions to the root 'directus/extensions' directory.
- Updated 'scripts/sync-extensions.sh' and 'scripts/validate-extensions.sh' for better extension management.
- Added 'scripts/validate-sdk-imports.sh' as a safeguard against future invalid SDK imports.
- Integrated import validation into the '.husky/pre-push' hook.
- Standardized Docker restart policies and network configurations in 'cms-infra/docker-compose.yml'.
- Updated tracked 'data.db' with the correct 'module_bar' settings to ensure extension visibility.
- Cleaned up legacy files and consolidated extension package source code.

This commit captures the full state of the repository after resolving the 'missing extensions' issue.
2026-02-14 01:44:18 +01:00
ad40e71757 fix: replace invalid useRouter import from @directus/extensions-sdk with vue-router
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 26s
Monorepo Pipeline / 🚀 Release (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 / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
The Directus 11.x SDK does not export useRouter. Importing it caused a
SyntaxError that crashed the entire extensions bundle, preventing ALL
modules from appearing in the Data Studio sidebar.

Changes:
- Replace useRouter import from @directus/extensions-sdk → vue-router
- Add scripts/validate-sdk-imports.sh to catch invalid SDK imports
- Integrate SDK import validation into pre-push hook
- Add EXTENSIONS_AUTO_RELOAD to docker-compose.yml
- Remove debug NODE_ENV=development
2026-02-14 01:43:10 +01:00
911ceffdc5 fix(pipeline): serialize image builds to prevent act cache collisions
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m0s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m41s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m19s
Monorepo Pipeline / 🚀 Release (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-13 15:01:34 +01:00
23358fc708 fix: temporary trigger test
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 8m49s
Monorepo Pipeline / 🧹 Lint (push) Successful in 9m13s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Failing after 51s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 1m34s
Monorepo Pipeline / 🏗️ Build (push) Successful in 6m58s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 20s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 18s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m58s
2026-02-13 14:38:01 +01:00
73ea958655 chore: remove [skip ci] from version sync and update image tag 2026-02-13 14:31:30 +01:00
f2035d79dd chore: automate re-push in pre-push hook
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 56s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m9s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m59s
Monorepo Pipeline / 🚀 Release (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-13 14:28:55 +01:00
f514349ccf chore: sync versions to v1.8.2 [skip ci] 2026-02-13 14:27:22 +01:00
a71f86560b chore: fix @mintel/directus-extension-toolkit build and update eslint ignores
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 27s
Monorepo Pipeline / 🧪 Test (push) Successful in 57s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m1s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m35s
Monorepo Pipeline / 🚀 Release (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-13 14:21:39 +01:00
de8314732d chore: fix remaining build script syntax errors in extensions
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m5s
Monorepo Pipeline / 🏗️ Build (push) Failing after 2m49s
Monorepo Pipeline / 🧪 Test (push) Successful in 3m0s
Monorepo Pipeline / 🚀 Release (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-13 12:15:30 +01:00
bdf7773310 chore: finalize 'meaningful' sync hook and pipeline stabilization
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (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 / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
2026-02-13 12:15:01 +01:00
a25e4aa1d4 chore: stabilize pipeline, fix extension build scripts, and finalize version sync hook
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧹 Lint (push) Has started running
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (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 (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
2026-02-13 12:14:27 +01:00
ecc2163b8e chore: remove redundant version sync from pre-push hook
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 47s
Monorepo Pipeline / 🏗️ Build (push) Failing after 3m27s
Monorepo Pipeline / 🧪 Test (push) Successful in 4m33s
Monorepo Pipeline / 🚀 Release (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-13 12:08:58 +01:00
af02378d29 chore: sync versions
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 4s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m10s
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (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 / 🧹 Lint (push) Has been cancelled
2026-02-13 12:05:14 +01:00
f8847a7a10 feat(next-feedback): refine selector filters for tailwind and dynamic classes
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 8s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m59s
Monorepo Pipeline / 🧪 Test (push) Successful in 4m5s
Monorepo Pipeline / 🧹 Lint (push) Successful in 5m8s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 20s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 18s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 20s
Monorepo Pipeline / 🚀 Release (push) Successful in 8m3s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 10m25s
2026-02-13 12:03:33 +01:00
117b23db1e feat(next-feedback): improve selector precision with @medv/finder and fix client/server boundary 2026-02-13 12:03:11 +01:00
d6f9a24823 chore: sync versions to 1.8.0 2026-02-12 22:05:20 +01:00
422e4fccba feat(cloner): add cloner-library and finalize pdf-library rename 2026-02-12 22:04:40 +01:00
57ec4d7544 chore: bump versions 2026-02-12 21:47:55 +01:00
a4d021c658 feat(pdf): rename acquisition-library to pdf-library and update package name to @mintel/pdf
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Failing after 13s
Monorepo Pipeline / 🏗️ Build (push) Failing after 11s
Monorepo Pipeline / 🧪 Test (push) Failing after 25s
Monorepo Pipeline / 🚀 Release (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-12 21:46:45 +01:00
269d19bbef fix(acquisition): finalize extension build and components
- Fixed IndustrialCard export in SharedUI.
- Successfully built all extensions including acquisition-library.
- Verified sitemap and briefing module updates.
2026-02-12 21:27:39 +01:00
30ff08c66d fix(acquisition): standardize bundling and externalize React/PDF dependencies
- Added JSX support and correctly externalized react/pdf dependencies in esbuild.
- Fixed acquisition-library exports by removing missing DINLayout reference.
- Standardized extension entry points across all modules.
2026-02-12 21:26:30 +01:00
81deaf447f fix(acquisition): standardize bundling and externalize React/PDF dependencies
- Added JSX support to esbuild configuration.
- Externalized react, react-dom, and @react-pdf/renderer to avoid redundant bundling.
- Updated acquisition-library exports for modular PDF generation.
2026-02-12 21:24:15 +01:00
a0ebc58d6d fix(directus): resolve extension visibility and registration failures
- Corrected module_bar settings to restore custom extension visibility in UI.
- Fixed 'fs' dynamic require in acquisition endpoint by externalizing Node.js built-ins.
- Standardized local environment branding to AT Mintel.
2026-02-12 21:20:28 +01:00
7498c24c9a fix(directus): resolve login failures and standardize project branding
- Fixed project isolation bypass (identity shadowing) by prefixing database service name.
- Standardized health check paths and protocols in docker-compose.yml.
- Resolved extension SyntaxError caused by duplicate banner injections in build scripts.
- Migrated extension build system to clean esbuild-based bundles (removing shims).
- Updated sync-directus.sh for project-prefixed service name.
- Synchronized latest production data and branding (AT Mintel).
2026-02-12 19:21:53 +01:00
efba82337c refactor(acquisition): change build output to dist/ directory
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 59s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m38s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m22s
Monorepo Pipeline / 🚀 Release (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-12 12:47:06 +01:00
c083b309fb fix(acquisition): add missing dependencies to acquisition-library and fix build failure
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m0s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m6s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m23s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
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-02-12 12:15:43 +01:00
eb8bf60408 fix(infra): use dynamic container detection for registry maintenance
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 20s
Monorepo Pipeline / 🧪 Test (push) Successful in 49s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m55s
Monorepo Pipeline / 🏗️ Build (push) Failing after 2m1s
Monorepo Pipeline / 🚀 Release (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-12 11:16:28 +01:00
a3819490ac chore(package): standardize formatting and cleanup temporary logs
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 26s
Monorepo Pipeline / 🧪 Test (push) Successful in 52s
Monorepo Pipeline / 🏗️ Build (push) Has started running
Monorepo Pipeline / 🚀 Release (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 / 🧹 Lint (push) Has been cancelled
2026-02-12 11:14:58 +01:00
1127954fea feat(cms): final restoration of extension logic and monorepo stabilization
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 55s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m32s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m50s
Monorepo Pipeline / 🚀 Release (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-12 02:11:53 +01:00
fa0b133012 feat(cms): restore extension logic and stabilize build pipeline
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 45s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m47s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m57s
Monorepo Pipeline / 🚀 Release (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
- restored People Manager and Acquisition Manager logic from git history
- standardized build scripts for directus extensions
- added mmintel user and enabled extensions in cms settings
- updated sync script for robust artifact distribution
2026-02-12 01:14:13 +01:00
1b40baebd4 fix(infra): use SHA detection and better logging in wait-for-upstream.sh
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 44s
Monorepo Pipeline / 🧪 Test (push) Successful in 2m24s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m43s
Monorepo Pipeline / 🚀 Release (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-11 23:25:47 +01:00
316c03869a fix(gatekeeper): enhance logging and stabilize upstream polling
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 6m38s
Monorepo Pipeline / 🧹 Lint (push) Successful in 7m14s
Monorepo Pipeline / 🏗️ Build (push) Successful in 10m24s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 1m39s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 2m7s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 2m8s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m18s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 6m58s
2026-02-11 22:49:16 +01:00
63d2acfab5 feat(infra): add wait-for-upstream script for smart dependencies
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 47s
Monorepo Pipeline / 🧪 Test (push) Successful in 39s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m49s
Monorepo Pipeline / 🚀 Release (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
2026-02-11 22:41:47 +01:00
bdeae0aca6 chore(gatekeeper): bump to 1.7.11 for fix
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 47s
Monorepo Pipeline / 🧪 Test (push) Successful in 40s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m51s
Monorepo Pipeline / 🚀 Release (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
2026-02-11 22:35:04 +01:00
47c70a16f1 fix(gatekeeper): trim auth inputs and prioritize access code to prevent autofill traps
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
Monorepo Pipeline / 🚀 Release (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
2026-02-11 22:32:17 +01:00
b96d44bf6d chore: finalize version updates for v1.7.10
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧪 Test (push) Successful in 45s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m12s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m52s
Monorepo Pipeline / 🚀 Release (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-11 16:55:17 +01:00
73b60f14a9 chore: release clean base image 1.7.10
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m3s
Monorepo Pipeline / 🧪 Test (push) Successful in 41s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m19s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 16s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 2m14s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m43s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 21s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 32s
2026-02-11 16:32:16 +01:00
b3f43c421f chore: manual version bump to 1.7.9
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 57s
Monorepo Pipeline / 🧪 Test (push) Successful in 50s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m49s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 57s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m7s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 4m4s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 3m1s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 34s
2026-02-11 16:02:51 +01:00
a2339f7106 fix: make directus extension build scripts more resilient
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 45s
Monorepo Pipeline / 🧪 Test (push) Successful in 52s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m50s
Monorepo Pipeline / 🚀 Release (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-11 15:58:58 +01:00
e83a76f111 chore: trigger CI build after disk cleanup
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 45s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m4s
Monorepo Pipeline / 🏗️ Build (push) Failing after 38s
Monorepo Pipeline / 🚀 Release (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-11 15:56:16 +01:00
0096c18098 fix(infra): correct registry data path and enable untagged manifest deletion
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 27s
Monorepo Pipeline / 🧪 Test (push) Failing after 24s
Monorepo Pipeline / 🏗️ Build (push) Failing after 22s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m4s
Monorepo Pipeline / 🚀 Release (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-11 12:39:50 +01:00
3284931f84 chore(next-utils): respect skip flags in refinements and publish v1.7.15
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 46s
Monorepo Pipeline / 🧪 Test (push) Successful in 55s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m48s
Monorepo Pipeline / 🚀 Release (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-11 01:32:01 +01:00
28517a3558 chore(next-utils): introduce withMintelRefinements and publish v1.7.14
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Has started running
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
2026-02-11 01:31:16 +01:00
3b9f10ec98 chore(next-utils): convert to ESM-only and publish v1.7.13
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 42s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m0s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m47s
Monorepo Pipeline / 🚀 Release (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-11 01:26:53 +01:00
65fd248993 chore(next-utils): fix generic propagation in createMintelDirectusClient and publish v1.7.12
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 45s
Monorepo Pipeline / 🧪 Test (push) Successful in 54s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m46s
Monorepo Pipeline / 🚀 Release (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-11 01:22:12 +01:00
ebd9ab132c chore(next-utils): add explicit return type to validateMintelEnv and publish v1.7.11
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 45s
Monorepo Pipeline / 🧪 Test (push) Successful in 58s
Monorepo Pipeline / 🚀 Release (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-11 01:20:57 +01:00
ddaeb2c3ca chore(next-utils): rebuild with generic types and publish v1.7.10
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 42s
Monorepo Pipeline / 🚀 Release (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 (push) Has been cancelled
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
2026-02-11 01:20:03 +01:00
ad1a8c4fbf feat(next-utils): support generic schema in directus client
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 34s
Monorepo Pipeline / 🧪 Test (push) Successful in 43s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m6s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m50s
Monorepo Pipeline / 🚀 Release (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-11 01:00:34 +01:00
013b0259b2 fix(pipeline): use POSIX sh compatible logic for release prioritization
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 44s
Monorepo Pipeline / 🧪 Test (push) Successful in 40s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m47s
Monorepo Pipeline / 🚀 Release (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
2026-02-11 00:33:06 +01:00
d5a9a3bce4 chore: refine release prioritization logic and bump v1.7.8
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m0s
Monorepo Pipeline / 🧪 Test (push) Successful in 39s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m23s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 19s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m42s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 2m17s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 16s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 5m29s
2026-02-11 00:31:03 +01:00
b9fd583ac4 chore: fix pipeline hang by disabling broken caching and using corepack
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 57s
Monorepo Pipeline / 🚀 Release (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 / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
2026-02-11 00:29:10 +01:00
bfdbaba0d0 chore: implement release prioritization and streamline setup for speed
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m2s
Monorepo Pipeline / 🧪 Test (push) Successful in 58s
Monorepo Pipeline / 🚀 Release (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-11 00:22:31 +01:00
4ea9cbc551 fix(next-utils): use natural type inference for validateMintelEnv to fix unknown type errors
Some checks failed
Monorepo Pipeline / 🚀 Release (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
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 📦 Install & Sync (push) Has been cancelled
2026-02-11 00:17:46 +01:00
d8c1a38c0d chore: optimize pipeline for speed and parallelize QA jobs
Some checks failed
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
Monorepo Pipeline / 🚀 Release (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 / 📦 Install & Sync (push) Has been cancelled
2026-02-11 00:14:55 +01:00
b65b9a7fb2 fix(next-utils): finalize type safety for validateMintelEnv and fix pre-push hook
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 2m7s
Monorepo Pipeline / 🚀 Release (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-11 00:10:38 +01:00
858c7bbc39 fix(next-utils): use z.extend() for robust type inference in validateMintelEnv
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m33s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 33s
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
2026-02-11 00:04:14 +01:00
149123ef90 fix(next-utils): restore optional argument with robust types to satisfy linter
Some checks failed
Monorepo Pipeline / 🚀 Release (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 / 🧪 Quality Assurance (push) Has been cancelled
2026-02-11 00:02:53 +01:00
6bc49d1c52 fix(next-utils): make validateMintelEnv generic for better type safety
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 2m58s
Monorepo Pipeline / 🚀 Release (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-10 23:57:18 +01:00
52ffe49019 feat(next-utils): make directus client environment-aware and standardize base env schema
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m10s
Monorepo Pipeline / 🚀 Release (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-10 23:44:27 +01:00
73fa292528 fix: remove klz from workspace 2026-02-10 21:39:48 +01:00
f2c0a4581c chore: sync versions
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m6s
Monorepo Pipeline / 🚀 Release (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-10 00:39:34 +01:00
367c4d8404 fix: cms schema 2026-02-10 00:35:26 +01:00
587c88980f chore: release next-config v1.7.0
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m8s
Monorepo Pipeline / 🚀 Release (push) Successful in 1m51s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 17s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 2m18s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 16s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 5m34s
2026-02-10 00:29:02 +01:00
fcdfdb4588 chore: release next-config v1.6.1
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m12s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 18s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m17s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 2m13s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 17s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 5m27s
2026-02-10 00:27:59 +01:00
6bbaa8d105 chore: cms sync 2026-02-10 00:26:13 +01:00
eccc084441 chore: cms sync commands 2026-02-10 00:13:42 +01:00
da6b8aba64 fix: cms sync
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m43s
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-10 00:03:27 +01:00
290097b4e6 chore: fix linter
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Has been cancelled
Monorepo Pipeline / 🚀 Release (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
2026-02-10 00:02:26 +01:00
45894cce34 chore: fix linter
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 57s
Monorepo Pipeline / 🚀 Release (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-09 23:59:22 +01:00
7195906da0 chore: fix linter
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 42s
Monorepo Pipeline / 🚀 Release (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-09 23:54:58 +01:00
dcb466f53b chore: fix husky
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 1m3s
Monorepo Pipeline / 🚀 Release (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-09 23:44:34 +01:00
14089766ea feat: infra cms
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 1m5s
Monorepo Pipeline / 🚀 Release (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-09 23:33:45 +01:00
190 changed files with 20378 additions and 6662 deletions

View File

@@ -0,0 +1,82 @@
---
description: How to manage and deploy Directus CMS infrastructure changes.
---
# Directus CMS Infrastructure Workflow
This workflow ensures "Industrial Grade" consistency and stability across local, testing, and production environments for the `at-mintel` Directus CMS.
## 1. Local Development Lifecycle
### Starting the CMS
To start the local Directus instance with extensions:
```bash
cd packages/cms-infra
npm run up
```
### Modifying Schema
1. **Directus UI**: Make your changes directly in the local Directus Admin UI (Collections, Fields, Relations).
2. **Take Snapshot**:
```bash
cd packages/cms-infra
npm run snapshot:local
```
This updates `packages/cms-infra/schema/snapshot.yaml`.
3. **Commit**: Commit the updated `snapshot.yaml`.
## 2. Deploying Schema Changes
### To Local Environment (Reconciliation)
If you pull changes from Git and need to apply them to your local database:
```bash
cd packages/cms-infra
npm run schema:apply:local
```
> [!IMPORTANT]
> This command automatically runs `scripts/cms-reconcile.sh` to prevent "Field already exists" errors by registering database columns in Directus metadata first.
### To Production (Infra)
To deploy the local snapshot to the production server:
```bash
cd packages/cms-infra
npm run schema:apply:infra
```
This script:
1. Syncs built extensions via rsync.
2. Injects the `snapshot.yaml` into the remote container.
3. Runs `directus schema apply`.
4. Restarts Directus to clear the schema cache.
## 3. Data Synchronization
### Pulling from Production
To update your local environment with production data and assets:
```bash
cd packages/cms-infra
npm run sync:pull
```
### Pushing to Production
> [!CAUTION]
> This will overwrite production data. Use with extreme care.
```bash
cd packages/cms-infra
npm run sync:push
```
## 4. Extension Management
When modifying extensions in `packages/*-manager`:
1. Extensions are automatically built and synced when running `npm run up`.
2. To sync manually without restarting the stack:
```bash
cd packages/cms-infra
npm run build:extensions
```
## 5. Troubleshooting "Field already exists"
If `schema:apply` fails with "Field already exists", run:
```bash
./scripts/cms-reconcile.sh
```
This script ensures the database state matches Directus's internal field registry (`directus_fields`).

View File

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

View File

@@ -1,12 +1,26 @@
node_modules
**/node_modules
.next
**/.next
.git
# .npmrc is allowed as it contains the registry template
dist
**/dist
build
**/build
out
**/out
coverage
**/coverage
.vercel
**/.vercel
.turbo
**/.turbo
*.log
**/*.log
.DS_Store
**/.DS_Store
.pnpm-store
**/.pnpm-store
.gitea
**/.gitea

37
.env Normal file
View File

@@ -0,0 +1,37 @@
# Project
IMAGE_TAG=v1.8.19
PROJECT_NAME=at-mintel
PROJECT_COLOR=#82ed20
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
OPENROUTER_API_KEY=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
# Authentication
GATEKEEPER_PASSWORD=mintel
AUTH_COOKIE_NAME=mintel_gatekeeper_session
# Host Config (Local)
TRAEFIK_HOST=at-mintel.localhost
DIRECTUS_HOST=cms-legacy.localhost
# Next.js
NEXT_PUBLIC_BASE_URL=http://at-mintel.localhost
# Directus
DIRECTUS_URL=http://cms-legacy.localhost
DIRECTUS_KEY=F9IIfahEjPq6NZhKyRLw516D8GotuFj79EGK7pGfIWg=
DIRECTUS_SECRET=OZfxMu8lBxzaEnFGRKreNBoJpRiRu58U+HsVg2yWk4o=
CORS_ENABLED=true
CORS_ORIGIN=true
LOG_LEVEL=debug
DIRECTUS_ADMIN_EMAIL=mmintel@mintel.me
DIRECTUS_ADMIN_PASSWORD=Tim300493.
DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=directus
DIRECTUS_DB_PASSWORD=mintel-db-pass
# Sentry / Glitchtip
SENTRY_DSN=
# Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js

View File

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

4
.eslintignore Normal file
View File

@@ -0,0 +1,4 @@
**/index.js
**/dist/**
packages/cms-infra/extensions/**
packages/cms-infra/extensions/**

View File

@@ -0,0 +1,44 @@
name: 🏥 Server Maintenance
on:
schedule:
- cron: '0 3 * * *' # Every day at 3:00 AM
workflow_dispatch: # Allow manual trigger
jobs:
maintenance:
name: 🧹 Prune & Clean
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 🚀 Execute Maintenance via SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
# Run the prune script on the host
# We transfer the script and execute it to ensure it matches the repo version
scp packages/infra/scripts/mintel-optimizer.sh root@${{ secrets.SSH_HOST }}:/tmp/mintel-optimizer.sh
ssh root@${{ secrets.SSH_HOST }} "bash /tmp/mintel-optimizer.sh && rm /tmp/mintel-optimizer.sh"
- name: 🔔 Notification - Success
if: success()
run: |
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=🏥 Maintenance Complete" \
-F "message=Server-Wartung erfolgreich ausgeführt.\nRegistry & Docker Ressourcen bereinigt." \
-F "priority=2" || true
- name: 🔔 Notification - Failure
if: failure()
run: |
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=❌ Maintenance FAILED" \
-F "message=Die automatische Server-Wartung ist fehlgeschlagen!\nBitte Logs prüfen." \
-F "priority=8" || true

View File

@@ -5,15 +5,74 @@ on:
branches:
- '**'
tags:
- 'v*'
- '*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
qa:
name: 🧪 Quality Assurance
prioritize:
name: ⚡ Prioritize Release
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: 🛑 Cancel Redundant Runs
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
REF: ${{ github.ref }}
REF_NAME: ${{ github.ref_name }}
EVENT: ${{ github.event_name }}
SHA: ${{ github.sha }}
run: |
echo "🔎 Debug: Event=$EVENT, Ref=$REF, RefName=$REF_NAME, RunId=$RUN_ID"
# Fetch recent runs for the repository
RUNS=$(curl -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs?limit=30")
case "$REF" in
refs/tags/*)
echo "🚀 Release detected ($REF_NAME). Cancelling non-tag runs..."
# Identify runs to cancel: in_progress/queued, NOT this run, and NOT a tag run
echo "$RUNS" | jq -c '.workflow_runs[] | select(.status == "in_progress" or .status == "queued") | select(.id | tostring != "'$RUN_ID'")' | while read -r run; do
ID=$(echo "$run" | jq -r '.id')
RUN_REF=$(echo "$run" | jq -r '.ref')
TITLE=$(echo "$run" | jq -r '.display_title')
case "$RUN_REF" in
refs/tags/*)
echo "⏭️ Skipping parallel release run $ID ($TITLE) on $RUN_REF"
;;
*)
echo "🛑 Cancelling redundant branch run $ID ($TITLE) on $RUN_REF..."
curl -X POST -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs/$ID/cancel"
;;
esac
done
;;
*)
echo " Regular push. Checking for parallel release tag for SHA $SHA..."
# Check if there's a tag run for the SAME commit
TAG_RUN_ID=$(echo "$RUNS" | jq -r '.workflow_runs[] | select(.ref | startswith("refs/tags/")) | select(.head_sha == "'$SHA'") | .id' | head -n 1)
if [[ -n "$TAG_RUN_ID" && "$TAG_RUN_ID" != "null" ]]; then
echo "🚀 Found parallel tag run $TAG_RUN_ID for commit $SHA. Cancelling this branch run ($RUN_ID)..."
curl -X POST -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs/$RUN_ID/cancel"
exit 0
fi
echo "✅ No parallel tag run found. Proceeding."
;;
esac
lint:
name: 🧹 Lint
needs: prioritize
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
@@ -22,37 +81,67 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: 🏷️ Sync Versions (if Tagged)
if: startsWith(github.ref, 'refs/tags/v')
run: pnpm sync-versions
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
- name: Lint
run: pnpm lint
test:
name: 🧪 Test
needs: prioritize
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
- name: Test
run: pnpm test
build:
name: 🏗️ Build
needs: prioritize
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
- name: Build
run: pnpm build
release:
name: 🚀 Release
needs: qa
if: startsWith(github.ref, 'refs/tags/v')
needs: [lint, test, build]
if: startsWith(github.ref, 'refs/tags/')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
@@ -64,20 +153,16 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
- name: 🏷️ Sync Versions (if Tagged)
run: pnpm sync-versions
- name: 🏷️ Release Packages (Tag-Driven)
run: |
echo "🏷️ Tag detected [${{ github.ref_name }}], performing sync release..."
@@ -85,13 +170,14 @@ jobs:
build-images:
name: 🐳 Build ${{ matrix.name }}
needs: qa
if: startsWith(github.ref, 'refs/tags/v')
needs: [lint, test, build]
if: startsWith(github.ref, 'refs/tags/')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- image: nextjs
@@ -106,6 +192,9 @@ jobs:
- 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
@@ -125,14 +214,13 @@ jobs:
with:
context: .
file: ${{ matrix.file }}
platforms: linux/arm64
platforms: linux/amd64
pull: true
provenance: false
push: true
secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: |
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache,mode=max

5
.gitignore vendored
View File

@@ -37,3 +37,8 @@ Thumbs.db
# Changesets
.changeset/*.lock
directus/extensions/
packages/cms-infra/extensions/
packages/cms-infra/uploads/
directus/uploads/directus-health-file

View File

@@ -1,15 +1,45 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Validate Directus SDK imports before push
# This prevents runtime crashes caused by importing non-existent exports
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
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
if echo "$*" | grep -q "refs/tags/v"; then
echo "🏷️ Tag detected in push, syncing versions..."
pnpm sync-versions
# Stage the changed package.json files
git add "package.json" "packages/*/package.json" "apps/*/package.json"
# Amend the tag if it's on the current commit, but this is complex in pre-push.
# Better: Just warn the user that they might need to update the tag if package.json changed.
echo "⚠️ package.json files updated to match tag. Please ensure these changes are part of your tag/commit."
fi
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 "✨ Success! The hook synchronized the versions and pushed the updated tag for you."
echo " Note: The original push command was aborted in favor of the auto-push. This is normal."
exit 0 # Change to exit 0 to not show as an 'error' in vscode/terminal, though original push will still be technically 'failed' by git
else
echo "✨ Versions already in sync for $TAG."
fi
fi
done

56
Dockerfile.template Normal file
View File

@@ -0,0 +1,56 @@
# Stage 1: Builder
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
WORKDIR /app
# Clean the workspace in case the base image is dirty
RUN rm -rf ./*
# Arguments for build-time configuration
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ARG NPM_TOKEN
# Environment variables for Next.js build
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
ENV SKIP_RUNTIME_ENV_VALIDATION=true
ENV CI=true
# Enable pnpm
RUN corepack enable
# Copy lockfile and manifest for dependency installation caching
COPY pnpm-lock.yaml package.json .npmrc* ./
# Install dependencies with cache mount
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build application
RUN pnpm build
# Stage 2: Runner
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
WORKDIR /app
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
ENV NODE_ENV=production
# Copy standalone output and static files
# Adjust paths if using a monorepo structure (e.g., /app/apps/web/public)
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
USER nextjs
CMD ["node", "server.js"]

View File

@@ -80,3 +80,5 @@ Client websites scaffolded via the CLI use a **tag-based deployment** strategy:
- **Git Tag `v*.*.*`**: Deploys to the `production` environment.
See the [`@mintel/infra`](packages/infra/README.md) package for detailed template documentation.
Trigger rebuilding for x86 architecture.

View File

@@ -0,0 +1,40 @@
FROM node:20.18-bookworm-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN apt-get update && apt-get install -y \
build-essential \
python3 \
libcairo2-dev \
libpango1.0-dev \
libjpeg-dev \
libgif-dev \
librsvg2-dev \
libexpat1 \
&& rm -rf /var/lib/apt/lists/*
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
# Instead of copying node_modules which contains native C++ bindings for canvas and tfjs-node,
# we copy the package.json files and install natively in the final stage so the bindings are correct.
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY apps/image-service/package.json ./apps/image-service/package.json
COPY packages/image-processor/package.json ./packages/image-processor/package.json
RUN pnpm install --frozen-lockfile --filter image-service...
COPY --from=build /app/apps/image-service/dist ./apps/image-service/dist
COPY --from=build /app/packages/image-processor/dist ./packages/image-processor/dist
COPY --from=build /app/packages/image-processor/models ./packages/image-processor/models
EXPOSE 8080
WORKDIR /app/apps/image-service
CMD ["npm", "run", "start"]

View File

@@ -0,0 +1,23 @@
{
"name": "image-service",
"version": "1.8.21",
"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

@@ -0,0 +1,109 @@
import Fastify from "fastify";
import {
processImageWithSmartCrop,
parseImgproxyOptions,
mapUrl,
} from "@mintel/image-processor";
const fastify = Fastify({
logger: true,
});
fastify.get("/unsafe/:options/:urlSafeB64", async (request, reply) => {
const { options, urlSafeB64 } = request.params as {
options: string;
urlSafeB64: string;
};
// urlSafeB64 might be "plain/http://..." or a Base64 string
let url = "";
if (urlSafeB64.startsWith("plain/")) {
url = urlSafeB64.substring(6);
} else {
try {
url = Buffer.from(urlSafeB64, "base64").toString("utf-8");
} catch (e) {
return reply.status(400).send({ error: "Invalid Base64 URL" });
}
}
const parsedOptions = parseImgproxyOptions(options);
const mappedUrl = mapUrl(url, process.env.IMGPROXY_URL_MAPPING);
return handleProcessing(mappedUrl, parsedOptions, reply);
});
// Helper to avoid duplication
async function handleProcessing(url: string, options: any, reply: any) {
const width = options.width || 800;
const height = options.height || 600;
const quality = options.quality || 80;
const format = options.format || "webp";
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,
});
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("/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 any;
if (!url) {
return reply.status(400).send({ error: 'Parameter "url" is required' });
}
const mappedUrl = mapUrl(url, process.env.IMGPROXY_URL_MAPPING);
return handleProcessing(mappedUrl, { width, height, quality, format }, reply);
});
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

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

View File

@@ -1,3 +0,0 @@
import { nextConfig } from "@mintel/eslint-config/next";
export default nextConfig;

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "sample-website",
"version": "1.6.0",
"version": "1.8.21",
"private": true,
"type": "module",
"scripts": {
@@ -8,15 +8,9 @@
"dev:local": "mintel dev --local",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"cms:bootstrap": "mintel directus bootstrap",
"cms:push:testing": "mintel directus sync push testing",
"cms:pull:testing": "mintel directus sync pull testing",
"cms:push:staging": "mintel directus sync push staging",
"cms:pull:staging": "mintel directus sync pull staging",
"cms:push:prod": "mintel directus sync push production",
"cms:pull:prod": "mintel directus sync pull production",
"pagespeed:test": "mintel pagespeed"
},
@@ -24,8 +18,9 @@
"@mintel/next-utils": "workspace:*",
"@mintel/observability": "workspace:*",
"@mintel/next-observability": "workspace:*",
"@sentry/nextjs": "^8.55.0",
"next": "15.1.6",
"@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

@@ -0,0 +1,60 @@
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 },
);
}
}

View File

@@ -1,17 +1,18 @@
services:
app:
build:
context: .
context: ./apps/sample-website
dockerfile: Dockerfile
args:
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-http://localhost:3000}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL}
NEXT_PUBLIC_TARGET: ${TARGET:-development}
DIRECTUS_URL: ${DIRECTUS_URL:-http://directus:8055}
restart: always
networks:
- infra
environment:
- DIRECTUS_URL=${DIRECTUS_URL:-http://directus:8055}
env_file:
- .env
ports:
@@ -20,9 +21,17 @@ services:
- "traefik.enable=true"
- "traefik.http.routers.sample-website.rule=Host(`${TRAEFIK_HOST:-sample-website.localhost}`)"
- "traefik.http.services.sample-website.loadbalancer.server.port=3000"
- "caddy=http://${TRAEFIK_HOST:-acquisition.localhost}"
- "caddy.reverse_proxy={{upstreams 3000}}"
directus:
image: registry.infra.mintel.me/mintel/directus:latest
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
@@ -34,7 +43,7 @@ services:
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
DB_CLIENT: 'pg'
DB_HOST: 'directus-db'
DB_HOST: 'at-mintel-directus-db'
DB_PORT: '5432'
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus}
@@ -46,12 +55,15 @@ services:
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}}"
directus-db:
at-mintel-directus-db:
image: postgres:15-alpine
restart: always
networks:

View File

@@ -5,9 +5,13 @@ export default [
{
ignores: [
"packages/cms-infra/extensions/**",
"packages/customer-manager/index.js",
"**/index.js",
"**/*.db",
"**/build/**",
"**/data/**",
"**/reference/**",
"**/dist/**",
"**/.next/**",
],
},
...baseConfig,

View File

@@ -0,0 +1 @@
404: Not Found

View File

@@ -0,0 +1,30 @@
[
{
"weights":
[
{"name":"conv0/filters","shape":[3,3,3,16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009007044399485869,"min":-1.2069439495311063}},
{"name":"conv0/bias","shape":[16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005263455241334205,"min":-0.9211046672334858}},
{"name":"conv1/depthwise_filter","shape":[3,3,16,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.004001977630690033,"min":-0.5042491814669441}},
{"name":"conv1/pointwise_filter","shape":[1,1,16,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013836609615999109,"min":-1.411334180831909}},
{"name":"conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0015159862590771096,"min":-0.30926119685173037}},
{"name":"conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002666276225856706,"min":-0.317286870876948}},
{"name":"conv2/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015265831292844286,"min":-1.6792414422128714}},
{"name":"conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0020280554598453,"min":-0.37113414915168985}},
{"name":"conv3/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006100742489683862,"min":-0.8907084034938438}},
{"name":"conv3/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.016276211832083907,"min":-2.0508026908425725}},
{"name":"conv3/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.003394414279975143,"min":-0.7637432129944072}},
{"name":"conv4/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006716050119961009,"min":-0.8059260143953211}},
{"name":"conv4/pointwise_filter","shape":[1,1,128,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021875603993733724,"min":-2.8875797271728514}},
{"name":"conv4/bias","shape":[256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0041141652009066415,"min":-0.8187188749804216}},
{"name":"conv5/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008423839597141042,"min":-0.9013508368940915}},
{"name":"conv5/pointwise_filter","shape":[1,1,256,512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.030007277283014035,"min":-3.8709387695088107}},
{"name":"conv5/bias","shape":[512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008402082966823203,"min":-1.4871686851277068}},
{"name":"conv8/filters","shape":[1,1,512,25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.028336129469030042,"min":-4.675461362389957}},
{"name":"conv8/bias","shape":[25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002268134028303857,"min":-0.41053225912299807}}
],
"paths":
[
"tiny_face_detector_model.bin"
]
}
]

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,11 +5,21 @@
"scripts": {
"build": "pnpm -r build",
"dev": "pnpm -r dev",
"lint": "pnpm -r lint",
"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",
"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": "pnpm build && changeset publish",
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
"prepare": "husky"
@@ -20,6 +30,7 @@
"@commitlint/config-conventional": "^20.4.0",
"@mintel/eslint-config": "workspace:*",
"@mintel/husky-config": "workspace:*",
"@next/eslint-plugin-next": "16.1.6",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^20.17.16",
@@ -27,7 +38,6 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"@next/eslint-plugin-next": "16.1.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"happy-dom": "^20.4.0",
@@ -41,10 +51,29 @@
"vitest": "^4.0.18"
},
"dependencies": {
"globals": "^17.3.0",
"import-in-the-middle": "^3.0.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"require-in-the-middle": "^8.0.1"
},
"version": "1.6.0"
"version": "1.8.21",
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"@sentry/cli",
"@swc/core",
"@tensorflow/tfjs-node",
"canvas",
"core-js",
"esbuild",
"sharp",
"unrs-resolver",
"vue-demi"
],
"overrides": {
"next": "16.1.6",
"@sentry/nextjs": "10.38.0"
}
}
}

View File

@@ -0,0 +1,30 @@
{
"name": "acquisition-manager",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.21",
"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

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,468 @@
<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

@@ -0,0 +1,55 @@
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

@@ -0,0 +1,27 @@
{
"name": "acquisition",
"version": "1.8.21",
"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

@@ -0,0 +1,236 @@
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,6 +1,6 @@
{
"name": "@mintel/cli",
"version": "1.6.0",
"version": "1.8.21",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"

View File

@@ -41,7 +41,7 @@ program
`),
);
execSync(
"docker-compose down --remove-orphans && docker-compose up app directus directus-db",
"docker compose down --remove-orphans && docker compose up -d app directus at-mintel-directus-db",
{ stdio: "inherit" },
);
});

View File

@@ -0,0 +1,43 @@
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 entryPoints = [
resolve(__dirname, 'src/index.ts')
];
try {
mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
} catch {
// ignore
}
console.log(`Building entry point...`);
build({
entryPoints: entryPoints,
bundle: true,
platform: 'node',
target: 'node18',
outdir: resolve(__dirname, 'dist'),
format: 'esm',
loader: {
'.ts': 'ts',
'.js': 'js',
},
external: ["playwright", "crawlee", "axios", "cheerio", "fs", "path", "os", "http", "https", "url", "stream", "util", "child_process"],
}).then(() => {
console.log("Build succeeded!");
}).catch((e) => {
if (e.errors) {
console.error("Build failed with errors:");
e.errors.forEach(err => console.error(` ${err.text} at ${err.location?.file}:${err.location?.line}`));
} else {
console.error("Build failed:", e);
}
process.exit(1);
});

View File

@@ -0,0 +1,30 @@
{
"name": "@mintel/cloner",
"version": "1.8.21",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "node build.mjs",
"dev": "node build.mjs --watch"
},
"devDependencies": {
"esbuild": "^0.25.0",
"typescript": "^5.6.3",
"@types/node": "^22.0.0"
},
"dependencies": {
"playwright": "^1.40.0",
"crawlee": "^3.7.0",
"axios": "^1.6.0",
"cheerio": "^1.0.0-rc.12"
}
}

View File

@@ -0,0 +1,98 @@
import axios from "axios";
import fs from "node:fs";
import path from "node:path";
export interface AssetMap {
[originalUrl: string]: string;
}
export class AssetManager {
private userAgent: string;
constructor(
userAgent: string = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
) {
this.userAgent = userAgent;
}
public sanitizePath(rawPath: string): string {
return rawPath
.split("/")
.map((p) => p.replace(/[^a-z0-9._-]/gi, "_"))
.join("/");
}
public async downloadFile(
url: string,
assetsDir: string,
): Promise<string | null> {
if (url.startsWith("//")) url = `https:${url}`;
if (!url.startsWith("http")) return null;
try {
const u = new URL(url);
const relPath = this.sanitizePath(u.hostname + u.pathname);
const dest = path.join(assetsDir, relPath);
if (fs.existsSync(dest)) return `./assets/${relPath}`;
const res = await axios.get(url, {
responseType: "arraybuffer",
headers: { "User-Agent": this.userAgent },
timeout: 15000,
validateStatus: () => true,
});
if (res.status !== 200) return null;
if (!fs.existsSync(path.dirname(dest)))
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, Buffer.from(res.data));
return `./assets/${relPath}`;
} catch {
return null;
}
}
public async processCssRecursively(
cssContent: string,
cssUrl: string,
assetsDir: string,
urlMap: AssetMap,
depth = 0,
): Promise<string> {
if (depth > 5) return cssContent;
const urlRegex = /(?:url\(["']?|@import\s+["'])([^"')]*)["']?\)?/gi;
let match;
let newContent = cssContent;
while ((match = urlRegex.exec(cssContent)) !== null) {
const originalUrl = match[1];
if (originalUrl.startsWith("data:") || originalUrl.startsWith("blob:"))
continue;
try {
const absUrl = new URL(originalUrl, cssUrl).href;
const local = await this.downloadFile(absUrl, assetsDir);
if (local) {
const u = new URL(cssUrl);
const cssPath = u.hostname + u.pathname;
const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
const rel = path.relative(
path.dirname(this.sanitizePath(cssPath)),
this.sanitizePath(assetPath),
);
newContent = newContent.split(originalUrl).join(rel);
urlMap[absUrl] = local;
}
} catch {
// Ignore
}
}
return newContent;
}
}

View File

@@ -0,0 +1,256 @@
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
import axios from "axios";
import { AssetManager, AssetMap } from "./AssetManager.js";
export interface PageClonerOptions {
outputDir: string;
userAgent?: string;
}
export class PageCloner {
private options: PageClonerOptions;
private assetManager: AssetManager;
private userAgent: string;
constructor(options: PageClonerOptions) {
this.options = options;
this.userAgent =
options.userAgent ||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
this.assetManager = new AssetManager(this.userAgent);
}
public async clone(targetUrl: string): Promise<string> {
const urlObj = new URL(targetUrl);
const domainSlug = urlObj.hostname.replace("www.", "");
const domainDir = path.resolve(this.options.outputDir, domainSlug);
const assetsDir = path.join(domainDir, "assets");
if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true });
let pageSlug = urlObj.pathname.split("/").filter(Boolean).join("-");
if (!pageSlug) pageSlug = "index";
const htmlFilename = `${pageSlug}.html`;
console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
userAgent: this.userAgent,
viewport: { width: 1920, height: 1080 },
});
const page = await context.newPage();
const urlMap: AssetMap = {};
const foundAssets = new Set<string>();
page.on("response", (response) => {
if (response.status() === 200) {
const url = response.url();
if (
url.match(
/\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)/i,
)
) {
foundAssets.add(url);
}
}
});
try {
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 90000 });
// Scroll Wave
await page.evaluate(async () => {
await new Promise((resolve) => {
let totalHeight = 0;
const distance = 400;
const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(timer);
window.scrollTo(0, 0);
resolve(true);
}
}, 100);
});
});
const fullHeight = await page.evaluate(() => document.body.scrollHeight);
await page.setViewportSize({ width: 1920, height: fullHeight + 1000 });
await page.waitForTimeout(3000);
// Sanitization
await page.evaluate(() => {
const assetPattern =
/\.(jpg|jpeg|png|gif|svg|webp|mp4|webm|woff2?|ttf|otf)/i;
document.querySelectorAll("*").forEach((el) => {
if (
["META", "LINK", "HEAD", "SCRIPT", "STYLE", "SVG", "PATH"].includes(
el.tagName,
)
)
return;
const htmlEl = el as HTMLElement;
const style = window.getComputedStyle(htmlEl);
if (style.opacity === "0" || style.visibility === "hidden") {
htmlEl.style.setProperty("opacity", "1", "important");
htmlEl.style.setProperty("visibility", "visible", "important");
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
const val = attr.value;
if (
assetPattern.test(val) ||
name.includes("src") ||
name.includes("image")
) {
if (el.tagName === "IMG") {
const img = el as HTMLImageElement;
if (name.includes("srcset")) img.srcset = val;
else if (!img.src || img.src.includes("data:")) img.src = val;
}
if (el.tagName === "SOURCE")
(el as HTMLSourceElement).srcset = val;
if (el.tagName === "VIDEO" || el.tagName === "AUDIO")
(el as HTMLMediaElement).src = val;
if (
val.match(/^(https?:\/\/|\/\/|\/)/) &&
!name.includes("href")
) {
const bg = htmlEl.style.backgroundImage;
if (!bg || bg === "none")
htmlEl.style.backgroundImage = `url('${val}')`;
}
}
}
});
if (document.body) {
document.body.style.setProperty("opacity", "1", "important");
document.body.style.setProperty("visibility", "visible", "important");
}
});
await page.waitForLoadState("networkidle");
await page.waitForTimeout(1000);
const content = await page.content();
const regexPatterns = [
/(?:src|href|url|data-[a-z-]+|srcset)=["']([^"'<>\s]+?\.(?:css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)(?:\?[^"']*)?)["']/gi,
/url\(["']?([^"')]*)["']?\)/gi,
];
for (const pattern of regexPatterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
try {
foundAssets.add(new URL(match[1], targetUrl).href);
} catch {
// Ignore invalid URLs
}
}
}
for (const url of foundAssets) {
const local = await this.assetManager.downloadFile(url, assetsDir);
if (local) {
urlMap[url] = local;
const clean = url.split("?")[0];
urlMap[clean] = local;
if (clean.endsWith(".css")) {
try {
const { data } = await axios.get(url, {
headers: { "User-Agent": this.userAgent },
});
const processedCss =
await this.assetManager.processCssRecursively(
data,
url,
assetsDir,
urlMap,
);
const relPath = this.assetManager.sanitizePath(
new URL(url).hostname + new URL(url).pathname,
);
fs.writeFileSync(path.join(assetsDir, relPath), processedCss);
} catch {
// Ignore stylesheet download/process failures
}
}
}
}
let finalContent = content;
const sortedUrls = Object.keys(urlMap).sort(
(a, b) => b.length - a.length,
);
if (sortedUrls.length > 0) {
const escaped = sortedUrls.map((u) =>
u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
);
const masterRegex = new RegExp(`(${escaped.join("|")})`, "g");
finalContent = finalContent.replace(
masterRegex,
(match) => urlMap[match] || match,
);
}
const commonDirs = [
"/wp-content/",
"/wp-includes/",
"/assets/",
"/static/",
"/images/",
];
for (const dir of commonDirs) {
const localDir = `./assets/${urlObj.hostname}${dir}`;
finalContent = finalContent
.split(`"${dir}`)
.join(`"${localDir}`)
.split(`'${dir}`)
.join(`'${localDir}`)
.split(`(${dir}`)
.join(`(${localDir}`);
}
const domainPattern = new RegExp(
`https?://(www\\.)?${urlObj.hostname.replace(/\./g, "\\.")}[^"']*`,
"gi",
);
finalContent = finalContent.replace(domainPattern, () => "./");
finalContent = finalContent.replace(
/<script\b[^>]*>([\s\S]*?)<\/script>/gi,
(match, scriptContent) => {
const lower = scriptContent.toLowerCase();
return lower.includes("google-analytics") ||
lower.includes("gtag") ||
lower.includes("fbq") ||
lower.includes("lazy") ||
lower.includes("tracker")
? ""
: match;
},
);
const headEnd = finalContent.indexOf("</head>");
if (headEnd > -1) {
const stabilityCss = `\n<style>* { transition: none !important; animation: none !important; scroll-behavior: auto !important; } [data-aos], .reveal, .lazypath, .lazy-load, [data-src] { opacity: 1 !important; visibility: visible !important; transform: none !important; clip-path: none !important; } img, video, iframe { max-width: 100%; display: block; } a { pointer-events: none; cursor: default; } </style>`;
finalContent =
finalContent.slice(0, headEnd) +
stabilityCss +
finalContent.slice(headEnd);
}
const finalPath = path.join(domainDir, htmlFilename);
fs.writeFileSync(finalPath, finalContent);
return finalPath;
} finally {
await browser.close();
}
}
}

View File

@@ -0,0 +1,150 @@
import { PlaywrightCrawler, RequestQueue } from "crawlee";
import * as path from "node:path";
import * as fs from "node:fs";
import { execSync } from "node:child_process";
export interface WebsiteClonerOptions {
baseOutputDir: string;
maxRequestsPerCrawl?: number;
maxConcurrency?: number;
}
export class WebsiteCloner {
private options: WebsiteClonerOptions;
constructor(options: WebsiteClonerOptions) {
this.options = {
maxRequestsPerCrawl: 100,
maxConcurrency: 3,
...options,
};
}
public async clone(
targetUrl: string,
outputDirName?: string,
): Promise<string> {
const urlObj = new URL(targetUrl);
const domain = urlObj.hostname;
const finalOutputDirName = outputDirName || domain.replace(/\./g, "-");
const baseOutputDir = path.resolve(
this.options.baseOutputDir,
finalOutputDirName,
);
if (fs.existsSync(baseOutputDir)) {
fs.rmSync(baseOutputDir, { recursive: true, force: true });
}
fs.mkdirSync(baseOutputDir, { recursive: true });
console.log(`🚀 Starting perfect recursive clone of ${targetUrl}...`);
console.log(`📂 Output: ${baseOutputDir}`);
const requestQueue = await RequestQueue.open();
await requestQueue.addRequest({ url: targetUrl });
const crawler = new PlaywrightCrawler({
requestQueue,
maxRequestsPerCrawl: this.options.maxRequestsPerCrawl,
maxConcurrency: this.options.maxConcurrency,
async requestHandler({ request, enqueueLinks, log }) {
const url = request.url;
log.info(`Capturing ${url}...`);
const u = new URL(url);
let relPath = u.pathname;
if (relPath === "/" || relPath === "") relPath = "/index.html";
if (!relPath.endsWith(".html") && !path.extname(relPath))
relPath += "/index.html";
if (relPath.startsWith("/")) relPath = relPath.substring(1);
const fullPath = path.join(baseOutputDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
try {
// Note: This assumes single-file-cli is available in the environment
execSync(
`npx single-file-cli "${url}" "${fullPath}" --browser-headless=true --browser-wait-until=networkidle0`,
{
stdio: "inherit",
},
);
} catch (_e) {
log.error(`Failed to capture ${url} with SingleFile`);
}
await enqueueLinks({
strategy: "same-domain",
transformRequestFunction: (req) => {
if (
/\.(download|pdf|zip|gz|exe|png|jpg|jpeg|gif|svg|css|js)$/i.test(
req.url,
)
)
return false;
return req;
},
});
},
});
await crawler.run();
console.log("🔗 Rewriting internal links for offline navigation...");
const allFiles = this.getFiles(baseOutputDir).filter((f) =>
f.endsWith(".html"),
);
for (const file of allFiles) {
let content = fs.readFileSync(file, "utf8");
const fileRelToRoot = path.relative(baseOutputDir, file);
content = content.replace(/href="([^"]+)"/g, (match, href) => {
if (
href.startsWith(targetUrl) ||
href.startsWith("/") ||
(!href.includes("://") && !href.startsWith("data:"))
) {
try {
const linkUrl = new URL(href, targetUrl);
if (linkUrl.hostname === domain) {
let linkPath = linkUrl.pathname;
if (linkPath === "/" || linkPath === "") linkPath = "/index.html";
if (!linkPath.endsWith(".html") && !path.extname(linkPath))
linkPath += "/index.html";
if (linkPath.startsWith("/")) linkPath = linkPath.substring(1);
const relativeLink = path.relative(
path.dirname(fileRelToRoot),
linkPath,
);
return `href="${relativeLink}"`;
}
} catch (_e) {
// Ignore link rewriting failures
}
}
return match;
});
fs.writeFileSync(file, content);
}
console.log(`\n✅ Done! Perfect clone complete in: ${baseOutputDir}`);
return baseOutputDir;
}
private getFiles(dir: string, fileList: string[] = []) {
const files = fs.readdirSync(dir);
for (const file of files) {
const name = path.join(dir, file);
if (fs.statSync(name).isDirectory()) {
this.getFiles(name, fileList);
} else {
fileList.push(name);
}
}
return fileList;
}
}

View File

@@ -0,0 +1,3 @@
export * from "./AssetManager.js";
export * from "./PageCloner.js";
export * from "./WebsiteCloner.js";

View File

@@ -0,0 +1,17 @@
{
"extends": "../tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"emitDeclarationOnly": true,
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": false
},
"include": [
"src/**/*"
]
}

View File

@@ -0,0 +1,11 @@
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,6 +1,6 @@
services:
infra-cms:
image: directus/directus:11
image: directus/directus:11.15.2
ports:
- "8059:8055"
networks:
@@ -14,6 +14,7 @@ services:
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"
@@ -21,19 +22,32 @@ services:
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.infra-cms.rule=Host(`cms.localhost`)"
- "traefik.http.services.infra-cms.loadbalancer.server.port=8055"
- "traefik.docker.network=infra"
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: mintel-infra-cms-internal
name: at-mintel-cms-network
infra:
external: true

View File

@@ -1,851 +0,0 @@
import { useApi as e, defineModule as a } from "@directus/extensions-sdk";
import {
defineComponent as t,
ref as l,
onMounted as n,
resolveComponent as i,
resolveDirective as s,
openBlock as d,
createBlock as r,
withCtx as u,
createVNode as o,
createElementBlock as m,
Fragment as c,
renderList as v,
createTextVNode as p,
toDisplayString as f,
createCommentVNode as g,
createElementVNode as y,
withDirectives as b,
nextTick as _,
} from "vue";
const h = { class: "content-wrapper" },
x = { key: 0, class: "empty-state" },
w = { class: "header" },
k = { class: "header-left" },
V = { class: "title" },
C = { class: "subtitle" },
M = { class: "header-right" },
F = { class: "user-cell" },
N = { class: "user-name" },
z = { key: 0, class: "status-date" },
E = { key: 0, class: "drawer-content" },
U = { class: "form-section" },
S = { class: "field" },
A = { class: "drawer-actions" },
T = { key: 0, class: "drawer-content" },
Z = { class: "form-section" },
j = { class: "field" },
$ = { class: "field" },
D = { class: "field" },
O = { key: 1, class: "field" },
W = { class: "drawer-actions" };
var q = t({
__name: "module",
setup(a) {
const t = e(),
q = l([]),
B = l(null),
K = l([]),
L = l(!1),
P = l(!1),
G = l(null),
I = l(null),
H = l(!1),
J = l(!1),
Q = l({ id: "", name: "" }),
R = l(!1),
X = l(!1),
Y = l({
id: "",
first_name: "",
last_name: "",
email: "",
temporary_password: "",
}),
ee = [
{ text: "Name", value: "name", sortable: !0 },
{ text: "E-Mail", value: "email", sortable: !0 },
{ text: "Zuletzt eingeladen", value: "last_invited", sortable: !0 },
];
async function ae() {
const e = await t.get("/items/companies", {
params: { fields: ["id", "name"], sort: "name" },
});
q.value = e.data.data;
}
async function te(e) {
((B.value = e), (L.value = !0));
try {
const a = await t.get("/items/client_users", {
params: {
filter: { company: { _eq: e.id } },
fields: ["*"],
sort: "first_name",
},
});
K.value = a.data.data;
} finally {
L.value = !1;
}
}
function le() {
((J.value = !1), (Q.value = { id: "", name: "" }), (H.value = !0));
}
async function ne() {
B.value &&
((Q.value = { id: B.value.id, name: B.value.name }),
(J.value = !0),
await _(),
(H.value = !0));
}
async function ie() {
var e;
if (Q.value.name) {
P.value = !0;
try {
(J.value
? (await t.patch(`/items/companies/${Q.value.id}`, {
name: Q.value.name,
}),
(I.value = { type: "success", message: "Firma aktualisiert!" }))
: (await t.post("/items/companies", { name: Q.value.name }),
(I.value = { type: "success", message: "Firma angelegt!" })),
(H.value = !1),
await ae(),
(null == (e = B.value) ? void 0 : e.id) === Q.value.id &&
(B.value.name = Q.value.name));
} catch (e) {
I.value = { type: "danger", message: e.message };
} finally {
P.value = !1;
}
}
}
function se() {
((X.value = !1),
(Y.value = {
id: "",
first_name: "",
last_name: "",
email: "",
temporary_password: "",
}),
(R.value = !0));
}
async function de() {
if (Y.value.email && B.value) {
P.value = !0;
try {
(X.value
? (await t.patch(`/items/client_users/${Y.value.id}`, {
first_name: Y.value.first_name,
last_name: Y.value.last_name,
email: Y.value.email,
}),
(I.value = {
type: "success",
message: "Mitarbeiter aktualisiert!",
}))
: (await t.post("/items/client_users", {
first_name: Y.value.first_name,
last_name: Y.value.last_name,
email: Y.value.email,
company: B.value.id,
}),
(I.value = {
type: "success",
message: "Mitarbeiter angelegt!",
})),
(R.value = !1),
await te(B.value));
} catch (e) {
I.value = { type: "danger", message: e.message };
} finally {
P.value = !1;
}
}
}
function re(e) {
const a = (null == e ? void 0 : e.item) || e;
a &&
a.id &&
(async function (e) {
((Y.value = {
id: e.id || "",
first_name: e.first_name || "",
last_name: e.last_name || "",
email: e.email || "",
temporary_password: e.temporary_password || "",
}),
(X.value = !0),
await _(),
(R.value = !0));
})(a);
}
return (
n(() => {
ae();
}),
(e, a) => {
const l = i("v-icon"),
n = i("v-list-item-icon"),
_ = i("v-text-overflow"),
ae = i("v-list-item-content"),
ue = i("v-list-item"),
oe = i("v-divider"),
me = i("v-list"),
ce = i("v-notice"),
ve = i("v-button"),
pe = i("v-info"),
fe = i("v-avatar"),
ge = i("v-chip"),
ye = i("v-table"),
be = i("v-input"),
_e = i("v-drawer"),
he = i("private-view"),
xe = s("tooltip");
return (
d(),
r(
he,
{ title: "Customer Manager" },
{
navigation: u(() => [
o(
me,
{ nav: "" },
{
default: u(() => [
o(
ue,
{ onClick: le, clickable: "" },
{
default: u(() => [
o(n, null, {
default: u(() => [
o(l, {
name: "add",
color: "var(--theme--primary)",
}),
]),
_: 1,
}),
o(ae, null, {
default: u(() => [
o(_, { text: "Neue Firma anlegen" }),
]),
_: 1,
}),
]),
_: 1,
},
),
o(oe),
(d(!0),
m(
c,
null,
v(q.value, (e) => {
var a;
return (
d(),
r(
ue,
{
key: e.id,
active:
(null == (a = B.value) ? void 0 : a.id) ===
e.id,
class: "company-item",
clickable: "",
onClick: (a) => te(e),
},
{
default: u(() => [
o(n, null, {
default: u(() => [
o(l, { name: "business" }),
]),
_: 1,
}),
o(
ae,
null,
{
default: u(() => [
o(_, { text: e.name }, null, 8, [
"text",
]),
]),
_: 2,
},
1024,
),
]),
_: 2,
},
1032,
["active", "onClick"],
)
);
}),
128,
)),
]),
_: 1,
},
),
]),
"title-outer:after": u(() => [
I.value
? (d(),
r(
ce,
{
key: 0,
type: I.value.type,
onClose: a[0] || (a[0] = (e) => (I.value = null)),
dismissible: "",
},
{ default: u(() => [p(f(I.value.message), 1)]), _: 1 },
8,
["type"],
))
: g("v-if", !0),
]),
default: u(() => [
y("div", h, [
B.value
? (d(),
m(
c,
{ key: 1 },
[
y("header", w, [
y("div", k, [
y("h1", V, f(B.value.name), 1),
y(
"p",
C,
f(K.value.length) + " Kunden-Mitarbeiter",
1,
),
]),
y("div", M, [
b(
(d(),
r(
ve,
{
secondary: "",
rounded: "",
icon: "",
onClick: ne,
},
{
default: u(() => [
o(l, { name: "edit" }),
]),
_: 1,
},
)),
[
[
xe,
"Firma bearbeiten",
void 0,
{ bottom: !0 },
],
],
),
o(
ve,
{ primary: "", onClick: se },
{
default: u(() => [
...(a[14] ||
(a[14] = [
p(" Mitarbeiter hinzufügen ", -1),
])),
]),
_: 1,
},
),
]),
]),
o(
ye,
{
headers: ee,
items: K.value,
loading: L.value,
class: "clickable-table",
"fixed-header": "",
"onClick:row": re,
},
{
"item.name": u(({ item: e }) => [
y("div", F, [
o(
fe,
{ name: e.first_name, "x-small": "" },
null,
8,
["name"],
),
y(
"span",
N,
f(e.first_name) + " " + f(e.last_name),
1,
),
]),
]),
"item.last_invited": u(({ item: e }) => {
return [
e.last_invited
? (d(),
m(
"span",
z,
f(
((t = e.last_invited),
new Date(t).toLocaleString(
"de-DE",
{
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
},
)),
),
1,
))
: (d(),
r(
ge,
{ key: 1, "x-small": "" },
{
default: u(() => [
...(a[15] ||
(a[15] = [p("Noch nie", -1)])),
]),
_: 1,
},
)),
];
var t;
}),
_: 2,
},
1032,
["items", "loading"],
),
],
64,
))
: (d(),
m("div", x, [
o(
pe,
{
title: "Firmen auswählen",
icon: "business",
center: "",
},
{
default: u(() => [
a[12] ||
(a[12] = p(
" Wähle eine Firma in der Navigation aus oder ",
-1,
)),
o(
ve,
{ "x-small": "", onClick: le },
{
default: u(() => [
...(a[11] ||
(a[11] = [
p("erstelle eine neue Firma", -1),
])),
]),
_: 1,
},
),
a[13] || (a[13] = p(". ", -1)),
]),
_: 1,
},
),
])),
]),
o(
_e,
{
modelValue: H.value,
"onUpdate:modelValue":
a[2] || (a[2] = (e) => (H.value = e)),
title: J.value
? "Firma bearbeiten"
: "Neue Firma anlegen",
icon: "business",
onCancel: a[3] || (a[3] = (e) => (H.value = !1)),
},
{
default: u(() => [
H.value
? (d(),
m("div", E, [
y("div", U, [
y("div", S, [
a[16] ||
(a[16] = y(
"span",
{ class: "label" },
"Firmenname",
-1,
)),
o(
be,
{
modelValue: Q.value.name,
"onUpdate:modelValue":
a[1] ||
(a[1] = (e) => (Q.value.name = e)),
placeholder: "z.B. KLZ Cables",
autofocus: "",
},
null,
8,
["modelValue"],
),
]),
]),
y("div", A, [
o(
ve,
{
primary: "",
block: "",
loading: P.value,
onClick: ie,
},
{
default: u(() => [
...(a[17] ||
(a[17] = [p("Speichern", -1)])),
]),
_: 1,
},
8,
["loading"],
),
]),
]))
: g("v-if", !0),
]),
_: 1,
},
8,
["modelValue", "title"],
),
o(
_e,
{
modelValue: R.value,
"onUpdate:modelValue":
a[9] || (a[9] = (e) => (R.value = e)),
title: X.value
? "Mitarbeiter bearbeiten"
: "Neuen Mitarbeiter anlegen",
icon: "person",
onCancel: a[10] || (a[10] = (e) => (R.value = !1)),
},
{
default: u(() => [
R.value
? (d(),
m("div", T, [
y("div", Z, [
y("div", j, [
a[18] ||
(a[18] = y(
"span",
{ class: "label" },
"Vorname",
-1,
)),
o(
be,
{
modelValue: Y.value.first_name,
"onUpdate:modelValue":
a[4] ||
(a[4] = (e) =>
(Y.value.first_name = e)),
placeholder: "Vorname",
autofocus: "",
},
null,
8,
["modelValue"],
),
]),
y("div", $, [
a[19] ||
(a[19] = y(
"span",
{ class: "label" },
"Nachname",
-1,
)),
o(
be,
{
modelValue: Y.value.last_name,
"onUpdate:modelValue":
a[5] ||
(a[5] = (e) => (Y.value.last_name = e)),
placeholder: "Nachname",
},
null,
8,
["modelValue"],
),
]),
y("div", D, [
a[20] ||
(a[20] = y(
"span",
{ class: "label" },
"E-Mail",
-1,
)),
o(
be,
{
modelValue: Y.value.email,
"onUpdate:modelValue":
a[6] ||
(a[6] = (e) => (Y.value.email = e)),
placeholder: "E-Mail Adresse",
type: "email",
},
null,
8,
["modelValue"],
),
]),
X.value
? (d(), r(oe, { key: 0 }))
: g("v-if", !0),
X.value
? (d(),
m("div", O, [
a[21] ||
(a[21] = y(
"span",
{ class: "label" },
"Temporäres Passwort",
-1,
)),
o(
be,
{
modelValue:
Y.value.temporary_password,
"onUpdate:modelValue":
a[7] ||
(a[7] = (e) =>
(Y.value.temporary_password = e)),
readonly: "",
class: "password-input",
},
null,
8,
["modelValue"],
),
a[22] ||
(a[22] = y(
"p",
{ class: "field-note" },
"Wird beim Senden der Zugangsdaten automatisch generiert.",
-1,
)),
]))
: g("v-if", !0),
]),
y("div", W, [
o(
ve,
{
primary: "",
block: "",
loading: P.value,
onClick: de,
},
{
default: u(() => [
...(a[23] ||
(a[23] = [p("Daten speichern", -1)])),
]),
_: 1,
},
8,
["loading"],
),
X.value
? (d(),
m(
c,
{ key: 0 },
[
o(oe),
b(
(d(),
r(
ve,
{
secondary: "",
block: "",
loading: G.value === Y.value.id,
onClick:
a[8] ||
(a[8] = (e) =>
(async function (e) {
G.value = e.id;
try {
if (
(await t.post(
"/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501",
[e.id],
),
(I.value = {
type: "success",
message: `Zugangsdaten für ${e.first_name} versendet. 📧`,
}),
await te(B.value),
R.value &&
Y.value.id === e.id)
) {
const a = K.value.find(
(a) => a.id === e.id,
);
a &&
(Y.value.temporary_password =
a.temporary_password);
}
} catch (e) {
I.value = {
type: "danger",
message: `Fehler: ${e.message}`,
};
} finally {
G.value = null;
}
})(Y.value)),
},
{
default: u(() => [
o(l, {
name: "send",
left: "",
}),
a[24] ||
(a[24] = p(
" Zugangsdaten senden ",
-1,
)),
]),
_: 1,
},
8,
["loading"],
)),
[
[
xe,
"Generiert PW, speichert es und sendet E-Mail",
void 0,
{ bottom: !0 },
],
],
),
],
64,
))
: g("v-if", !0),
]),
]))
: g("v-if", !0),
]),
_: 1,
},
8,
["modelValue", "title"],
),
]),
_: 1,
},
)
);
}
);
},
}),
B = [],
K = [];
!(function (e, a) {
if (e && "undefined" != typeof document) {
var t,
l = !0 === a.prepend ? "prepend" : "append",
n = !0 === a.singleTag,
i =
"string" == typeof a.container
? document.querySelector(a.container)
: document.getElementsByTagName("head")[0];
if (n) {
var s = B.indexOf(i);
(-1 === s && ((s = B.push(i) - 1), (K[s] = {})),
(t = K[s] && K[s][l] ? K[s][l] : (K[s][l] = d())));
} else t = d();
(65279 === e.charCodeAt(0) && (e = e.substring(1)),
t.styleSheet
? (t.styleSheet.cssText += e)
: t.appendChild(document.createTextNode(e)));
}
function d() {
var e = document.createElement("style");
if ((e.setAttribute("type", "text/css"), a.attributes))
for (var t = Object.keys(a.attributes), n = 0; n < t.length; n++)
e.setAttribute(t[n], a.attributes[t[n]]);
var s = "prepend" === l ? "afterbegin" : "beforeend";
return (i.insertAdjacentElement(s, e), e);
}
})(
"\n.content-wrapper[data-v-3fd11e72] { padding: 32px; height: 100%; display: flex; flex-direction: column;\n}\n.company-item[data-v-3fd11e72] { cursor: pointer;\n}\n.header[data-v-3fd11e72] { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end;\n}\n.header-right[data-v-3fd11e72] { display: flex; gap: 12px;\n}\n.title[data-v-3fd11e72] { font-size: 24px; font-weight: 800; margin-bottom: 4px;\n}\n.subtitle[data-v-3fd11e72] { color: var(--theme--foreground-subdued); font-size: 14px;\n}\n.empty-state[data-v-3fd11e72] { height: 100%; display: flex; align-items: center; justify-content: center;\n}\n.user-cell[data-v-3fd11e72] { display: flex; align-items: center; gap: 12px;\n}\n.user-name[data-v-3fd11e72] { font-weight: 600;\n}\n.status-date[data-v-3fd11e72] { font-size: 12px; color: var(--theme--foreground-subdued);\n}\n.drawer-content[data-v-3fd11e72] { padding: 24px; display: flex; flex-direction: column; gap: 32px;\n}\n.form-section[data-v-3fd11e72] { display: flex; flex-direction: column; gap: 20px;\n}\n.field[data-v-3fd11e72] { display: flex; flex-direction: column; gap: 8px;\n}\n.label[data-v-3fd11e72] { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px;\n}\n.field-note[data-v-3fd11e72] { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px;\n}\n.drawer-actions[data-v-3fd11e72] { margin-top: 24px; display: flex; flex-direction: column; gap: 12px;\n}\n.password-input[data-v-3fd11e72] textarea {\n\tfont-family: var(--family-monospace);\n\tfont-weight: 800;\n\tcolor: var(--theme--primary) !important;\n\tbackground: var(--theme--background-subdued) !important;\n}\n.clickable-table[data-v-3fd11e72] tbody tr { cursor: pointer; transition: background-color 0.2s ease;\n}\n.clickable-table[data-v-3fd11e72] tbody tr:hover { background-color: var(--theme--background-subdued) !important;\n}\n[data-v-3fd11e72] .v-list-item { cursor: pointer !important;\n}\n",
{},
);
var L = a({
id: "customer-manager",
name: "Customer Manager",
icon: "supervisor_account",
routes: [
{
path: "",
component: ((e, a) => {
const t = e.__vccOpts || e;
for (const [e, l] of a) t[e] = l;
return t;
})(q, [
["__scopeId", "data-v-3fd11e72"],
["__file", "module.vue"],
]),
},
],
});
export { L as default };

View File

@@ -1,29 +0,0 @@
{
"name": "customer-manager",
"description": "Custom High-Fidelity Customer & Company Management for Directus",
"icon": "supervisor_account",
"version": "1.0.0",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"name": "Customer Manager"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,29 +0,0 @@
{
"name": "feedback-commander",
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
"icon": "view_kanban",
"version": "1.0.0",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"index.js"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"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,11 +1,18 @@
{
"name": "@mintel/cms-infra",
"version": "1.6.0",
"version": "1.8.21",
"private": true,
"type": "module",
"scripts": {
"up": "docker compose up -d",
"dev": "npm run up -- --link",
"up": "../../scripts/cms-up.sh",
"down": "docker compose down",
"logs": "docker compose logs -f"
"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 +0,0 @@
xmKX5

View File

@@ -0,0 +1,30 @@
{
"name": "company-manager",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.21",
"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

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,224 @@
<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,38 @@
{
"name": "@mintel/concept-engine",
"version": "1.8.21",
"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": {
"@crawlee/cheerio": "^3.11.2",
"@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",
"zod": "^3.24.2"
},
"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,48 @@
import { ContentGenerator } from "../src/index";
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
// Load .env from mintel.me (since that's where the key is)
dotenv.config({
path: path.resolve(__dirname, "../../../../mintel.me/apps/web/.env"),
});
async function main() {
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!apiKey) {
console.error("❌ OPENROUTER_API_KEY not found");
process.exit(1);
}
const generator = new ContentGenerator(apiKey);
const topic = "Why traditional CMSs are dead for developers";
console.log(`🚀 Generating post for: "${topic}"`);
try {
const post = await generator.generatePost({
topic,
includeResearch: true,
includeDiagrams: true,
includeMemes: true,
});
console.log("\n\n✅ GENERATION COMPLETE");
console.log("--------------------------------------------------");
console.log(`Title: ${post.title}`);
console.log(`Research Points: ${post.research.length}`);
console.log(`Memes Generated: ${post.memes.length}`);
console.log(`Diagrams Generated: ${post.diagrams.length}`);
console.log("--------------------------------------------------");
// Save to file
const outputPath = path.join(__dirname, "output.md");
fs.writeFileSync(outputPath, post.content);
console.log(`📄 Saved output to: ${outputPath}`);
} catch (error) {
console.error("❌ Generation failed:", error);
}
}
main();

View File

@@ -0,0 +1,58 @@
import { ContentGenerator } from "../src/index";
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
// Fix __dirname for ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load .env from mintel.me (since that's where the key is)
dotenv.config({
path: path.resolve(__dirname, "../../../../mintel.me/apps/web/.env"),
});
async function main() {
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!apiKey) {
console.error("❌ OPENROUTER_API_KEY not found");
process.exit(1);
}
const generator = new ContentGenerator(apiKey);
const draftContent = `# The Case for Static Sites
Static sites are faster and more secure. They don't have a database to hack.
They are also cheaper to host. You can use a CDN to serve them globally.
Dynamic sites are complex and prone to errors.`;
console.log("📄 Original Content:");
console.log(draftContent);
console.log("\n🚀 Optimizing content...\n");
try {
const post = await generator.optimizePost(draftContent, {
enhanceFacts: true,
addDiagrams: true,
addMemes: true,
});
console.log("\n\n✅ OPTIMIZATION COMPLETE");
console.log("--------------------------------------------------");
console.log(`Research Points Added: ${post.research.length}`);
console.log(`Memes Generated: ${post.memes.length}`);
console.log(`Diagrams Generated: ${post.diagrams.length}`);
console.log("--------------------------------------------------");
// Save to file
const outputPath = path.join(__dirname, "optimized.md");
fs.writeFileSync(outputPath, post.content);
console.log(`📄 Saved output to: ${outputPath}`);
} catch (error) {
console.error("❌ Optimization failed:", error);
}
}
main();

View File

@@ -0,0 +1,132 @@
import { ContentGenerator, ComponentDefinition } from "../src/index";
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
// Fix __dirname for ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load .env from mintel.me
dotenv.config({
path: path.resolve(__dirname, "../../../../mintel.me/apps/web/.env"),
});
async function main() {
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!apiKey) {
console.error("❌ OPENROUTER_API_KEY not found");
process.exit(1);
}
const generator = new ContentGenerator(apiKey);
const contentToOptimize = `
"Wir können nicht wechseln, das wäre zu teuer."
In meiner Arbeit als Digital Architect ist das der Anfang vom Ende jeder technologischen Innovation.
Vendor Lock-In ist die digitale Version einer Geiselnahme.
Ich zeige Ihnen, wie wir Systeme bauen, die Ihnen jederzeit die volle Freiheit lassen technologisch und wirtschaftlich.
Die unsichtbaren Ketten proprietärer Systeme
Viele Unternehmen lassen sich von der Bequemlichkeit großer SaaS-Plattformen oder Baukästen blenden.
Man bekommt ein schnelles Feature, gibt aber dafür die Kontrolle über seine Daten und seine Codebasis ab.
Nach zwei Jahren sind Sie so tief im Ökosystem eines Anbieters verstrickt, dass ein Auszug unmöglich scheint.
Der Anbieter weiß das und diktiert fortan die Preise und das Tempo Ihrer Entwicklung.
Ich nenne das technologische Erpressbarkeit.
Wahre Unabhängigkeit beginnt bei der strategischen Wahl der Architektur.
Technologische Souveränität als Asset
Software sollte für Sie arbeiten, nicht umgekehrt.
Indem wir auf offene Standards und portable Architekturen setzen, verwandeln wir Code in ein echtes Firmen-Asset.
Sie können den Cloud-Anbieter wechseln, die Agentur tauschen oder das Team skalieren ohne jemals bei Null anfangen zu müssen.
Das ist das Privileg der technologischen Elite.
Portabilität ist kein technisches Gimmick, sondern eine unternehmerische Notwendigkeit.
Meine Architektur der Ungebundenheit
Ich baue keine "Käfige" aus fertigen Plugins.
Mein Framework basiert auf Modularität und Klarheit.
Standard-basiertes Engineering: Wir nutzen Technologien, die weltweit verstanden werden. Keine geheimen "Spezial-Module" eines einzelnen Anbieters.
Daten-Portabilität: Ihre Daten gehören Ihnen. Zu jeder Zeit. Wir bauen Schnittstellen, die den Export so einfach machen wie den Import.
Cloud-agnostisches Hosting: Wir nutzen Container-Technologie. Ob AWS, Azure oder lokale Anbieter Ihr Code läuft überall gleich perfekt.
Der strategische Hebel für langfristige Rendite
Systeme ohne Lock-In altern besser.
Sie lassen sich schrittweise modernisieren, statt alle fünf Jahre komplett neu gebaut werden zu müssen.
Das spart Millionen an Opportunitätskosten und Fehl-Investitionen.
Seien Sie der Herr über Ihr digitales Schicksal.
Investieren Sie in intelligente Unabhängigkeit.
Für wen ich 'Freiheits-Systeme' erstelle
Ich arbeite für Gründer, die ihr Unternehmen langfristig wertvoll aufstellen wollen.
Ist digitale Exzellenz Teil Ihrer Exit-Strategie oder Ihres Erbes? Dann brauchen Sie meine Architektur.
Ich baue keine Provisorien, sondern nachhaltige Werte.
Fazit: Freiheit ist eine Wahl
Technologie sollte Ihnen Flügel verleihen, keine Fesseln anlegen.
Lassen Sie uns gemeinsam ein System schaffen, das so flexibel ist wie Ihr Business.
Werden Sie unersetzbar durch Qualität, nicht durch Abhängigkeit. Ihr Erfolg verdient absolute Freiheit.
`;
// Define components available in mintel.me
const availableComponents: ComponentDefinition[] = [
{
name: "LeadParagraph",
description: "Large, introductory text for the beginning of the article.",
usageExample: "<LeadParagraph>First meaningful sentence.</LeadParagraph>",
},
{
name: "H2",
description: "Section heading.",
usageExample: "<H2>Section Title</H2>",
},
{
name: "H3",
description: "Subsection heading.",
usageExample: "<H3>Subtitle</H3>",
},
{
name: "Paragraph",
description: "Standard body text paragraph.",
usageExample: "<Paragraph>Some text...</Paragraph>",
},
{
name: "ArticleBlockquote",
description: "A prominent quote block for key insights.",
usageExample: "<ArticleBlockquote>Important quote</ArticleBlockquote>",
},
{
name: "Marker",
description: "Yellow highlighter effect for very important phrases.",
usageExample: "<Marker>Highlighted Text</Marker>",
},
{
name: "ComparisonRow",
description: "A component comparing a negative vs positive scenario.",
usageExample:
'<ComparisonRow description="Cost Comparison" negativeLabel="Lock-In" negativeText="High costs" positiveLabel="Open" positiveText="Control" />',
},
];
console.log('🚀 Optimizing "Vendor Lock-In" post...');
try {
const post = await generator.optimizePost(contentToOptimize, {
enhanceFacts: true,
addDiagrams: true,
addMemes: true,
availableComponents,
});
console.log("\n\n✅ OPTIMIZATION COMPLETE");
// Save to a file in the package dir
const outputPath = path.join(__dirname, "VendorLockIn_OPTIMIZED.md");
fs.writeFileSync(outputPath, post.content);
console.log(`📄 Saved output to: ${outputPath}`);
} catch (error) {
console.error("❌ Optimization failed:", error);
}
}
main();

View File

@@ -0,0 +1,71 @@
import { ContentGenerator, ComponentDefinition } from "../src/index";
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
// Fix __dirname for ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load .env from mintel.me (since that's where the key is)
dotenv.config({
path: path.resolve(__dirname, "../../../../mintel.me/apps/web/.env"),
});
async function main() {
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!apiKey) {
console.error("❌ OPENROUTER_API_KEY not found");
process.exit(1);
}
const generator = new ContentGenerator(apiKey);
const draftContent = `# Improving User Retention
User retention is key. You need to keep users engaged.
Offer them value and they will stay.
If they have questions, they should contact support.`;
const availableComponents: ComponentDefinition[] = [
{
name: "InfoCard",
description: "A colored box to highlight important tips or warnings.",
usageExample:
'<InfoCard variant="warning" title="Pro Tip">Always measure retention.</InfoCard>',
},
{
name: "CallToAction",
description: "A prominent button for conversion.",
usageExample: '<CallToAction href="/contact">Get in Touch</CallToAction>',
},
];
console.log("📄 Original Content:");
console.log(draftContent);
console.log("\n🚀 Optimizing content with components...\n");
try {
const post = await generator.optimizePost(draftContent, {
enhanceFacts: true,
addDiagrams: false, // Skip diagrams for this test to focus on components
addMemes: false,
availableComponents,
});
console.log("\n\n✅ OPTIMIZATION COMPLETE");
console.log("--------------------------------------------------");
console.log(post.content);
console.log("--------------------------------------------------");
// Save to file
const outputPath = path.join(__dirname, "optimized-components.md");
fs.writeFileSync(outputPath, post.content);
console.log(`📄 Saved output to: ${outputPath}`);
} catch (error) {
console.error("❌ Optimization failed:", error);
}
}
main();

View File

@@ -0,0 +1,34 @@
{
"name": "@mintel/content-engine",
"version": "1.8.21",
"private": false,
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsup src/index.ts --format esm --dts --clean",
"dev": "tsup src/index.ts --format esm --watch --dts",
"lint": "eslint src"
},
"dependencies": {
"@mintel/journaling": "workspace:*",
"@mintel/meme-generator": "workspace:*",
"@mintel/thumbnail-generator": "workspace:*",
"dotenv": "^17.3.1",
"openai": "^4.82.0"
},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@types/node": "^20.0.0",
"tsup": "^8.3.5",
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,990 @@
import OpenAI from "openai";
import { ResearchAgent, type Fact, type SocialPost } from "@mintel/journaling";
import { MemeGenerator, MemeSuggestion } from "@mintel/meme-generator";
import * as fs from "node:fs/promises";
import * as path from "node:path";
export interface ComponentDefinition {
name: string;
description: string;
usageExample: string;
}
export interface BlogPostOptions {
topic: string;
tone?: string;
targetAudience?: string;
includeMemes?: boolean;
includeDiagrams?: boolean;
includeResearch?: boolean;
availableComponents?: ComponentDefinition[];
}
export interface OptimizationOptions {
enhanceFacts?: boolean;
addMemes?: boolean;
addDiagrams?: boolean;
availableComponents?: ComponentDefinition[];
projectContext?: string;
/** Target audience description for all AI prompts */
targetAudience?: string;
/** Tone/persona description for all AI prompts */
tone?: string;
/** Prompt for DALL-E 3 style generation */
memeStylePrompt?: string;
/** Path to the docs folder (e.g. apps/web/docs) for full persona/tone context */
docsPath?: string;
}
export interface GeneratedPost {
title: string;
content: string;
research: Fact[];
memes: MemeSuggestion[];
diagrams: string[];
}
interface Insertion {
afterSection: number;
content: string;
}
// 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",
// Mermaid diagram generation - User requested Pro
DIAGRAM: "google/gemini-2.5-pro",
} as const;
/** Strip markdown fences that some models wrap around JSON despite response_format */
function safeParseJSON(raw: string, fallback: any = {}): any {
let cleaned = raw.trim();
// Remove ```json ... ``` or ``` ... ``` wrapping
if (cleaned.startsWith("```")) {
cleaned = cleaned
.replace(/^```(?:json)?\s*\n?/, "")
.replace(/\n?```\s*$/, "");
}
try {
return JSON.parse(cleaned);
} catch (e) {
console.warn(
"⚠️ Failed to parse JSON response, using fallback:",
(e as Error).message,
);
return fallback;
}
}
export class ContentGenerator {
private openai: OpenAI;
private researchAgent: ResearchAgent;
private memeGenerator: MemeGenerator;
constructor(apiKey: string) {
this.openai = new OpenAI({
apiKey,
baseURL: "https://openrouter.ai/api/v1",
defaultHeaders: {
"HTTP-Referer": "https://mintel.me",
"X-Title": "Mintel Content Engine",
},
});
this.researchAgent = new ResearchAgent(apiKey);
this.memeGenerator = new MemeGenerator(apiKey);
}
// =========================================================================
// generatePost — for new posts (unchanged from original)
// =========================================================================
async generatePost(options: BlogPostOptions): Promise<GeneratedPost> {
const {
topic,
tone = "professional yet witty",
includeResearch = true,
availableComponents = [],
} = options;
console.log(`🚀 Starting content generation for: "${topic}"`);
let facts: Fact[] = [];
if (includeResearch) {
console.log("📚 Gathering research...");
facts = await this.researchAgent.researchTopic(topic);
}
console.log("📝 Creating outline...");
const outline = await this.createOutline(topic, facts, tone);
console.log("✍️ Drafting content...");
let content = await this.draftContent(
topic,
outline,
facts,
tone,
availableComponents,
);
const diagrams: string[] = [];
if (options.includeDiagrams) {
content = await this.processDiagramPlaceholders(content, diagrams);
}
const memes: MemeSuggestion[] = [];
if (options.includeMemes) {
const memeIdeas = await this.memeGenerator.generateMemeIdeas(
content.slice(0, 4000),
);
memes.push(...memeIdeas);
}
return { title: outline.title, content, research: facts, memes, diagrams };
}
// =========================================================================
// generateTldr — Creates a TL;DR block for the given content
// =========================================================================
async generateTldr(content: string): Promise<string> {
const context = content.slice(0, 3000);
const response = await this.openai.chat.completions.create({
model: MODELS.CONTENT,
messages: [
{
role: "system",
content: `Du bist ein kompromissloser Digital Architect.
Erstelle ein "TL;DR" für diesen Artikel.
REGELN:
- 3 knackige Bulletpoints
- TON: Sarkastisch, direkt, provokant ("Finger in die Wunde")
- Fokussiere auf den wirtschaftlichen Schaden von schlechter Tech
- Formatiere als MDX-Komponente:
<div className="my-8 p-6 bg-slate-50 border-l-4 border-blue-600 rounded-r-xl">
<H3>TL;DR: Warum Ihr Geld verbrennt</H3>
<ul className="list-disc pl-5 space-y-2 mb-0">
<li>Punkt 1</li>
<li>Punkt 2</li>
<li>Punkt 3</li>
</ul>
</div>`,
},
{
role: "user",
content: context,
},
],
});
return response.choices[0].message.content?.trim() ?? "";
}
// =========================================================================
// optimizePost — ADDITIVE architecture (never rewrites original content)
// =========================================================================
async optimizePost(
content: string,
options: OptimizationOptions,
): Promise<GeneratedPost> {
console.log("🚀 Optimizing existing content (additive mode)...");
// Load docs context if provided
let docsContext = "";
if (options.docsPath) {
docsContext = await this.loadDocsContext(options.docsPath);
console.log(`📖 Loaded ${docsContext.length} chars of docs context`);
}
const fullContext = [options.projectContext || "", docsContext]
.filter(Boolean)
.join("\n\n---\n\n");
// Split content into numbered sections for programmatic insertion
const sections = this.splitIntoSections(content);
console.log(`📋 Content has ${sections.length} sections`);
const insertions: Insertion[] = [];
const facts: Fact[] = [];
const diagrams: string[] = [];
const memes: MemeSuggestion[] = [];
// Build a numbered content map for LLM reference (read-only)
const sectionMap = this.buildSectionMap(sections);
// ----- STEP 1: Research -----
if (options.enhanceFacts) {
console.log("🔍 Identifying research topics...");
const researchTopics = await this.identifyResearchTopics(
content,
fullContext,
);
console.log(`📚 Researching: ${researchTopics.join(", ")}`);
for (const topic of researchTopics) {
const topicFacts = await this.researchAgent.researchTopic(topic);
facts.push(...topicFacts);
}
if (facts.length > 0) {
console.log(`📝 Planning fact insertions for ${facts.length} facts...`);
const factInsertions = await this.planFactInsertions(
sectionMap,
sections,
facts,
fullContext,
);
insertions.push(...factInsertions);
console.log(`${factInsertions.length} fact enrichments planned`);
}
// ----- STEP 1.5: Social Media Extraction (no LLM — regex only) -----
console.log("📱 Extracting existing social media embeds...");
const socialPosts = this.researchAgent.extractSocialPosts(content);
// If none exist, fetch real ones via Serper API
if (socialPosts.length === 0) {
console.log(
" → None found. Fetching real social posts via Serper API...",
);
const newPosts = await this.researchAgent.fetchRealSocialPosts(
content.slice(0, 500),
);
socialPosts.push(...newPosts);
}
if (socialPosts.length > 0) {
console.log(
`📝 Planning placement for ${socialPosts.length} social media posts...`,
);
const socialInsertions = await this.planSocialMediaInsertions(
sectionMap,
sections,
socialPosts,
fullContext,
);
insertions.push(...socialInsertions);
console.log(
`${socialInsertions.length} social embeddings planned`,
);
}
}
// ----- STEP 2: Component suggestions -----
if (options.availableComponents && options.availableComponents.length > 0) {
console.log("🧩 Planning component additions...");
const componentInsertions = await this.planComponentInsertions(
sectionMap,
sections,
options.availableComponents,
fullContext,
);
insertions.push(...componentInsertions);
console.log(
`${componentInsertions.length} component additions planned`,
);
}
// ----- STEP 3: Diagram generation -----
if (options.addDiagrams) {
console.log("📊 Planning diagrams...");
const diagramPlans = await this.planDiagramInsertions(
sectionMap,
sections,
fullContext,
);
for (const plan of diagramPlans) {
const mermaidCode = await this.generateMermaid(plan.concept);
if (!mermaidCode) {
console.warn(` ⏭️ Skipping invalid diagram for: "${plan.concept}"`);
continue;
}
diagrams.push(mermaidCode);
const diagramId = plan.concept
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.slice(0, 40);
insertions.push({
afterSection: plan.afterSection,
content: `<div className="my-8">\n <Mermaid id="${diagramId}" title="${plan.concept}" showShare={true}>\n${mermaidCode}\n </Mermaid>\n</div>`,
});
}
console.log(
`${diagramPlans.length} diagrams planned, ${diagrams.length} valid`,
);
}
// ----- STEP 4: Meme placement (memegen.link via ArticleMeme) -----
if (options.addMemes) {
console.log("✨ Generating meme ideas...");
let memeIdeas = await this.memeGenerator.generateMemeIdeas(
content.slice(0, 4000),
);
// User requested to explicitly limit memes to max 1 per page to prevent duplication
if (memeIdeas.length > 1) {
memeIdeas = [memeIdeas[0]];
}
memes.push(...memeIdeas);
if (memeIdeas.length > 0) {
console.log(
`🎨 Planning meme placement for ${memeIdeas.length} memes...`,
);
const memePlacements = await this.planMemePlacements(
sectionMap,
sections,
memeIdeas,
);
for (let i = 0; i < memeIdeas.length; i++) {
const meme = memeIdeas[i];
if (
memePlacements[i] !== undefined &&
memePlacements[i] >= 0 &&
memePlacements[i] < sections.length
) {
const captionsStr = meme.captions.join("|");
insertions.push({
afterSection: memePlacements[i],
content: `<div className="my-8">\n <ArticleMeme template="${meme.template}" captions="${captionsStr}" />\n</div>`,
});
}
}
console.log(`${memeIdeas.length} memes placed`);
}
}
// ----- Enforce visual spacing (no consecutive visualizations) -----
this.enforceVisualSpacing(insertions, sections);
// ----- Apply all insertions to original content -----
console.log(
`\n🔧 Applying ${insertions.length} insertions to original content...`,
);
let optimizedContent = this.applyInsertions(sections, insertions);
// ----- FINAL AGENTIC REWRITE (Replaces dumb regex scripts) -----
console.log(
`\n🧠 Agentic Rewrite: Polishing MDX, fixing syntax, and deduplicating...`,
);
const finalRewrite = await this.openai.chat.completions.create({
model: MODELS.CONTENT,
messages: [
{
role: "system",
content: `You are an expert MDX Editor. Your task is to take a draft blog post and output the FINAL, error-free MDX code.
CRITICAL RULES:
1. DEDUPLICATION: Ensure there is MAX ONE <ArticleMeme> in the entire post. Remove any duplicates or outdated memes. Ensure there is MAX ONE TL;DR section. Ensure there are no duplicate components.
2. TEXT-TO-COMPONENT RATIO: Ensure there are at least 3-4 paragraphs of normal text between any two visual components (<Mermaid>, <ArticleMeme>, <StatsGrid>, <BoldNumber>, etc.). If they are clumped together, spread them out or delete the less important ones.
3. SYNTAX: Fix any broken Mermaid/MDX syntax (e.g. unclosed tags, bad quotes).
4. FIDELITY: Preserve the author's original German text, meaning, and tone. Smooth out transitions into the components.
5. NO HALLUCINATION: Do not invent new URLs or facts. Keep the data provided in the draft.
6. OUTPUT: Return ONLY the raw MDX content. No markdown code blocks (\`\`\`mdx), no preamble. Just the raw code file.`,
},
{
role: "user",
content: optimizedContent,
},
],
});
optimizedContent =
finalRewrite.choices[0].message.content?.trim() || optimizedContent;
// Strip any residual markdown formatting fences just in case
if (optimizedContent.startsWith("```")) {
optimizedContent = optimizedContent
.replace(/^```[a-zA-Z]*\n/, "")
.replace(/\n```$/, "");
}
return {
title: "Optimized Content",
content: optimizedContent,
research: facts,
memes,
diagrams,
};
}
// =========================================================================
// ADDITIVE HELPERS — these return JSON instructions, never rewrite content
// =========================================================================
private splitIntoSections(content: string): string[] {
// Split on double newlines (paragraph/block boundaries in MDX)
return content.split(/\n\n+/);
}
private applyInsertions(sections: string[], insertions: Insertion[]): string {
// Sort by section index DESCENDING to avoid index shifting
const sorted = [...insertions].sort(
(a, b) => b.afterSection - a.afterSection,
);
const result = [...sections];
for (const ins of sorted) {
const idx = Math.min(ins.afterSection + 1, result.length);
result.splice(idx, 0, ins.content);
}
return result.join("\n\n");
}
/**
* Enforce visual spacing: visual components must have at least 2 text sections between them.
* This prevents walls of visualizations and maintains reading flow.
*/
private enforceVisualSpacing(
insertions: Insertion[],
sections: string[],
): void {
const visualPatterns = [
"<Mermaid",
"<ArticleMeme",
"<StatsGrid",
"<StatsDisplay",
"<BoldNumber",
"<MetricBar",
"<ComparisonRow",
"<PremiumComparisonChart",
"<DiagramFlow",
"<DiagramPie",
"<DiagramGantt",
"<DiagramState",
"<DiagramSequence",
"<DiagramTimeline",
"<Carousel",
"<WebVitalsScore",
"<WaterfallChart",
];
const isVisual = (content: string) =>
visualPatterns.some((p) => content.includes(p));
// Sort by section ascending
insertions.sort((a, b) => a.afterSection - b.afterSection);
// Minimum gap of 10 sections between visual components (= ~6-8 text paragraphs)
// User requested a better text-to-component ratio (not 1:1)
const MIN_VISUAL_GAP = 10;
for (let i = 1; i < insertions.length; i++) {
if (
isVisual(insertions[i].content) &&
isVisual(insertions[i - 1].content)
) {
const gap = insertions[i].afterSection - insertions[i - 1].afterSection;
if (gap < MIN_VISUAL_GAP) {
const newPos = Math.min(
insertions[i - 1].afterSection + MIN_VISUAL_GAP,
sections.length - 1,
);
insertions[i].afterSection = newPos;
}
}
}
}
private buildSectionMap(sections: string[]): string {
return sections
.map((s, i) => {
const preview = s.trim().replace(/\n/g, " ").slice(0, 120);
return `[${i}] ${preview}${s.length > 120 ? "…" : ""}`;
})
.join("\n");
}
private async loadDocsContext(docsPath: string): Promise<string> {
try {
const files = await fs.readdir(docsPath);
const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
const contents: string[] = [];
for (const file of mdFiles) {
const filePath = path.join(docsPath, file);
const text = await fs.readFile(filePath, "utf8");
contents.push(`=== ${file} ===\n${text.trim()}`);
}
return contents.join("\n\n");
} catch (e) {
console.warn(`⚠️ Could not load docs from ${docsPath}: ${e}`);
return "";
}
}
// --- Fact insertion planning (Claude Sonnet — precise content understanding) ---
private async planFactInsertions(
sectionMap: string,
sections: string[],
facts: Fact[],
context: string,
): Promise<Insertion[]> {
const factsText = facts
.map((f, i) => `${i + 1}. ${f.statement} [Source: ${f.source}]`)
.join("\n");
const response = await this.openai.chat.completions.create({
model: MODELS.CONTENT,
messages: [
{
role: "system",
content: `You enrich a German blog post by ADDING new paragraphs with researched facts.
RULES:
- Do NOT rewrite or modify any existing content
- Only produce NEW <Paragraph> blocks to INSERT after a specific section number
- Maximum 5 insertions (only the most impactful facts)
- Match the post's tone and style (see context below)
- Use the post's JSX components: <Paragraph>, <Marker> for emphasis
- Cite sources using ExternalLink: <ExternalLink href="URL">Source: Name</ExternalLink>
- Write in German, active voice, Ich-Form where appropriate
CONTEXT (tone, style, persona):
${context.slice(0, 3000)}
EXISTING SECTIONS (read-only — do NOT modify these):
${sectionMap}
FACTS TO INTEGRATE:
${factsText}
Return JSON:
{ "insertions": [{ "afterSection": 3, "content": "<Paragraph>\\n Fact-enriched paragraph text. [Source: Name]\\n</Paragraph>" }] }
Return ONLY the JSON.`,
},
],
response_format: { type: "json_object" },
});
const result = safeParseJSON(
response.choices[0].message.content || '{"insertions": []}',
{ insertions: [] },
);
return (result.insertions || []).filter(
(i: any) =>
typeof i.afterSection === "number" &&
i.afterSection >= 0 &&
i.afterSection < sections.length &&
typeof i.content === "string",
);
}
// --- Social Media insertion planning ---
private async planSocialMediaInsertions(
sectionMap: string,
sections: string[],
posts: SocialPost[],
context: string,
): Promise<Insertion[]> {
if (!posts || posts.length === 0) return [];
const postsText = posts
.map(
(p, i) =>
`[${i}] Platform: ${p.platform}, ID: ${p.embedId} (${p.description})`,
)
.join("\n");
const response = await this.openai.chat.completions.create({
model: MODELS.CONTENT,
messages: [
{
role: "system",
content: `You enhance a German blog post by embedding relevant social media posts (YouTube, Twitter, LinkedIn).
RULES:
- Do NOT rewrite any existing content
- Return exactly 1 or 2 high-impact insertions
- Choose the best fitting post(s) from the provided list
- Use the correct component based on the platform:
- youtube -> <YouTubeEmbed videoId="ID" />
- twitter -> <TwitterEmbed tweetId="ID" theme="light" />
- linkedin -> <LinkedInEmbed urn="ID" />
- Add a 1-sentence intro paragraph above the embed to contextualize it naturally in the flow of the text (e.g. "Wie Experte XY im folgenden Video detailliert erklärt:"). This context is MANDATORY. Do not just drop the Component without text reference.
CONTEXT:
${context.slice(0, 3000)}
SOCIAL POSTS AVAILABLE TO EMBED:
${postsText}
EXISTING SECTIONS:
${sectionMap}
Return JSON:
{ "insertions": [{ "afterSection": 4, "content": "<Paragraph>Wie Experten passend bemerken:</Paragraph>\\n\\n<TwitterEmbed tweetId=\\"123456\\" theme=\\"light\\" />" }] }
Return ONLY the JSON.`,
},
],
response_format: { type: "json_object" },
});
const result = safeParseJSON(
response.choices[0].message.content || '{"insertions": []}',
{ insertions: [] },
);
return (result.insertions || []).filter(
(i: any) =>
typeof i.afterSection === "number" &&
i.afterSection >= 0 &&
i.afterSection < sections.length &&
typeof i.content === "string",
);
}
// --- Component insertion planning (Claude Sonnet — understands JSX context) ---
private async planComponentInsertions(
sectionMap: string,
sections: string[],
components: ComponentDefinition[],
context: string,
): Promise<Insertion[]> {
const fullContent = sections.join("\n\n");
const componentsText = components
.map((c) => `<${c.name}>: ${c.description}\n Example: ${c.usageExample}`)
.join("\n\n");
const usedComponents = components
.filter((c) => fullContent.includes(`<${c.name}`))
.map((c) => c.name);
const response = await this.openai.chat.completions.create({
model: MODELS.CONTENT,
messages: [
{
role: "system",
content: `You enhance a German blog post by ADDING interactive UI components.
STRICT BALANCE RULES:
- Maximum 34 component additions total
- There MUST be at least 34 text paragraphs between any two visual components
- Visual components MUST NEVER appear directly after each other
- Each unique component type should only appear ONCE (e.g., only one WebVitalsScore, one WaterfallChart)
- Multiple MetricBar or ComparisonRow in sequence are OK (they form a group)
CONTENT RULES:
- Do NOT rewrite any existing content — only ADD new component blocks
- Do NOT add components already present: ${usedComponents.join(", ") || "none"}
- Statistics MUST have comparison context (before/after, competitor vs us) — never standalone numbers
- All BoldNumber components MUST include source and sourceUrl props
- All ArticleQuote components MUST include source and sourceUrl; add "(übersetzt)" if translated
- MetricBar value must be a real number > 0, not placeholder zeros
- Carousel items array must have at least 2 items with substantive content
- Use exact JSX syntax from the examples
CONTEXT:
${context.slice(0, 3000)}
EXISTING SECTIONS (read-only):
${sectionMap}
AVAILABLE COMPONENTS:
${componentsText}
Return JSON:
{ "insertions": [{ "afterSection": 5, "content": "<StatsDisplay value=\\"100\\" label=\\"PageSpeed Score\\" subtext=\\"Kein Kompromiss.\\" />" }] }
Return ONLY the JSON.`,
},
],
response_format: { type: "json_object" },
});
const result = safeParseJSON(
response.choices[0].message.content || '{"insertions": []}',
{ insertions: [] },
);
return (result.insertions || []).filter(
(i: any) =>
typeof i.afterSection === "number" &&
i.afterSection >= 0 &&
i.afterSection < sections.length &&
typeof i.content === "string",
);
}
// --- Diagram planning (Gemini Flash — structured output) ---
private async planDiagramInsertions(
sectionMap: string,
sections: string[],
context: string,
): Promise<{ afterSection: number; concept: string }[]> {
const fullContent = sections.join("\n\n");
const hasDiagrams =
fullContent.includes("<Mermaid") || fullContent.includes("<Diagram");
const response = await this.openai.chat.completions.create({
model: MODELS.STRUCTURED,
messages: [
{
role: "system",
content: `Analyze this German blog post and suggest 1-2 Mermaid diagrams.
${hasDiagrams ? "The post already has diagrams. Only suggest NEW concepts not already visualized." : ""}
${context.slice(0, 1500)}
SECTIONS:
${sectionMap}
Return JSON:
{ "diagrams": [{ "afterSection": 5, "concept": "Descriptive concept name" }] }
Maximum 2 diagrams. Return ONLY the JSON.`,
},
],
response_format: { type: "json_object" },
});
const result = safeParseJSON(
response.choices[0].message.content || '{"diagrams": []}',
{ diagrams: [] },
);
return (result.diagrams || []).filter(
(d: any) =>
typeof d.afterSection === "number" &&
d.afterSection >= 0 &&
d.afterSection < sections.length,
);
}
// --- Meme placement planning (Gemini Flash — structural positioning) ---
private async planMemePlacements(
sectionMap: string,
sections: string[],
memes: MemeSuggestion[],
): Promise<number[]> {
const memesText = memes
.map((m, i) => `${i}: "${m.template}" — ${m.captions.join(" / ")}`)
.join("\n");
const response = await this.openai.chat.completions.create({
model: MODELS.STRUCTURED,
messages: [
{
role: "system",
content: `Place ${memes.length} memes at appropriate positions in this blog post.
Rules: Space them out evenly, place between thematic sections, never at position 0 (the very start).
SECTIONS:
${sectionMap}
MEMES:
${memesText}
Return JSON: { "placements": [sectionNumber, sectionNumber, ...] }
One section number per meme, in the same order as the memes list. Return ONLY JSON.`,
},
],
response_format: { type: "json_object" },
});
const result = safeParseJSON(
response.choices[0].message.content || '{"placements": []}',
{ placements: [] },
);
return result.placements || [];
}
// =========================================================================
// SHARED HELPERS
// =========================================================================
private async createOutline(
topic: string,
facts: Fact[],
tone: string,
): Promise<{ title: string; sections: string[] }> {
const factsContext = facts
.map((f) => `- ${f.statement} (${f.source})`)
.join("\n");
const response = await this.openai.chat.completions.create({
model: MODELS.STRUCTURED,
messages: [
{
role: "system",
content: `Create a blog post outline on "${topic}".
Tone: ${tone}.
Incorporating these facts:
${factsContext}
Return JSON: { "title": "Catchy Title", "sections": ["Introduction", "Section 1", "Conclusion"] }
Return ONLY the JSON.`,
},
],
response_format: { type: "json_object" },
});
return safeParseJSON(
response.choices[0].message.content || '{"title": "", "sections": []}',
{ title: "", sections: [] },
);
}
private async draftContent(
topic: string,
outline: { title: string; sections: string[] },
facts: Fact[],
tone: string,
components: ComponentDefinition[],
): Promise<string> {
const factsContext = facts
.map((f) => `- ${f.statement} (Source: ${f.source})`)
.join("\n");
const componentsContext =
components.length > 0
? `\n\nAvailable Components:\n` +
components
.map(
(c) =>
`- <${c.name}>: ${c.description}\n Example: ${c.usageExample}`,
)
.join("\n")
: "";
const response = await this.openai.chat.completions.create({
model: MODELS.CONTENT,
messages: [
{
role: "system",
content: `Write a blog post based on this outline:
Title: ${outline.title}
Sections: ${outline.sections.join(", ")}
Tone: ${tone}.
Facts: ${factsContext}
${componentsContext}
BLOG POST BEST PRACTICES (MANDATORY):
- DEVIL'S ADVOCATE: Füge zwingend eine kurze kritische Sektion ein (z.B. mit \`<ComparisonRow>\` oder \`<IconList>\`), in der du offen die Nachteile/Kosten/Haken deiner eigenen Lösung ansprichst ("Der Haken an der Sache...").
- FAQ GENERATOR: Am absoluten Ende des Artikels erstellst du zwingend eine Markdown-Liste mit den 3 wichtigsten Fragen (FAQ) und Antworten (jeweils 2 Sätze) für Google Rich Snippets.
- Nutze wo passend die obigen React-Komponenten für ein hochwertiges Layout.
Format as Markdown. Start with # H1.
For places where a diagram would help, insert: <!-- DIAGRAM_PLACEHOLDER: Concept Name -->
Return ONLY raw content.`,
},
],
});
return response.choices[0].message.content || "";
}
private async processDiagramPlaceholders(
content: string,
diagrams: string[],
): Promise<string> {
const matches = content.matchAll(/<!-- DIAGRAM_PLACEHOLDER: (.+?) -->/g);
let processedContent = content;
for (const match of Array.from(matches)) {
const concept = match[1];
const diagram = await this.generateMermaid(concept);
diagrams.push(diagram);
const diagramId = concept
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.slice(0, 40);
const mermaidJsx = `\n<div className="my-8">\n <Mermaid id="${diagramId}" title="${concept}" showShare={true}>\n${diagram}\n </Mermaid>\n</div>\n`;
processedContent = processedContent.replace(
`<!-- DIAGRAM_PLACEHOLDER: ${concept} -->`,
mermaidJsx,
);
}
return processedContent;
}
private async generateMermaid(concept: string): Promise<string> {
const response = await this.openai.chat.completions.create({
model: MODELS.DIAGRAM,
messages: [
{
role: "system",
content: `Generate a Mermaid.js diagram for: "${concept}".
RULES:
- Use clear labels in German where appropriate
- Keep it EXTREMELY SIMPLE AND COMPACT: strictly max 3-4 nodes for a tiny visual footprint.
- Prefer vertical layouts (TD) over horizontal (LR) to prevent wide overflowing graphs.
- CRITICAL: Generate ONLY ONE single connected graph. Do NOT generate multiple independent graphs or isolated subgraphs in the same Mermaid block.
- No nested subgraphs. Keep instructions short.
- Use double-quoted labels for nodes: A["Label"]
- VERY CRITICAL: DO NOT use curly braces '{}' or brackets '[]' inside labels unless they are wrapped in double quotes (e.g. A["Text {with braces}"]).
- VERY CRITICAL: DO NOT use any HTML tags (no <br>, no <br/>, no <b>, etc).
- VERY CRITICAL: DO NOT use special characters like '&', '<', '>', or double-quotes inside the label strings. They break the mermaid parser in our environment.
- Return ONLY the raw mermaid code. No markdown blocks, no backticks.
- The first line MUST be a valid mermaid diagram type: graph, flowchart, sequenceDiagram, pie, gantt, stateDiagram, timeline`,
},
],
});
const code =
response.choices[0].message.content
?.replace(/```mermaid/g, "")
.replace(/```/g, "")
.trim() || "";
// Validate: must start with a valid mermaid keyword
const validStarts = [
"graph",
"flowchart",
"sequenceDiagram",
"pie",
"gantt",
"stateDiagram",
"timeline",
"classDiagram",
"erDiagram",
];
const firstLine = code.split("\n")[0]?.trim().toLowerCase() || "";
const isValid = validStarts.some((keyword) =>
firstLine.startsWith(keyword),
);
if (!isValid || code.length < 10) {
console.warn(
`⚠️ Mermaid: Invalid diagram generated for "${concept}", skipping`,
);
return "";
}
return code;
}
private async identifyResearchTopics(
content: string,
context: string,
): Promise<string[]> {
try {
console.log("Sending request to OpenRouter...");
const response = await this.openai.chat.completions.create({
model: MODELS.STRUCTURED,
messages: [
{
role: "system",
content: `Analyze the following blog post and identify 3 key topics or claims that would benefit from statistical data or external verification.
Return relevant, specific research queries (not too broad).
Context: ${context.slice(0, 1500)}
Return JSON: { "topics": ["topic 1", "topic 2", "topic 3"] }
Return ONLY the JSON.`,
},
{
role: "user",
content: content.slice(0, 4000),
},
],
response_format: { type: "json_object" },
});
console.log("Got response from OpenRouter");
const parsed = safeParseJSON(
response.choices[0].message.content || '{"topics": []}',
{ topics: [] },
);
return (parsed.topics || []).map((t: any) =>
typeof t === "string" ? t : JSON.stringify(t),
);
} catch (e: any) {
console.error("Error in identifyResearchTopics:", e);
throw e;
}
}
}

View File

@@ -0,0 +1,2 @@
export * from "./generator";
export * from "./orchestrator";

View File

@@ -0,0 +1,687 @@
import OpenAI from "openai";
import { ResearchAgent, type Fact, type SocialPost } from "@mintel/journaling";
import { ThumbnailGenerator } from "@mintel/thumbnail-generator";
import { ComponentDefinition } from "./generator";
import * as fs from "node:fs/promises";
import * as path from "node:path";
export interface OrchestratorConfig {
apiKey: string;
replicateApiKey?: string;
model?: string;
}
export interface OptimizationTask {
content: string;
projectContext: string;
availableComponents?: ComponentDefinition[];
instructions?: string;
internalLinks?: { title: string; slug: string }[];
customSources?: string[];
}
export interface OptimizeFileOptions {
contextDir: string;
availableComponents?: ComponentDefinition[];
shouldRename?: boolean;
}
export class AiBlogPostOrchestrator {
private openai: OpenAI;
private researchAgent: ResearchAgent;
private thumbnailGenerator?: ThumbnailGenerator;
private model: string;
constructor(config: OrchestratorConfig) {
this.model = config.model || "google/gemini-3-flash-preview";
this.openai = new OpenAI({
apiKey: config.apiKey,
baseURL: "https://openrouter.ai/api/v1",
defaultHeaders: {
"HTTP-Referer": "https://mintel.me",
"X-Title": "Mintel AI Blog Post Orchestrator",
},
});
this.researchAgent = new ResearchAgent(config.apiKey);
if (config.replicateApiKey) {
this.thumbnailGenerator = new ThumbnailGenerator({
replicateApiKey: config.replicateApiKey,
});
}
}
/**
* Reusable context loader. Loads all .md and .txt files from a directory into a single string.
*/
async loadContext(dirPath: string): Promise<string> {
try {
const resolvedDir = path.resolve(process.cwd(), dirPath);
const files = await fs.readdir(resolvedDir);
const textFiles = files.filter((f) => /\.(md|txt)$/i.test(f)).sort();
const contents: string[] = [];
for (const file of textFiles) {
const filePath = path.join(resolvedDir, file);
const text = await fs.readFile(filePath, "utf8");
contents.push(`=== ${file} ===\n${text.trim()}`);
}
return contents.join("\n\n");
} catch (e) {
console.warn(`⚠️ Could not load context from ${dirPath}: ${e}`);
return "";
}
}
/**
* Reads a file, extracts frontmatter, loads context, optimizes body, and writes it back.
*/
async optimizeFile(
targetFile: string,
options: OptimizeFileOptions,
): Promise<void> {
const absPath = path.isAbsolute(targetFile)
? targetFile
: path.resolve(process.cwd(), targetFile);
console.log(`📄 Processing File: ${path.basename(absPath)}`);
const content = await fs.readFile(absPath, "utf8");
// Idea 4: We no longer split frontmatter and body. We pass the whole file
// to the LLM so it can optimize the SEO title and description.
// Idea 1: Build Internal Link Graph
const blogDir = path.dirname(absPath);
const internalLinks = await this.buildInternalLinkGraph(
blogDir,
path.basename(absPath),
);
console.log(`📖 Loading context from: ${options.contextDir}`);
const projectContext = await this.loadContext(options.contextDir);
if (!projectContext) {
console.warn(
"⚠️ No project context loaded. AI might miss specific guidelines.",
);
}
const optimizedContent = await this.optimizeDocument({
content: content,
projectContext,
availableComponents: options.availableComponents,
internalLinks: internalLinks, // pass to orchestrator
});
// Idea 4b: Extract the potentially updated title to rename the file (SEO Slug)
const newFmMatch = optimizedContent.match(/^---\s*\n([\s\S]*?)\n---/);
let finalPath = absPath;
let finalSlug = path.basename(absPath, ".mdx");
if (options.shouldRename && newFmMatch && newFmMatch[1]) {
const titleMatch = newFmMatch[1].match(/title:\s*["']([^"']+)["']/);
if (titleMatch && titleMatch[1]) {
const newTitle = titleMatch[1];
// Generate SEO Slug
finalSlug = newTitle
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const newAbsPath = path.join(path.dirname(absPath), `${finalSlug}.mdx`);
if (newAbsPath !== absPath) {
console.log(
`🔄 SEO Title changed! Renaming file to: ${finalSlug}.mdx`,
);
// Delete old file if the title changed significantly
try {
await fs.unlink(absPath);
} catch (_err) {
// ignore
}
finalPath = newAbsPath;
}
}
} else if (newFmMatch && newFmMatch[1]) {
console.log(
` Rename skipped (permalink stability active). If you want to rename, use --rename.`,
);
}
// Idea 5: Automatic Thumbnails
let finalContent = optimizedContent;
// Skip if thumbnail already exists in frontmatter
const hasExistingThumbnail = /thumbnail:\s*["'][^"']+["']/.test(
finalContent,
);
if (this.thumbnailGenerator && !hasExistingThumbnail) {
console.log("🎨 Phase 5: Generating/Linking visual thumbnail...");
try {
const webPublicDir = path.resolve(process.cwd(), "apps/web/public");
const thumbnailRelPath = `/blog/${finalSlug}.png`;
const thumbnailAbsPath = path.join(
webPublicDir,
"blog",
`${finalSlug}.png`,
);
// Check if the physical file already exists
let physicalFileExists = false;
try {
await fs.access(thumbnailAbsPath);
physicalFileExists = true;
} catch (_err) {
// File does not exist
}
if (physicalFileExists) {
console.log(
`⏭️ Thumbnail already exists on disk, skipping generation: ${thumbnailAbsPath}`,
);
} else {
const visualPrompt = await this.generateVisualPrompt(finalContent);
await this.thumbnailGenerator.generateImage(
visualPrompt,
thumbnailAbsPath,
);
}
// Update frontmatter with thumbnail
if (finalContent.includes("thumbnail:")) {
finalContent = finalContent.replace(
/thumbnail:\s*["'].*?["']/,
`thumbnail: "${thumbnailRelPath}"`,
);
} else {
finalContent = finalContent.replace(
/(title:\s*["'].*?["'])/,
`$1\nthumbnail: "${thumbnailRelPath}"`,
);
}
} catch (e) {
console.warn("⚠️ Thumbnail processing failed, skipping:", e);
}
}
await fs.writeFile(finalPath, finalContent);
console.log(`✅ Saved optimized file to: ${finalPath}`);
}
async generateSlug(content: string, title?: string, instructions?: string): Promise<string> {
const response = await this.openai.chat.completions.create({
model: "google/gemini-2.5-flash",
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": ""}');
let 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: [
{
role: "system",
content: `You are a Visual Discovery Agent for an architectural design system.
Review the provided blog post and create a 1-sentence abstract visual description for an image generator (like Flux).
THEME: Technical blueprint / structural illustration.
STYLE: Clean lines, geometric shapes, monochrome base with one highlighter accent color (green, pink, or yellow).
NO TEXT. NO PEOPLE. NO REALISTIC PHOTOS.
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)}${instructions ? `\n\nEDITOR INSTRUCTIONS:\nPlease strictly follow these instructions from the editor when generating the visual prompt:\n${instructions}` : ""}` },
],
max_tokens: 100,
});
return (
response.choices[0].message.content ||
"Technical architectural blueprint of a digital system"
);
}
private async buildInternalLinkGraph(
blogDir: string,
currentFile: string,
): Promise<{ title: string; slug: string }[]> {
try {
const files = await fs.readdir(blogDir);
const mdxFiles = files.filter(
(f) => f.endsWith(".mdx") && f !== currentFile,
);
const graph: { title: string; slug: string }[] = [];
for (const file of mdxFiles) {
const fileContent = await fs.readFile(path.join(blogDir, file), "utf8");
const titleMatch = fileContent.match(/title:\s*["']([^"']+)["']/);
if (titleMatch && titleMatch[1]) {
graph.push({
title: titleMatch[1],
slug: `/blog/${file.replace(".mdx", "")}`,
});
}
}
return graph;
} catch (e) {
console.warn("Could not build internal link graph", e);
return [];
}
}
/**
* Executes the 3-step optimization pipeline:
* 1. Fakten recherchieren
* 2. Bestehende Social Posts extrahieren (kein LLM — nur Regex)
* 3. AI anweisen daraus Artikel zu erstellen
*/
async optimizeDocument(task: OptimizationTask): Promise<string> {
console.log(`🚀 Starting AI Orchestration Pipeline (${this.model})...`);
// 1. Fakten & Konkurrenz recherchieren
console.log("1⃣ Recherchiere Fakten und analysiere Konkurrenz...");
const researchTopics = await this.identifyTopics(task.content);
const facts: Fact[] = [];
const competitorInsights: string[] = [];
// Paralellize competitor research and fact research
await Promise.all(
researchTopics.map(async (topic) => {
const [topicFacts, insights] = await Promise.all([
this.researchAgent.researchTopic(topic),
this.researchAgent.researchCompetitors(topic),
]);
facts.push(...topicFacts);
competitorInsights.push(...insights);
}),
);
// 2. Bestehende Social Posts aus dem Content extrahieren (deterministisch, kein LLM)
console.log("2⃣ Extrahiere bestehende Social Media Embeds aus Content...");
const socialPosts = this.researchAgent.extractSocialPosts(task.content);
// Wenn keine vorhanden sind, besorge echte von der Serper API
if (socialPosts.length === 0) {
console.log(
" → Keine bestehenden Posts gefunden. Suche neue über Serper API...",
);
const realPosts = await this.researchAgent.fetchRealSocialPosts(
task.content.slice(0, 500),
task.customSources
);
socialPosts.push(...realPosts);
}
// 3. AI anweisen daraus Artikel zu erstellen
console.log("3⃣ Erstelle optimierten Artikel (Agentic Rewrite)...");
return await this.compileArticle(
task,
facts,
competitorInsights,
socialPosts,
task.internalLinks || [],
);
}
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
messages: [
{
role: "system",
content: `Analyze the following blog post and identify 1 to 2 key topics or claims that would benefit from statistical data or external verification.
Return JSON: { "topics": ["topic 1", "topic 2"] }
Return ONLY the JSON.`,
},
{
role: "user",
content: content.slice(0, 4000),
},
],
response_format: { type: "json_object" },
});
try {
const raw = response.choices[0].message.content || '{"topics": []}';
const cleaned = raw
.trim()
.replace(/^```(?:json)?\s*\n?/, "")
.replace(/\n?```\s*$/, "");
const parsed = JSON.parse(cleaned);
return parsed.topics || [];
} catch (e) {
console.warn("⚠️ Failed to parse research topics", e);
return [];
}
}
private async compileArticle(
task: OptimizationTask,
facts: Fact[],
competitorInsights: string[],
socialPosts: SocialPost[],
internalLinks: { title: string; slug: string }[],
retryCount = 0,
): Promise<string> {
const factsText = facts
.map((f, i) => `${i + 1}. ${f.statement} [Source: ${f.source}]`)
.join("\n");
let socialText = `CRITICAL RULE: NO VERIFIED SOCIAL MEDIA POSTS FOUND. You MUST NOT use <YouTubeEmbed />, <TwitterEmbed />, or <LinkedInEmbed /> under ANY circumstances in this article. DO NOT hallucinate IDs.`;
if (socialPosts.length > 0) {
const allowedTags: string[] = [];
if (socialPosts.some((p) => p.platform === "youtube"))
allowedTags.push('<YouTubeEmbed videoId="..." />');
if (socialPosts.some((p) => p.platform === "twitter"))
allowedTags.push('<TwitterEmbed tweetId="..." />');
if (socialPosts.some((p) => p.platform === "linkedin"))
allowedTags.push('<LinkedInEmbed url="..." />');
socialText = `Social Media Posts to embed (use ONLY these tags, do not use others: ${allowedTags.join(", ")}):\n${socialPosts.map((p) => `Platform: ${p.platform}, ID: ${p.embedId} (${p.description})`).join("\n")}\nCRITICAL: Do not invent any IDs that are not explicitly listed in the list above.`;
}
const componentsText = (task.availableComponents || [])
.filter((c) => {
if (
c.name === "YouTubeEmbed" &&
!socialPosts.some((p) => p.platform === "youtube")
)
return false;
if (
c.name === "TwitterEmbed" &&
!socialPosts.some((p) => p.platform === "twitter")
)
return false;
if (
c.name === "LinkedInEmbed" &&
!socialPosts.some((p) => p.platform === "linkedin")
)
return false;
return true;
})
.map((c) => {
// Ensure LinkedInEmbed usage example consistently uses 'url'
if (c.name === "LinkedInEmbed") {
return `<${c.name}>: ${c.description}\n Example: <LinkedInEmbed url="https://www.linkedin.com/posts/..." />`;
}
return `<${c.name}>: ${c.description}\n Example: ${c.usageExample}`;
})
.join("\n\n");
const memeTemplates = [
"db", // Distracted Boyfriend
"gb", // Galaxy Brain
"fine", // This is Fine
"ds", // Daily Struggle
"gru", // Gru's Plan
"cmm", // Change My Mind
"astronaut", // Always Has Been (ahb)
"disastergirl",
"pigeon", // Is this a pigeon?
"rollsafe",
"slap", // Will Smith
"exit", // Left Exit 12
"mordor",
"panik-kalm-panik",
"woman-cat", // Woman yelling at cat
"grumpycat",
"sadfrog",
"stonks",
"same", // They're the same picture
"spongebob",
];
const forcedMeme =
memeTemplates[Math.floor(Math.random() * memeTemplates.length)];
const response = await this.openai.chat.completions.create({
model: this.model,
messages: [
{
role: "system",
content: `You are an expert MDX Editor and Digital Architect.
YOUR TASK:
Take the given draft blog post and rewrite/enhance it into a final, error-free MDX file. Maintain the author's original German text, meaning, and tone, but enrich it gracefully.
CONTEXT & RULES:
Project Context / Tone:
${task.projectContext}
FACTS TO INTEGRATE:
${factsText || "No new facts needed."}
COMPETITOR BENCHMARK (TOP RANKING ARTICLES):
Here are snippets from the top 5 ranking Google articles for this topic. Read them carefully and ensure our article covers these topics but is fundamentally BETTER, deeper, and more authoritative:
${competitorInsights.length > 0 ? competitorInsights.join("\n") : "No competitor insights found."}
AVAILABLE UI COMPONENTS:
${componentsText}
SOCIAL MEDIA POSTS:
${socialText}
INTERNAL LINKING GRAPH:
Hier sind unsere existierenden Blog-Posts (Titel und URL-Slug). Finde 2-3 passende Stellen im Text, um organisch mit regulärem Markdown (\`[passender Text]([slug])\`) auf diese Posts zu verlinken. Nutze KEIN <ExternalLink> für B2B-interne Links.
${internalLinks.length > 0 ? internalLinks.map((l) => `- "${l.title}" -> ${l.slug}`).join("\n") : "Keine internen Links verfügbar."}
Special Instructions from User:
${task.instructions || "None"}
BLOG POST BEST PRACTICES (MANDATORY):
- DEVIL'S ADVOCATE: Füge zwingend eine kurze kritische Sektion ein (z.B. mit \`<ComparisonRow>\` oder \`<IconList>\`), in der du offen die Nachteile/Kosten/Haken deiner eigenen Lösung ansprichst ("Der Haken an der Sache..."). Das baut Vertrauen bei B2B Entscheidenden auf.
- FAQ GENERATOR: Am absoluten Ende des Artikels erstellst du zwingend eine Markdown-Liste mit den 3 wichtigsten Fragen (FAQ) und Antworten (jeweils 2 Sätze) für Google Rich Snippets. Nutze dazu das FAQSection Component oder normales Markdown.
- SUBTLE CTAs: Webe 1-2 subtile CTAs für High-End Website Entwicklung ein. Nutze ZWINGEND die Komponente [LeadMagnet] für diese Zwecke anstelle von einfachen Buttons. [LeadMagnet] bietet mehr Kontext und Vertrauen. Beispiel: <LeadMagnet title="Performance-Check anfragen" description="Wir analysieren Ihre Core Web Vitals und decken Umsatzpotenziale auf." buttonText="Jetzt analysieren lassen" href="/contact" variant="performance" />. Die Texte im LeadMagnet müssen absolut überzeugend, hochprofessionell und B2B-fokussiert sein (KEIN Robotik-Marketing-Sprech).
- 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.
- 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.
- ORIGINAL LANGUAGE QUOTES: Übersetze NIEMALS Zitate (z.B. in ArticleQuote). Behalte das Original (z.B. Englisch), wenn du Studien von Deloitte, McKinsey oder Aussagen von CEOs zitierst. Das erhöht die Authentizität im B2B-Mittelstand.
- CONTENT PRUNING: Wenn das dir übergebene MDX bereits interaktive Komponenten (z.B. \`<YouTubeEmbed>\`) enthält, die **nicht** oder **nicht mehr** zum inhaltlichen Fokus passen (z.B. irrelevante Videos oder platzhalter-ähnliche Snippets), MUSST du diese radikal **entfernen**. Behalte keine halluzinierten oder unpassenden Medien, nur weil sie schon da waren.
STRICT MDX OUTPUT RULES:
1. ONLY use the exact components defined above.
2. For Social Media Embeds, you MUST ONLY use the EXACT IDs provided in the list above. Do NOT invent IDs.
3. If ANY verified social media posts are provided, you MUST integrate at least one naturally with a contextual sentence.
4. Keep the original content blocks and headings as much as possible, just improve flow.
5. FRONTMATTER SEO (Idea 4): Ich übergebe dir die KOMPLETTE Datei inklusive Markdown-Frontmatter (--- ... ---). Du MUSST das Frontmatter ebenfalls zurückgeben! Optimiere darin den \`title\` und die \`description\` maximal für B2B SEO. Lasse die anderen Keys im Frontmatter (date, tags) unangetastet.
CRITICAL GUIDELINES (NEVER BREAK THESE):
1. THE OUTPUT MUST START WITH YAML FRONTMATTER AND END WITH THE MDX BODY.
2. DO NOT INCLUDE MARKDOWN WRAPPERS (do not wrap in \`\`\`mdx ... \`\`\`).
5. Be clean. Do NOT clump all components together. Provide 3-4 paragraphs of normal text between visual items.
6. If you insert components, ensure their syntax is 100% valid JSX/MDX.
7. CRITICAL MERMAID RULE: If you use <Mermaid>, the inner content MUST be 100% valid Mermaid.js syntax. NO HTML inside labels. NO quotes inside brackets without valid syntax.
8. Do NOT hallucinate links or facts. Use only what is provided.`,
},
{
role: "user",
content: task.content,
},
],
});
let rawContent = response.choices[0].message.content || task.content;
rawContent = this.cleanResponse(rawContent, socialPosts);
// --- Autonomous Validation Layer ---
let hasError = false;
let errorFeedback = "";
// 1. Validate Meme Templates
const memeRegex = /<ArticleMeme[^>]+template=["']([^"']+)["'][^>]*>/g;
let memeMatch;
const invalidMemes: string[] = [];
while ((memeMatch = memeRegex.exec(rawContent)) !== null) {
if (!memeTemplates.includes(memeMatch[1])) {
invalidMemes.push(memeMatch[1]);
}
}
if (invalidMemes.length > 0) {
hasError = true;
errorFeedback += `\n- You hallucinated invalid meme templates: ${invalidMemes.join(", ")}. You MUST ONLY use templates from this exact list: ${memeTemplates.join(", ")}. DO NOT INVENT TEMPLATES.\n`;
}
// 2. Validate Mermaid Syntax
if (rawContent.includes("<Mermaid>")) {
console.log("🔍 Validating Mermaid syntax in AI response...");
const mermaidBlocks = this.extractMermaidBlocks(rawContent);
for (const block of mermaidBlocks) {
const validationResult = await this.validateMermaidSyntax(block);
if (!validationResult.valid) {
hasError = true;
errorFeedback += `\n- Invalid Mermaid block:\n${block}\nError context: ${validationResult.error}\n`;
}
}
}
if (hasError && retryCount < 3) {
console.log(
`❌ Validation errors detected. Retrying compilation (Attempt ${retryCount + 1}/3)...`,
);
return this.compileArticle(
{
...task,
content: `CRITICAL ERROR IN PREVIOUS ATTEMPT:\nYour generated MDX contained the following errors that MUST be fixed:\n${errorFeedback}\n\nPlease rewrite the MDX and FIX these errors. Pay strict attention to the rules.\n\nOriginal Draft:\n${task.content}`,
},
facts,
competitorInsights,
socialPosts,
internalLinks,
retryCount + 1,
);
}
return rawContent;
}
private extractMermaidBlocks(content: string): string[] {
const blocks: string[] = [];
// Regex to match <Mermaid>...</Mermaid> blocks across multiple lines
const regex = /<Mermaid>([\s\S]*?)<\/Mermaid>/g;
let match;
while ((match = regex.exec(content)) !== null) {
if (match[1]) {
blocks.push(match[1].trim());
}
}
return blocks;
}
private async validateMermaidSyntax(
graph: string,
): Promise<{ valid: boolean; error?: string }> {
// Fast LLM validation to catch common syntax errors like unbalanced quotes or HTML entities
try {
const validationResponse = await this.openai.chat.completions.create({
model: "google/gemini-3-flash-preview", // Switch from gpt-4o-mini to user requested model
messages: [
{
role: "system",
content:
'You are a strict Mermaid.js compiler. Analyze the given Mermaid syntax. If it is 100% valid and will render without exceptions, reply ONLY with "VALID". If it has syntax errors (e.g., HTML inside labels, unescaped quotes, unclosed brackets), reply ONLY with "INVALID" followed by a short explanation of the exact error.',
},
{
role: "user",
content: graph,
},
],
});
const reply =
validationResponse.choices[0].message.content?.trim() || "VALID";
if (reply.startsWith("INVALID")) {
return { valid: false, error: reply };
}
return { valid: true };
} catch (e) {
console.error("Syntax validation LLM call failed, passing through:", e);
return { valid: true }; // Fallback to passing if validator fails
}
}
private cleanResponse(content: string, socialPosts: SocialPost[]): string {
let cleaned = content.trim();
// 1. Strip Markdown Wrappers (e.g. ```mdx ... ```)
if (cleaned.startsWith("```")) {
cleaned = cleaned
.replace(/^```[a-zA-Z]*\n?/, "")
.replace(/\n?```\s*$/, "");
}
// 2. We NO LONGER strip redundant frontmatter, because we requested the LLM to output it.
// Ensure the output actually has frontmatter, if not, something went wrong, but we just pass it along.
// 3. Strip any social embeds the AI hallucinated (IDs not in our extracted set)
const knownYtIds = new Set(
socialPosts.filter((p) => p.platform === "youtube").map((p) => p.embedId),
);
const knownTwIds = new Set(
socialPosts.filter((p) => p.platform === "twitter").map((p) => p.embedId),
);
const knownLiIds = new Set(
socialPosts
.filter((p) => p.platform === "linkedin")
.map((p) => p.embedId),
);
cleaned = cleaned.replace(
/<YouTubeEmbed[^>]*videoId="([^"]+)"[^>]*\/>/gi,
(tag, id) => {
if (knownYtIds.has(id)) return tag;
console.log(
`🛑 Stripped hallucinated YouTubeEmbed with videoId="${id}"`,
);
return "";
},
);
cleaned = cleaned.replace(
/<TwitterEmbed[^>]*tweetId="([^"]+)"[^>]*\/>/gi,
(tag, id) => {
if (knownTwIds.has(id)) return tag;
console.log(
`🛑 Stripped hallucinated TwitterEmbed with tweetId="${id}"`,
);
return "";
},
);
cleaned = cleaned.replace(
/<LinkedInEmbed[^>]*(?:url|urn)="([^"]+)"[^>]*\/>/gi,
(tag, id) => {
if (knownLiIds.has(id)) return tag;
console.log(`🛑 Stripped hallucinated LinkedInEmbed with id="${id}"`);
return "";
},
);
return cleaned;
}
}

View File

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

View File

@@ -1,851 +0,0 @@
import { useApi as e, defineModule as a } from "@directus/extensions-sdk";
import {
defineComponent as t,
ref as l,
onMounted as n,
resolveComponent as i,
resolveDirective as s,
openBlock as d,
createBlock as r,
withCtx as u,
createVNode as o,
createElementBlock as m,
Fragment as c,
renderList as v,
createTextVNode as p,
toDisplayString as f,
createCommentVNode as g,
createElementVNode as y,
withDirectives as b,
nextTick as _,
} from "vue";
const h = { class: "content-wrapper" },
x = { key: 0, class: "empty-state" },
w = { class: "header" },
k = { class: "header-left" },
V = { class: "title" },
C = { class: "subtitle" },
M = { class: "header-right" },
F = { class: "user-cell" },
N = { class: "user-name" },
z = { key: 0, class: "status-date" },
E = { key: 0, class: "drawer-content" },
U = { class: "form-section" },
S = { class: "field" },
A = { class: "drawer-actions" },
T = { key: 0, class: "drawer-content" },
Z = { class: "form-section" },
j = { class: "field" },
$ = { class: "field" },
D = { class: "field" },
O = { key: 1, class: "field" },
W = { class: "drawer-actions" };
var q = t({
__name: "module",
setup(a) {
const t = e(),
q = l([]),
B = l(null),
K = l([]),
L = l(!1),
P = l(!1),
G = l(null),
I = l(null),
H = l(!1),
J = l(!1),
Q = l({ id: "", name: "" }),
R = l(!1),
X = l(!1),
Y = l({
id: "",
first_name: "",
last_name: "",
email: "",
temporary_password: "",
}),
ee = [
{ text: "Name", value: "name", sortable: !0 },
{ text: "E-Mail", value: "email", sortable: !0 },
{ text: "Zuletzt eingeladen", value: "last_invited", sortable: !0 },
];
async function ae() {
const e = await t.get("/items/companies", {
params: { fields: ["id", "name"], sort: "name" },
});
q.value = e.data.data;
}
async function te(e) {
((B.value = e), (L.value = !0));
try {
const a = await t.get("/items/client_users", {
params: {
filter: { company: { _eq: e.id } },
fields: ["*"],
sort: "first_name",
},
});
K.value = a.data.data;
} finally {
L.value = !1;
}
}
function le() {
((J.value = !1), (Q.value = { id: "", name: "" }), (H.value = !0));
}
async function ne() {
B.value &&
((Q.value = { id: B.value.id, name: B.value.name }),
(J.value = !0),
await _(),
(H.value = !0));
}
async function ie() {
var e;
if (Q.value.name) {
P.value = !0;
try {
(J.value
? (await t.patch(`/items/companies/${Q.value.id}`, {
name: Q.value.name,
}),
(I.value = { type: "success", message: "Firma aktualisiert!" }))
: (await t.post("/items/companies", { name: Q.value.name }),
(I.value = { type: "success", message: "Firma angelegt!" })),
(H.value = !1),
await ae(),
(null == (e = B.value) ? void 0 : e.id) === Q.value.id &&
(B.value.name = Q.value.name));
} catch (e) {
I.value = { type: "danger", message: e.message };
} finally {
P.value = !1;
}
}
}
function se() {
((X.value = !1),
(Y.value = {
id: "",
first_name: "",
last_name: "",
email: "",
temporary_password: "",
}),
(R.value = !0));
}
async function de() {
if (Y.value.email && B.value) {
P.value = !0;
try {
(X.value
? (await t.patch(`/items/client_users/${Y.value.id}`, {
first_name: Y.value.first_name,
last_name: Y.value.last_name,
email: Y.value.email,
}),
(I.value = {
type: "success",
message: "Mitarbeiter aktualisiert!",
}))
: (await t.post("/items/client_users", {
first_name: Y.value.first_name,
last_name: Y.value.last_name,
email: Y.value.email,
company: B.value.id,
}),
(I.value = {
type: "success",
message: "Mitarbeiter angelegt!",
})),
(R.value = !1),
await te(B.value));
} catch (e) {
I.value = { type: "danger", message: e.message };
} finally {
P.value = !1;
}
}
}
function re(e) {
const a = (null == e ? void 0 : e.item) || e;
a &&
a.id &&
(async function (e) {
((Y.value = {
id: e.id || "",
first_name: e.first_name || "",
last_name: e.last_name || "",
email: e.email || "",
temporary_password: e.temporary_password || "",
}),
(X.value = !0),
await _(),
(R.value = !0));
})(a);
}
return (
n(() => {
ae();
}),
(e, a) => {
const l = i("v-icon"),
n = i("v-list-item-icon"),
_ = i("v-text-overflow"),
ae = i("v-list-item-content"),
ue = i("v-list-item"),
oe = i("v-divider"),
me = i("v-list"),
ce = i("v-notice"),
ve = i("v-button"),
pe = i("v-info"),
fe = i("v-avatar"),
ge = i("v-chip"),
ye = i("v-table"),
be = i("v-input"),
_e = i("v-drawer"),
he = i("private-view"),
xe = s("tooltip");
return (
d(),
r(
he,
{ title: "Customer Manager" },
{
navigation: u(() => [
o(
me,
{ nav: "" },
{
default: u(() => [
o(
ue,
{ onClick: le, clickable: "" },
{
default: u(() => [
o(n, null, {
default: u(() => [
o(l, {
name: "add",
color: "var(--theme--primary)",
}),
]),
_: 1,
}),
o(ae, null, {
default: u(() => [
o(_, { text: "Neue Firma anlegen" }),
]),
_: 1,
}),
]),
_: 1,
},
),
o(oe),
(d(!0),
m(
c,
null,
v(q.value, (e) => {
var a;
return (
d(),
r(
ue,
{
key: e.id,
active:
(null == (a = B.value) ? void 0 : a.id) ===
e.id,
class: "company-item",
clickable: "",
onClick: (a) => te(e),
},
{
default: u(() => [
o(n, null, {
default: u(() => [
o(l, { name: "business" }),
]),
_: 1,
}),
o(
ae,
null,
{
default: u(() => [
o(_, { text: e.name }, null, 8, [
"text",
]),
]),
_: 2,
},
1024,
),
]),
_: 2,
},
1032,
["active", "onClick"],
)
);
}),
128,
)),
]),
_: 1,
},
),
]),
"title-outer:after": u(() => [
I.value
? (d(),
r(
ce,
{
key: 0,
type: I.value.type,
onClose: a[0] || (a[0] = (e) => (I.value = null)),
dismissible: "",
},
{ default: u(() => [p(f(I.value.message), 1)]), _: 1 },
8,
["type"],
))
: g("v-if", !0),
]),
default: u(() => [
y("div", h, [
B.value
? (d(),
m(
c,
{ key: 1 },
[
y("header", w, [
y("div", k, [
y("h1", V, f(B.value.name), 1),
y(
"p",
C,
f(K.value.length) + " Kunden-Mitarbeiter",
1,
),
]),
y("div", M, [
b(
(d(),
r(
ve,
{
secondary: "",
rounded: "",
icon: "",
onClick: ne,
},
{
default: u(() => [
o(l, { name: "edit" }),
]),
_: 1,
},
)),
[
[
xe,
"Firma bearbeiten",
void 0,
{ bottom: !0 },
],
],
),
o(
ve,
{ primary: "", onClick: se },
{
default: u(() => [
...(a[14] ||
(a[14] = [
p(" Mitarbeiter hinzufügen ", -1),
])),
]),
_: 1,
},
),
]),
]),
o(
ye,
{
headers: ee,
items: K.value,
loading: L.value,
class: "clickable-table",
"fixed-header": "",
"onClick:row": re,
},
{
"item.name": u(({ item: e }) => [
y("div", F, [
o(
fe,
{ name: e.first_name, "x-small": "" },
null,
8,
["name"],
),
y(
"span",
N,
f(e.first_name) + " " + f(e.last_name),
1,
),
]),
]),
"item.last_invited": u(({ item: e }) => {
return [
e.last_invited
? (d(),
m(
"span",
z,
f(
((t = e.last_invited),
new Date(t).toLocaleString(
"de-DE",
{
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
},
)),
),
1,
))
: (d(),
r(
ge,
{ key: 1, "x-small": "" },
{
default: u(() => [
...(a[15] ||
(a[15] = [p("Noch nie", -1)])),
]),
_: 1,
},
)),
];
var t;
}),
_: 2,
},
1032,
["items", "loading"],
),
],
64,
))
: (d(),
m("div", x, [
o(
pe,
{
title: "Firmen auswählen",
icon: "business",
center: "",
},
{
default: u(() => [
a[12] ||
(a[12] = p(
" Wähle eine Firma in der Navigation aus oder ",
-1,
)),
o(
ve,
{ "x-small": "", onClick: le },
{
default: u(() => [
...(a[11] ||
(a[11] = [
p("erstelle eine neue Firma", -1),
])),
]),
_: 1,
},
),
a[13] || (a[13] = p(". ", -1)),
]),
_: 1,
},
),
])),
]),
o(
_e,
{
modelValue: H.value,
"onUpdate:modelValue":
a[2] || (a[2] = (e) => (H.value = e)),
title: J.value
? "Firma bearbeiten"
: "Neue Firma anlegen",
icon: "business",
onCancel: a[3] || (a[3] = (e) => (H.value = !1)),
},
{
default: u(() => [
H.value
? (d(),
m("div", E, [
y("div", U, [
y("div", S, [
a[16] ||
(a[16] = y(
"span",
{ class: "label" },
"Firmenname",
-1,
)),
o(
be,
{
modelValue: Q.value.name,
"onUpdate:modelValue":
a[1] ||
(a[1] = (e) => (Q.value.name = e)),
placeholder: "z.B. KLZ Cables",
autofocus: "",
},
null,
8,
["modelValue"],
),
]),
]),
y("div", A, [
o(
ve,
{
primary: "",
block: "",
loading: P.value,
onClick: ie,
},
{
default: u(() => [
...(a[17] ||
(a[17] = [p("Speichern", -1)])),
]),
_: 1,
},
8,
["loading"],
),
]),
]))
: g("v-if", !0),
]),
_: 1,
},
8,
["modelValue", "title"],
),
o(
_e,
{
modelValue: R.value,
"onUpdate:modelValue":
a[9] || (a[9] = (e) => (R.value = e)),
title: X.value
? "Mitarbeiter bearbeiten"
: "Neuen Mitarbeiter anlegen",
icon: "person",
onCancel: a[10] || (a[10] = (e) => (R.value = !1)),
},
{
default: u(() => [
R.value
? (d(),
m("div", T, [
y("div", Z, [
y("div", j, [
a[18] ||
(a[18] = y(
"span",
{ class: "label" },
"Vorname",
-1,
)),
o(
be,
{
modelValue: Y.value.first_name,
"onUpdate:modelValue":
a[4] ||
(a[4] = (e) =>
(Y.value.first_name = e)),
placeholder: "Vorname",
autofocus: "",
},
null,
8,
["modelValue"],
),
]),
y("div", $, [
a[19] ||
(a[19] = y(
"span",
{ class: "label" },
"Nachname",
-1,
)),
o(
be,
{
modelValue: Y.value.last_name,
"onUpdate:modelValue":
a[5] ||
(a[5] = (e) => (Y.value.last_name = e)),
placeholder: "Nachname",
},
null,
8,
["modelValue"],
),
]),
y("div", D, [
a[20] ||
(a[20] = y(
"span",
{ class: "label" },
"E-Mail",
-1,
)),
o(
be,
{
modelValue: Y.value.email,
"onUpdate:modelValue":
a[6] ||
(a[6] = (e) => (Y.value.email = e)),
placeholder: "E-Mail Adresse",
type: "email",
},
null,
8,
["modelValue"],
),
]),
X.value
? (d(), r(oe, { key: 0 }))
: g("v-if", !0),
X.value
? (d(),
m("div", O, [
a[21] ||
(a[21] = y(
"span",
{ class: "label" },
"Temporäres Passwort",
-1,
)),
o(
be,
{
modelValue:
Y.value.temporary_password,
"onUpdate:modelValue":
a[7] ||
(a[7] = (e) =>
(Y.value.temporary_password = e)),
readonly: "",
class: "password-input",
},
null,
8,
["modelValue"],
),
a[22] ||
(a[22] = y(
"p",
{ class: "field-note" },
"Wird beim Senden der Zugangsdaten automatisch generiert.",
-1,
)),
]))
: g("v-if", !0),
]),
y("div", W, [
o(
ve,
{
primary: "",
block: "",
loading: P.value,
onClick: de,
},
{
default: u(() => [
...(a[23] ||
(a[23] = [p("Daten speichern", -1)])),
]),
_: 1,
},
8,
["loading"],
),
X.value
? (d(),
m(
c,
{ key: 0 },
[
o(oe),
b(
(d(),
r(
ve,
{
secondary: "",
block: "",
loading: G.value === Y.value.id,
onClick:
a[8] ||
(a[8] = (e) =>
(async function (e) {
G.value = e.id;
try {
if (
(await t.post(
"/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501",
[e.id],
),
(I.value = {
type: "success",
message: `Zugangsdaten für ${e.first_name} versendet. 📧`,
}),
await te(B.value),
R.value &&
Y.value.id === e.id)
) {
const a = K.value.find(
(a) => a.id === e.id,
);
a &&
(Y.value.temporary_password =
a.temporary_password);
}
} catch (e) {
I.value = {
type: "danger",
message: `Fehler: ${e.message}`,
};
} finally {
G.value = null;
}
})(Y.value)),
},
{
default: u(() => [
o(l, {
name: "send",
left: "",
}),
a[24] ||
(a[24] = p(
" Zugangsdaten senden ",
-1,
)),
]),
_: 1,
},
8,
["loading"],
)),
[
[
xe,
"Generiert PW, speichert es und sendet E-Mail",
void 0,
{ bottom: !0 },
],
],
),
],
64,
))
: g("v-if", !0),
]),
]))
: g("v-if", !0),
]),
_: 1,
},
8,
["modelValue", "title"],
),
]),
_: 1,
},
)
);
}
);
},
}),
B = [],
K = [];
!(function (e, a) {
if (e && "undefined" != typeof document) {
var t,
l = !0 === a.prepend ? "prepend" : "append",
n = !0 === a.singleTag,
i =
"string" == typeof a.container
? document.querySelector(a.container)
: document.getElementsByTagName("head")[0];
if (n) {
var s = B.indexOf(i);
(-1 === s && ((s = B.push(i) - 1), (K[s] = {})),
(t = K[s] && K[s][l] ? K[s][l] : (K[s][l] = d())));
} else t = d();
(65279 === e.charCodeAt(0) && (e = e.substring(1)),
t.styleSheet
? (t.styleSheet.cssText += e)
: t.appendChild(document.createTextNode(e)));
}
function d() {
var e = document.createElement("style");
if ((e.setAttribute("type", "text/css"), a.attributes))
for (var t = Object.keys(a.attributes), n = 0; n < t.length; n++)
e.setAttribute(t[n], a.attributes[t[n]]);
var s = "prepend" === l ? "afterbegin" : "beforeend";
return (i.insertAdjacentElement(s, e), e);
}
})(
"\n.content-wrapper[data-v-3fd11e72] { padding: 32px; height: 100%; display: flex; flex-direction: column;\n}\n.company-item[data-v-3fd11e72] { cursor: pointer;\n}\n.header[data-v-3fd11e72] { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end;\n}\n.header-right[data-v-3fd11e72] { display: flex; gap: 12px;\n}\n.title[data-v-3fd11e72] { font-size: 24px; font-weight: 800; margin-bottom: 4px;\n}\n.subtitle[data-v-3fd11e72] { color: var(--theme--foreground-subdued); font-size: 14px;\n}\n.empty-state[data-v-3fd11e72] { height: 100%; display: flex; align-items: center; justify-content: center;\n}\n.user-cell[data-v-3fd11e72] { display: flex; align-items: center; gap: 12px;\n}\n.user-name[data-v-3fd11e72] { font-weight: 600;\n}\n.status-date[data-v-3fd11e72] { font-size: 12px; color: var(--theme--foreground-subdued);\n}\n.drawer-content[data-v-3fd11e72] { padding: 24px; display: flex; flex-direction: column; gap: 32px;\n}\n.form-section[data-v-3fd11e72] { display: flex; flex-direction: column; gap: 20px;\n}\n.field[data-v-3fd11e72] { display: flex; flex-direction: column; gap: 8px;\n}\n.label[data-v-3fd11e72] { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px;\n}\n.field-note[data-v-3fd11e72] { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px;\n}\n.drawer-actions[data-v-3fd11e72] { margin-top: 24px; display: flex; flex-direction: column; gap: 12px;\n}\n.password-input[data-v-3fd11e72] textarea {\n\tfont-family: var(--family-monospace);\n\tfont-weight: 800;\n\tcolor: var(--theme--primary) !important;\n\tbackground: var(--theme--background-subdued) !important;\n}\n.clickable-table[data-v-3fd11e72] tbody tr { cursor: pointer; transition: background-color 0.2s ease;\n}\n.clickable-table[data-v-3fd11e72] tbody tr:hover { background-color: var(--theme--background-subdued) !important;\n}\n[data-v-3fd11e72] .v-list-item { cursor: pointer !important;\n}\n",
{},
);
var L = a({
id: "customer-manager",
name: "Customer Manager",
icon: "supervisor_account",
routes: [
{
path: "",
component: ((e, a) => {
const t = e.__vccOpts || e;
for (const [e, l] of a) t[e] = l;
return t;
})(q, [
["__scopeId", "data-v-3fd11e72"],
["__file", "module.vue"],
]),
},
],
});
export { L as default };

View File

@@ -1,27 +1,28 @@
{
"name": "customer-manager",
"description": "Custom High-Fidelity Customer & Company Management for Directus",
"icon": "supervisor_account",
"version": "1.6.0",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.21",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "*",
"name": "Customer Manager"
"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,152 +1,196 @@
<template>
<private-view title="Customer Manager">
<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="openCreateCompany" clickable>
<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-text-overflow text="Neuen Kunden verlinken" />
</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="company-item"
v-for="item in items"
:key="item.id"
:active="selectedItem?.id === item.id"
class="nav-item"
clickable
@click="selectCompany(company)"
@click="selectItem(item)"
>
<v-list-item-icon><v-icon name="business" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="company.name" />
<v-text-overflow :text="item.company?.name" />
</v-list-item-content>
</v-list-item>
</v-list>
</template>
<template #title-outer:after>
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible>
{{ notice.message }}
</v-notice>
<template #subtitle>
<template v-if="selectedItem">
{{ clientUsers.length }} Portal-Nutzer &middot; {{ selectedItem.company?.domain }}
</template>
</template>
<div class="content-wrapper">
<div v-if="!selectedCompany" class="empty-state">
<v-info title="Firmen auswählen" icon="business" center>
Wähle eine Firma in der Navigation aus oder
<v-button x-small @click="openCreateCompany">erstelle eine neue Firma</v-button>.
</v-info>
<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 v-else>
<header class="header">
<div class="header-left">
<h1 class="title">{{ selectedCompany.name }}</h1>
<p class="subtitle">{{ employees.length }} Kunden-Mitarbeiter</p>
</div>
<div class="header-right">
<v-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openEditCompany">
<v-icon name="edit" />
</v-button>
<v-button primary @click="openCreateEmployee">
Mitarbeiter hinzufügen
</v-button>
</div>
</header>
<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>
<v-table
:headers="tableHeaders"
:items="employees"
: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>
</template>
</div>
<!-- Drawer: Company Form -->
<v-drawer
v-model="drawerCompanyActive"
:title="isEditingCompany ? 'Firma bearbeiten' : 'Neue Firma anlegen'"
icon="business"
@cancel="drawerCompanyActive = false"
<!-- Main Content: Client Users Table -->
<v-table
:headers="tableHeaders"
:items="clientUsers"
:loading="loading"
class="clickable-table"
fixed-header
@click:row="onRowClick"
>
<div v-if="drawerCompanyActive" class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Firmenname</span>
<v-input v-model="companyForm.name" placeholder="z.B. KLZ Cables" autofocus />
</div>
</div>
<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="saveCompany">Speichern</v-button>
<v-button primary block :loading="saving" @click="saveItem">Speichern</v-button>
</div>
</div>
</v-drawer>
<!-- Drawer: Employee Form -->
<!-- Drawer: Client User Form -->
<v-drawer
v-model="drawerEmployeeActive"
:title="isEditingEmployee ? 'Mitarbeiter bearbeiten' : 'Neuen Mitarbeiter anlegen'"
v-model="drawerUserActive"
:title="isEditingUser ? 'Portal-Nutzer bearbeiten' : 'Neuen Portal-Nutzer anlegen'"
icon="person"
@cancel="drawerEmployeeActive = false"
@cancel="drawerUserActive = false"
>
<div v-if="drawerEmployeeActive" class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Vorname</span>
<v-input v-model="employeeForm.first_name" placeholder="Vorname" autofocus />
</div>
<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="employeeForm.last_name" placeholder="Nachname" />
</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="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
</div>
<div class="field">
<span class="label">E-Mail</span>
<v-input v-model="userForm.email" placeholder="E-Mail Adresse" type="email" />
</div>
<v-divider v-if="isEditingEmployee" />
<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>
<div v-if="isEditingEmployee" class="field">
<span class="label">Temporäres Passwort</span>
<v-input v-model="employeeForm.temporary_password" readonly class="password-input" />
<p class="field-note">Wird beim Senden der Zugangsdaten automatisch generiert.</p>
</div>
</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="saveEmployee">Daten speichern</v-button>
<v-button primary block :loading="saving" @click="saveClientUser">Daten speichern</v-button>
<template v-if="isEditingEmployee">
<template v-if="isEditingUser">
<v-divider />
<v-button
v-tooltip.bottom="'Generiert PW, speichert es und sendet E-Mail'"
secondary
block
:loading="invitingId === employeeForm.id"
@click="inviteUser(employeeForm)"
:loading="invitingId === userForm.id"
@click="inviteUser(userForm)"
>
<v-icon name="send" left /> Zugangsdaten senden
</v-button>
@@ -154,37 +198,99 @@
</div>
</div>
</v-drawer>
</private-view>
<!-- 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 } from 'vue';
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();
const companies = ref<any[]>([]);
const selectedCompany = ref<any>(null);
const employees = ref<any[]>([]);
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);
// Forms State
const drawerCompanyActive = ref(false);
const isEditingCompany = ref(false);
const companyForm = ref({ id: '', name: '' });
const companies = ref<any[]>([]);
const people = ref<any[]>([]);
const drawerEmployeeActive = ref(false);
const isEditingEmployee = ref(false);
const employeeForm = ref({
id: '',
first_name: '',
last_name: '',
email: '',
temporary_password: ''
});
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 },
@@ -192,169 +298,224 @@ const tableHeaders = [
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
];
async function fetchCompanies() {
const res = await api.get('/items/companies', {
params: {
fields: ['id', 'name'],
sort: 'name',
},
});
companies.value = res.data.data;
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 selectCompany(company: any) {
selectedCompany.value = company;
loading.value = true;
try {
const res = await api.get('/items/client_users', {
params: {
filter: { company: { _eq: company.id } },
fields: ['*'],
sort: 'first_name',
},
});
employees.value = res.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;
}
}
// Company Actions
function openCreateCompany() {
isEditingCompany.value = false;
companyForm.value = { id: '', name: '' };
drawerCompanyActive.value = true;
function openCreateDrawer() {
isEditing.value = false;
form.value = { id: null, company: null, contact_person: null, status: 'active', notes: '' };
drawerActive.value = true;
}
async function openEditCompany() {
if (!selectedCompany.value) return;
companyForm.value = {
id: selectedCompany.value.id,
name: selectedCompany.value.name
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
};
isEditingCompany.value = true;
await nextTick();
drawerCompanyActive.value = true;
drawerActive.value = true;
}
async function saveCompany() {
if (!companyForm.value.name) return;
saving.value = true;
try {
if (isEditingCompany.value) {
await api.patch(`/items/companies/${companyForm.value.id}`, { name: companyForm.value.name });
notice.value = { type: 'success', message: 'Firma aktualisiert!' };
} else {
await api.post('/items/companies', { name: companyForm.value.name });
notice.value = { type: 'success', message: 'Firma angelegt!' };
}
drawerCompanyActive.value = false;
await fetchCompanies();
if (selectedCompany.value?.id === companyForm.value.id) {
selectedCompany.value.name = companyForm.value.name;
}
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} finally {
saving.value = false;
}
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;
}
}
// Employee Actions
function openCreateEmployee() {
isEditingEmployee.value = false;
employeeForm.value = { id: '', first_name: '', last_name: '', email: '', temporary_password: '' };
drawerEmployeeActive.value = true;
}
async function openEditEmployee(item: any) {
employeeForm.value = {
id: item.id || '',
first_name: item.first_name || '',
last_name: item.last_name || '',
email: item.email || '',
temporary_password: item.temporary_password || ''
};
isEditingEmployee.value = true;
await nextTick();
drawerEmployeeActive.value = true;
}
async function saveEmployee() {
if (!employeeForm.value.email || !selectedCompany.value) return;
saving.value = true;
try {
if (isEditingEmployee.value) {
await api.patch(`/items/client_users/${employeeForm.value.id}`, {
first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_name,
email: employeeForm.value.email
});
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
} else {
await api.post('/items/client_users', {
first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_name,
email: employeeForm.value.email,
company: selectedCompany.value.id
});
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
}
drawerEmployeeActive.value = false;
await selectCompany(selectedCompany.value);
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} 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 für ${user.first_name} versendet. 📧` };
await selectCompany(selectedCompany.value);
if (drawerEmployeeActive.value && employeeForm.value.id === user.id) {
const updated = employees.value.find(e => e.id === user.id);
if (updated) {
employeeForm.value.temporary_password = updated.temporary_password;
}
}
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler: ${e.message}` };
} finally {
invitingId.value = null;
}
// 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) {
openEditEmployee(item);
}
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'
});
return new Date(dateStr).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
onMounted(() => {
fetchCompanies();
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>
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; }
.company-item { cursor: pointer; }
.header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end; }
.header-right { display: flex; gap: 12px; }
.title { font-size: 24px; font-weight: 800; margin-bottom: 4px; }
.subtitle { color: var(--theme--foreground-subdued); font-size: 14px; }
.empty-state { height: 100%; display: flex; align-items: center; justify-content: center; }
.user-cell { display: flex; align-items: center; gap: 12px; }
.user-name { font-weight: 600; }
.status-date { font-size: 12px; color: var(--theme--foreground-subdued); }
@@ -364,6 +525,7 @@ onMounted(() => {
.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;
@@ -373,5 +535,4 @@ onMounted(() => {
.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; }
:deep(.v-list-item) { cursor: pointer !important; }
</style>

View File

@@ -0,0 +1,31 @@
{
"name": "@mintel/directus-extension-toolkit",
"version": "1.8.21",
"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

@@ -0,0 +1,102 @@
<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

@@ -0,0 +1,62 @@
<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

@@ -0,0 +1,84 @@
<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

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

View File

@@ -0,0 +1,24 @@
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,7 +1,17 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import globals from "globals";
export default tseslint.config(
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
...globals.es2021,
},
},
},
{
ignores: ["**/dist/**", "**/node_modules/**", "**/.next/**", "**/build/**"],
},

View File

@@ -2,40 +2,37 @@ import nextPlugin from "@next/eslint-plugin-next";
import reactPlugin from "eslint-plugin-react";
import hooksPlugin from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
import js from "@eslint/js";
/**
* Mintel Next.js ESLint Configuration (Flat Config)
*
*
* This configuration replaces the legacy 'eslint-config-next' which
* relies on @rushstack/eslint-patch and causes issues in ESLint 9.
*/
export const nextConfig = tseslint.config(
{
plugins: {
"react": reactPlugin,
"react-hooks": hooksPlugin,
"@next/next": nextPlugin,
export const nextConfig = tseslint.config({
plugins: {
react: reactPlugin,
"react-hooks": hooksPlugin,
"@next/next": nextPlugin,
},
languageOptions: {
globals: {
// Add common browser/node globals if needed,
// though usually handled by base configs
},
languageOptions: {
globals: {
// Add common browser/node globals if needed,
// though usually handled by base configs
},
},
rules: {
...reactPlugin.configs.recommended.rules,
...hooksPlugin.configs.recommended.rules,
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs["core-web-vitals"].rules,
"react/react-in-jsx-scope": "off",
"react/no-unescaped-entities": "off",
"@next/next/no-img-element": "warn",
},
settings: {
react: {
version: "detect",
},
rules: {
...reactPlugin.configs.recommended.rules,
...hooksPlugin.configs.recommended.rules,
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs["core-web-vitals"].rules,
"react/react-in-jsx-scope": "off",
"react/no-unescaped-entities": "off",
"@next/next/no-img-element": "warn",
},
settings: {
react: {
version: "detect",
},
},
}
);
},
});

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/eslint-config",
"version": "1.6.0",
"version": "1.8.21",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"

View File

@@ -0,0 +1,46 @@
{
"name": "@mintel/estimation-engine",
"version": "1.8.21",
"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",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"dotenv": "^17.3.1",
"ink": "^5.1.0",
"ink-spinner": "^5.0.0",
"ink-select-input": "^6.0.0",
"ink-text-input": "^6.0.0",
"react": "^18.2.0",
"openai": "^4.82.0"
},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"tsup": "^8.3.5",
"tsx": "^4.7.0",
"typescript": "^5.0.0"
}
}

View File

@@ -1,22 +1,20 @@
{
"name": "@mintel/extension-feedback-commander",
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
"icon": "view_kanban",
"version": "1.6.0",
"name": "feedback-commander",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.21",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "*",
"name": "Feedback Commander"
"host": "app",
"name": "feedback commander"
},
"scripts": {
"build": "directus-extension build",

View File

@@ -78,7 +78,7 @@
<div class="card-text">{{ item.text }}</div>
<footer class="card-footer">
<div class="meta-tags">
<v-chip x-small outline>{{ item.project }}</v-chip>
<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 />
@@ -142,7 +142,8 @@
<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 }}</span>
<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>
@@ -168,8 +169,12 @@
<v-card-title>Context</v-card-title>
<v-card-text class="meta-list">
<div class="meta-item">
<label><v-icon name="public" x-small /> Website</label>
<strong>{{ selectedItem.project }}</strong>
<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>
@@ -238,13 +243,14 @@ const statusOptions = [
];
const projects = computed(() => {
const projSet = new Set(items.value.map(i => i.project).filter(Boolean));
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 matchProject = currentProject.value === 'all' || item.project === currentProject.value;
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;
@@ -258,7 +264,8 @@ async function fetchData() {
const response = await api.get('/items/visual_feedback', {
params: {
sort: '-date_created,-id',
limit: 300
limit: 300,
fields: ['*', 'company.*', 'person.*']
}
});
items.value = response.data.data;
@@ -278,7 +285,8 @@ async function selectItem(item) {
const response = await api.get('/items/visual_feedback_comments', {
params: {
filter: { feedback_id: { _eq: item.id } },
sort: '-date_created,-id'
sort: '-date_created,-id',
fields: ['*', 'person.*']
}
});
comments.value = response.data.data;

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/gatekeeper",
"version": "1.6.0",
"version": "1.8.21",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,4 +1,3 @@
/* global module */
module.exports = {
plugins: {
tailwindcss: {},

View File

@@ -11,6 +11,8 @@ export async function GET(req: NextRequest) {
// 1. URL Parameter Bypass (for automated tests/staging)
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
console.log(`[Verify] Check: ${originalUrl} | Cookie: ${session ? "Found" : "Missing"}`);
const host =
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
const proto = req.headers.get("x-forwarded-proto") || "https";
@@ -44,7 +46,7 @@ export async function GET(req: NextRequest) {
return response;
}
} catch (e) {
} catch (_e) {
// URL parsing failed, proceed with normal logic
}
@@ -54,15 +56,17 @@ export async function GET(req: NextRequest) {
if (session?.value) {
if (session.value === password) {
isAuthenticated = true;
console.log(`[Verify] Legacy password match`);
} else {
try {
const payload = JSON.parse(session.value);
if (payload.identity) {
isAuthenticated = true;
identity = payload.identity;
console.log(`[Verify] Identity verified: ${identity}`);
}
} catch (e) {
// Fallback or old format
} catch (_e) {
console.log(`[Verify] JSON Parse failed for cookie: ${session.value.substring(0, 10)}...`);
}
}
}

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET(req: NextRequest) {
export async function GET(_req: NextRequest) {
const cookieStore = await cookies();
const authCookieName =
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
@@ -17,7 +17,7 @@ export async function GET(req: NextRequest) {
const payload = JSON.parse(session.value);
identity = payload.identity || "Guest";
company = payload.company || null;
} catch (e) {
} catch (_e) {
// Old format probably just the password
}

View File

@@ -17,8 +17,8 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
async function login(formData: FormData) {
"use server";
const email = formData.get("email") as string;
const password = formData.get("password") as string;
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;
@@ -31,19 +31,19 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
let userIdentity = "";
let userCompany: any = null;
// 1. Check Global Admin (from ENV)
if (
// 1. Check Generic Code (Guest) - High Priority to prevent autofill traps
if (password === expectedCode) {
userIdentity = "Guest";
}
// 2. Check Global Admin (from ENV)
else if (
adminEmail &&
adminPassword &&
email === adminEmail &&
password === adminPassword
email === adminEmail.trim() &&
password === adminPassword.trim()
) {
userIdentity = "Admin";
}
// 2. Check Generic Code (Guest)
else if (!email && password === expectedCode) {
userIdentity = "Guest";
}
// 3. Check Lightweight Client Users (dedicated collection)
if (email && password && process.env.INFRA_DIRECTUS_URL) {
try {
@@ -116,6 +116,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
}
if (userIdentity) {
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({
@@ -126,6 +127,8 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
const isDev = process.env.NODE_ENV === "development";
console.log(`[Login] Setting Cookie: ${authCookieName} | Domain: ${cookieDomain || "Default"}`);
cookieStore.set(authCookieName, sessionValue, {
httpOnly: true,
secure: !isDev,
@@ -136,6 +139,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
});
redirect(targetRedirect);
} else {
console.log(`[Login] Failed for inputs. Redirecting back with error.`);
redirect(`/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`);
}
}

View File

@@ -1,4 +1,3 @@
/* global module, require */
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [

View File

@@ -1,4 +1,3 @@
/* global process */
import path from "node:path";
const buildLintCommand = (filenames) => {

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/husky-config",
"version": "1.6.0",
"version": "1.8.21",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"

View File

@@ -0,0 +1 @@
404: Not Found

View File

@@ -0,0 +1,30 @@
[
{
"weights":
[
{"name":"conv0/filters","shape":[3,3,3,16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009007044399485869,"min":-1.2069439495311063}},
{"name":"conv0/bias","shape":[16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005263455241334205,"min":-0.9211046672334858}},
{"name":"conv1/depthwise_filter","shape":[3,3,16,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.004001977630690033,"min":-0.5042491814669441}},
{"name":"conv1/pointwise_filter","shape":[1,1,16,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013836609615999109,"min":-1.411334180831909}},
{"name":"conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0015159862590771096,"min":-0.30926119685173037}},
{"name":"conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002666276225856706,"min":-0.317286870876948}},
{"name":"conv2/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015265831292844286,"min":-1.6792414422128714}},
{"name":"conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0020280554598453,"min":-0.37113414915168985}},
{"name":"conv3/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006100742489683862,"min":-0.8907084034938438}},
{"name":"conv3/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.016276211832083907,"min":-2.0508026908425725}},
{"name":"conv3/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.003394414279975143,"min":-0.7637432129944072}},
{"name":"conv4/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006716050119961009,"min":-0.8059260143953211}},
{"name":"conv4/pointwise_filter","shape":[1,1,128,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021875603993733724,"min":-2.8875797271728514}},
{"name":"conv4/bias","shape":[256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0041141652009066415,"min":-0.8187188749804216}},
{"name":"conv5/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008423839597141042,"min":-0.9013508368940915}},
{"name":"conv5/pointwise_filter","shape":[1,1,256,512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.030007277283014035,"min":-3.8709387695088107}},
{"name":"conv5/bias","shape":[512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008402082966823203,"min":-1.4871686851277068}},
{"name":"conv8/filters","shape":[1,1,512,25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.028336129469030042,"min":-4.675461362389957}},
{"name":"conv8/bias","shape":[25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002268134028303857,"min":-0.41053225912299807}}
],
"paths":
[
"tiny_face_detector_model.bin"
]
}
]

View File

@@ -0,0 +1,33 @@
{
"name": "@mintel/image-processor",
"version": "1.8.21",
"private": true,
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"lint": "eslint src"
},
"dependencies": {
"@tensorflow/tfjs": "^4.22.0",
"@vladmandic/face-api": "^1.7.15",
"canvas": "^3.2.1",
"sharp": "^0.33.2"
},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@types/node": "^20.0.0",
"tsup": "^8.3.5",
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1 @@
export * from './processor.js';

View File

@@ -0,0 +1,218 @@
import sharp from "sharp";
import { Canvas, Image, ImageData } from "canvas";
// Use the ESM no-bundle build to avoid the default Node entrypoint
// which hardcodes require('@tensorflow/tfjs-node') and crashes in Docker.
// This build uses pure @tensorflow/tfjs (JS-only, no native C++ bindings).
// @ts-ignore - direct path import has no type declarations
import * as faceapi from "@vladmandic/face-api/dist/face-api.esm-nobundle.js";
import * as tf from "@tensorflow/tfjs";
import path from "path";
import { fileURLToPath } from "url";
// Polyfill required by face-api for Node.js
faceapi.env.monkeyPatch({ Canvas, Image, ImageData } as any);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const MODEL_URL = path.join(__dirname, "../models");
// State flag to ensure we only load weights once
let modelsLoaded = false;
async function loadModelsOnce() {
if (modelsLoaded) return;
// Initialize pure JS CPU backend (no native bindings needed)
await tf.setBackend("cpu");
await tf.ready();
// Load the microscopic TinyFaceDetector (~190KB)
await faceapi.nets.tinyFaceDetector.loadFromDisk(MODEL_URL);
modelsLoaded = true;
}
export interface ProcessImageOptions {
width: number;
height: number;
format?: "webp" | "jpeg" | "png" | "avif";
quality?: number;
}
/**
* Maps a URL based on the IMGPROXY_URL_MAPPING environment variable.
* Format: "match1:replace1,match2:replace2"
*/
export function mapUrl(url: string, mappingString?: string): string {
if (!mappingString) return url;
const mappings = mappingString.split(",").map((m) => {
if (m.includes("|")) {
return m.split("|");
}
// Legacy support for simple "host:target" or cases where one side might have a protocol
// We try to find the split point that isn't part of a protocol "://"
const colonIndices = [];
for (let i = 0; i < m.length; i++) {
if (m[i] === ":") {
// Check if this colon is part of "://"
if (!(m[i + 1] === "/" && m[i + 2] === "/")) {
colonIndices.push(i);
}
}
}
if (colonIndices.length === 0) return [m];
// In legacy mode with colons, we take the LAST non-protocol colon as the separator
// This handles "http://host:port" or "host:http://target" better
const lastColon = colonIndices[colonIndices.length - 1];
return [m.substring(0, lastColon), m.substring(lastColon + 1)];
});
let mappedUrl = url;
for (const [match, replace] of mappings) {
if (match && replace && url.includes(match)) {
mappedUrl = url.replace(match, replace);
}
}
return mappedUrl;
}
/**
* Parses legacy imgproxy options string.
* Example: rs:fill:300:400/q:80
*/
export function parseImgproxyOptions(
optionsStr: string,
): Partial<ProcessImageOptions> {
const parts = optionsStr.split("/");
const options: Partial<ProcessImageOptions> = {};
for (const part of parts) {
if (part.startsWith("rs:")) {
const [, , w, h] = part.split(":");
if (w) options.width = parseInt(w, 10);
if (h) options.height = parseInt(h, 10);
} else if (part.startsWith("q:")) {
const q = part.split(":")[1];
if (q) options.quality = parseInt(q, 10);
} else if (part.startsWith("ext:")) {
const ext = part.split(":")[1] as any;
if (["webp", "jpeg", "png", "avif"].includes(ext)) {
options.format = ext;
}
}
}
return options;
}
export async function processImageWithSmartCrop(
inputBuffer: Buffer,
options: ProcessImageOptions,
): Promise<Buffer> {
const sharpImage = sharp(inputBuffer);
const metadata = await sharpImage.metadata();
if (!metadata.width || !metadata.height) {
throw new Error("Could not read image metadata");
}
// Load ML models (noop if already loaded)
await loadModelsOnce();
// Convert sharp image to a Node-compatible canvas Image for face-api
const jpegBuffer = await sharpImage.jpeg().toBuffer();
const img = new Image();
img.src = jpegBuffer;
const canvas = new Canvas(img.width, img.height);
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, img.width, img.height);
// Detect faces locally using the tiny model
// Requires explicit any cast since the types expect HTML elements in browser contexts
const detections = await faceapi.detectAllFaces(
canvas as any,
new faceapi.TinyFaceDetectorOptions(),
);
let cropPosition: "center" | "attention" | number = "attention"; // Fallback to sharp's attention if no faces
if (detections.length > 0) {
// We have faces! Calculate the bounding box that contains all of them
let minX = metadata.width;
let minY = metadata.height;
let maxX = 0;
let maxY = 0;
for (const det of detections) {
const box = det.box;
if (box.x < minX) minX = Math.max(0, box.x);
if (box.y < minY) minY = Math.max(0, box.y);
if (box.x + box.width > maxX)
maxX = Math.min(metadata.width, box.x + box.width);
if (box.y + box.height > maxY)
maxY = Math.min(metadata.height, box.y + box.height);
}
const centerX = Math.floor(minX + (maxX - minX) / 2);
const centerY = Math.floor(minY + (maxY - minY) / 2);
const targetRatio = options.width / options.height;
const currentRatio = metadata.width / metadata.height;
let cropWidth = metadata.width;
let cropHeight = metadata.height;
// Determine the maximal crop window that maintains aspect ratio
if (currentRatio > targetRatio) {
cropWidth = Math.floor(metadata.height * targetRatio);
} else {
cropHeight = Math.floor(metadata.width / targetRatio);
}
// Center the crop window over the center of the faces
let cropX = Math.floor(centerX - cropWidth / 2);
let cropY = Math.floor(centerY - cropHeight / 2);
// Keep crop window inside image bounds
if (cropX < 0) cropX = 0;
if (cropY < 0) cropY = 0;
if (cropX + cropWidth > metadata.width) cropX = metadata.width - cropWidth;
if (cropY + cropHeight > metadata.height)
cropY = metadata.height - cropHeight;
// Pre-crop the image to isolate the faces before resizing
sharpImage.extract({
left: cropX,
top: cropY,
width: cropWidth,
height: cropHeight,
});
// As we manually calculated the exact focal box, we can now just center it
cropPosition = "center";
}
let finalImage = sharpImage.resize(options.width, options.height, {
fit: "cover",
position: cropPosition,
});
const format = options.format || "webp";
const quality = options.quality || 80;
if (format === "webp") {
finalImage = finalImage.webp({ quality });
} else if (format === "jpeg") {
finalImage = finalImage.jpeg({ quality });
} else if (format === "png") {
finalImage = finalImage.png({ quality });
} else if (format === "avif") {
finalImage = finalImage.avif({ quality });
}
return finalImage.toBuffer();
}

View File

@@ -0,0 +1,19 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"allowJs": true,
"esModuleInterop": true,
"module": "NodeNext",
"moduleResolution": "NodeNext"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts"
]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
// Bundle face-api and tensorflow inline (they're pure JS).
// Keep sharp and canvas external (they have native C++ bindings).
noExternal: [
"@vladmandic/face-api",
"@tensorflow/tfjs",
"@tensorflow/tfjs-backend-wasm"
],
external: [
"sharp",
"canvas"
],
});

View File

@@ -1,6 +1,6 @@
# Step 1: Builder stage
FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat curl
RUN apk add --no-cache libc6-compat curl python3 make g++ pkgconfig pixman-dev cairo-dev pango-dev
WORKDIR /app
RUN corepack enable pnpm
ENV CI=true
@@ -25,7 +25,7 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
pnpm config set store-dir /pnpm/store && \
pnpm i --frozen-lockfile
pnpm i --no-frozen-lockfile
# Copy the rest of the source
COPY . .

View File

@@ -1,38 +1,13 @@
# Step 1: Builder image
FROM node:20-alpine AS builder
# Step 1: Base image for Next.js builds
FROM node:20-alpine
RUN apk add --no-cache libc6-compat curl
# Enable pnpm
RUN corepack enable pnpm && \
corepack prepare pnpm@10.2.0 --activate
WORKDIR /app
RUN corepack enable pnpm
# Step 2: Install dependencies
ENV NPM_TOKEN=placeholder
# Copy manifest files specifically for better layer caching
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
# Copy package manifest files individually to preserve directory structure
COPY packages/cli/package.json ./packages/cli/
COPY packages/cms-infra/package.json ./packages/cms-infra/
COPY packages/customer-manager/package.json ./packages/customer-manager/
COPY packages/eslint-config/package.json ./packages/eslint-config/
COPY packages/feedback-commander/package.json ./packages/feedback-commander/
COPY packages/gatekeeper/package.json ./packages/gatekeeper/
COPY packages/husky-config/package.json ./packages/husky-config/
COPY packages/infra/package.json ./packages/infra/
COPY packages/mail/package.json ./packages/mail/
COPY packages/next-config/package.json ./packages/next-config/
COPY packages/next-feedback/package.json ./packages/next-feedback/
COPY packages/next-observability/package.json ./packages/next-observability/
COPY packages/next-utils/package.json ./packages/next-utils/
COPY packages/observability/package.json ./packages/observability/
COPY packages/tsconfig/package.json ./packages/tsconfig/
# packages/ui does not have a package.json
# Use a secret for NPM_TOKEN and a standardized cache mount
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
pnpm config set store-dir /pnpm/store && \
pnpm i --frozen-lockfile
# Step 3: Build shared packages
COPY . .
RUN pnpm --filter "./packages/*" -r build
# Final environment
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

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