Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d9604a65a | |||
| b3d089ac6d | |||
| baecc9c83c | |||
| d5632b009a | |||
| 90a9e34c7e | |||
| 99f040cfb0 | |||
| 02bffbc67f | |||
| f4507ef121 | |||
| 3a1a88db89 | |||
| a9adb2eff7 | |||
| a50b8d6393 | |||
| 3f1c37813a | |||
| 8f32c80801 | |||
| 67750c886e | |||
| 9fe9a74e71 | |||
| 92fe089619 | |||
| 7dcef0bc28 | |||
| 2ba091f738 | |||
| 5757c1172b | |||
| e7d5798857 | |||
| 29a414f385 | |||
| 69764e42c6 | |||
| d69ade6268 | |||
| ceaf3ae3ea | |||
| 169cb83f69 | |||
| f831a7e67e | |||
| cb4ffcaeda | |||
| 9b1f3fb7e8 | |||
| f48d89c368 | |||
| ad40e71757 | |||
| 911ceffdc5 | |||
| 23358fc708 | |||
| 73ea958655 | |||
| f2035d79dd | |||
| f514349ccf | |||
| a71f86560b | |||
| de8314732d | |||
| bdf7773310 | |||
| a25e4aa1d4 | |||
| ecc2163b8e | |||
| af02378d29 | |||
| f8847a7a10 | |||
| 117b23db1e | |||
| d6f9a24823 | |||
| 422e4fccba | |||
| 57ec4d7544 | |||
| a4d021c658 | |||
| 269d19bbef | |||
| 30ff08c66d | |||
| 81deaf447f | |||
| a0ebc58d6d | |||
| 7498c24c9a | |||
| efba82337c | |||
| c083b309fb | |||
| eb8bf60408 | |||
| a3819490ac | |||
| 1127954fea | |||
| fa0b133012 | |||
| 1b40baebd4 | |||
| 316c03869a | |||
| 63d2acfab5 | |||
| bdeae0aca6 | |||
| 47c70a16f1 | |||
| b96d44bf6d | |||
| 73b60f14a9 | |||
| b3f43c421f | |||
| a2339f7106 | |||
| e83a76f111 | |||
| 0096c18098 | |||
| 3284931f84 | |||
| 28517a3558 | |||
| 3b9f10ec98 | |||
| 65fd248993 | |||
| ebd9ab132c | |||
| ddaeb2c3ca | |||
| ad1a8c4fbf | |||
| 013b0259b2 | |||
| d5a9a3bce4 | |||
| b9fd583ac4 | |||
| bfdbaba0d0 | |||
| 4ea9cbc551 | |||
| d8c1a38c0d | |||
| b65b9a7fb2 | |||
| 858c7bbc39 | |||
| 149123ef90 | |||
| 6bc49d1c52 | |||
| 52ffe49019 | |||
| 73fa292528 | |||
| f2c0a4581c | |||
| 367c4d8404 | |||
| 587c88980f | |||
| fcdfdb4588 | |||
| 6bbaa8d105 | |||
| eccc084441 | |||
| da6b8aba64 | |||
| 290097b4e6 | |||
| 45894cce34 | |||
| 7195906da0 | |||
| dcb466f53b | |||
| 14089766ea | |||
| 6ecabe4a04 | |||
| b205220bde | |||
| 3d5a802c6e | |||
| b5d1272f85 | |||
| e152fb8171 | |||
| d7cec1fa0e | |||
| 67c2af958a | |||
| 015e295370 | |||
| c9952bfd1d | |||
| f9aaf3712e | |||
| d2bbfe3b40 | |||
| f3fafa8ea0 | |||
| 625c58398c | |||
| a306d24f51 | |||
| 59d3e97ef0 | |||
| 0c0d0caae6 | |||
| 2c9f12623e | |||
| a55649c5f2 | |||
| 0d7c588536 | |||
| b6debcbb59 | |||
| 5847bc5795 | |||
| e662415137 | |||
| 580b087e8a | |||
| ac3c405cb2 | |||
| a594affdfa | |||
| 61e78ea672 | |||
| 6501eac38a | |||
| 7f9206ae77 | |||
| 6229f8e886 | |||
| 8ac090aff3 | |||
| 696f9d361d | |||
| 31840da9e7 | |||
| 96ec2c7d8d | |||
| 9029375247 | |||
| 95d0a1622f | |||
| 646d615e76 | |||
| 51409099fc | |||
| 22cd20e639 | |||
| e7cc1c8ca5 | |||
| 0ccb15a929 | |||
| a94ddcfbb2 | |||
| d3a9af140c |
82
.agent/workflows/cms-workflow.md
Normal file
82
.agent/workflows/cms-workflow.md
Normal 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`).
|
||||
7
.changeset/resilient-build-scripts.md
Normal file
7
.changeset/resilient-build-scripts.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@mintel/monorepo": patch
|
||||
"acquisition-manager": patch
|
||||
"feedback-commander": patch
|
||||
---
|
||||
|
||||
fix: make directus extension build scripts more resilient
|
||||
@@ -1,7 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.npmrc
|
||||
# .npmrc is allowed as it contains the registry template
|
||||
dist
|
||||
build
|
||||
out
|
||||
@@ -10,3 +10,5 @@ coverage
|
||||
.turbo
|
||||
*.log
|
||||
.DS_Store
|
||||
.pnpm-store
|
||||
.gitea
|
||||
|
||||
37
.env
Normal file
37
.env
Normal file
@@ -0,0 +1,37 @@
|
||||
# Project
|
||||
IMAGE_TAG=v1.8.10
|
||||
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
|
||||
@@ -1,4 +1,5 @@
|
||||
# Project
|
||||
IMAGE_TAG=v1.8.6
|
||||
PROJECT_NAME=sample-website
|
||||
PROJECT_COLOR=#82ed20
|
||||
|
||||
4
.eslintignore
Normal file
4
.eslintignore
Normal file
@@ -0,0 +1,4 @@
|
||||
**/index.js
|
||||
**/dist/**
|
||||
packages/cms-infra/extensions/**
|
||||
packages/cms-infra/extensions/**
|
||||
44
.gitea/workflows/maintenance.yml
Normal file
44
.gitea/workflows/maintenance.yml
Normal 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
|
||||
@@ -2,51 +2,146 @@ name: Monorepo Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
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
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
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
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
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
|
||||
@@ -58,33 +153,45 @@ 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..."
|
||||
pnpm sync-versions
|
||||
pnpm release:tag
|
||||
|
||||
build-images:
|
||||
name: 🐳 Build & Push Images
|
||||
needs: qa
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
name: 🐳 Build ${{ matrix.name }}
|
||||
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
|
||||
file: packages/infra/docker/Dockerfile.nextjs
|
||||
name: Build-Base
|
||||
- image: runtime
|
||||
file: packages/infra/docker/Dockerfile.runtime
|
||||
name: Production Runtime
|
||||
- image: gatekeeper
|
||||
file: packages/infra/docker/Dockerfile.gatekeeper
|
||||
name: Gatekeeper (Product)
|
||||
- image: directus
|
||||
file: packages/infra/docker/Dockerfile.directus
|
||||
name: Directus (Base)
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -99,54 +206,18 @@ jobs:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASS }}
|
||||
|
||||
- name: 🏗️ Build & Push Nextjs Build-Base
|
||||
- name: 🏗️ Build & Push ${{ matrix.name }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.nextjs
|
||||
platforms: linux/amd64,linux/arm64
|
||||
file: ${{ matrix.file }}
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
provenance: false
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/nextjs:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/nextjs:latest
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
||||
|
||||
- name: 🏗️ Build & Push Production Runtime
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.runtime
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/runtime:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/runtime:latest
|
||||
|
||||
- name: 🏗️ Build & Push Gatekeeper (Product)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.gatekeeper
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/gatekeeper:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/gatekeeper:latest
|
||||
|
||||
- name: 🏗️ Build & Push Directus (Base)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.directus
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/directus:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/directus:latest
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnpm-debug.log*
|
||||
.pnpm-store/
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
@@ -36,3 +37,8 @@ Thumbs.db
|
||||
|
||||
# Changesets
|
||||
.changeset/*.lock
|
||||
directus/extensions/
|
||||
packages/cms-infra/extensions/
|
||||
packages/cms-infra/uploads/
|
||||
|
||||
directus/uploads/directus-health-file
|
||||
44
.husky/pre-push
Executable file
44
.husky/pre-push
Executable file
@@ -0,0 +1,44 @@
|
||||
# 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
|
||||
while read local_ref local_sha remote_ref remote_sha
|
||||
do
|
||||
if [[ "$remote_ref" == refs/tags/* ]]; then
|
||||
TAG=${remote_ref#refs/tags/}
|
||||
echo "🏷️ Tag detected: $TAG, ensuring versions are synced..."
|
||||
|
||||
# Run sync script
|
||||
pnpm sync-versions "$TAG"
|
||||
|
||||
# Check for changes in relevant files
|
||||
SYNC_FILES="package.json packages/*/package.json apps/*/package.json .env.example"
|
||||
CHANGES=$(git status --porcelain $SYNC_FILES)
|
||||
|
||||
if [[ -n "$CHANGES" ]]; then
|
||||
echo "📝 Version sync made changes. Integrating into tag..."
|
||||
|
||||
# Stage and commit
|
||||
git add $SYNC_FILES
|
||||
git commit -m "chore: sync versions to $TAG" --no-verify
|
||||
|
||||
# Force update the local tag to point to the new commit
|
||||
git tag -f "$TAG" > /dev/null
|
||||
|
||||
echo "✅ Tag $TAG has been updated locally with synced versions."
|
||||
echo "🚀 Auto-pushing updated tag..."
|
||||
|
||||
# Push the updated tag directly (using --no-verify to avoid recursion)
|
||||
git push origin "$TAG" --force --no-verify
|
||||
|
||||
echo "✨ All done! Hook integrated the sync and pushed for you."
|
||||
exit 1 # Still exit 1 to abort the original (now outdated) push attempt
|
||||
else
|
||||
echo "✨ Versions already in sync for $TAG."
|
||||
fi
|
||||
fi
|
||||
done
|
||||
3
.npmrc
3
.npmrc
@@ -2,3 +2,6 @@
|
||||
registry=https://npm.infra.mintel.me/
|
||||
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
||||
always-auth=true
|
||||
|
||||
public-hoist-pattern[]=*
|
||||
shamefully-hoist=true
|
||||
|
||||
56
Dockerfile.template
Normal file
56
Dockerfile.template
Normal 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"]
|
||||
@@ -80,3 +80,4 @@ 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.
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { nextConfig } from "@mintel/eslint-config/next";
|
||||
|
||||
export default nextConfig;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sample-website",
|
||||
"version": "0.1.1",
|
||||
"version": "1.8.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -8,21 +8,19 @@
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"next": "15.1.6",
|
||||
"@mintel/observability": "workspace:*",
|
||||
"@mintel/next-observability": "workspace:*",
|
||||
"@mintel/image-processor": "workspace:*",
|
||||
"@sentry/nextjs": "10.38.0",
|
||||
"next": "16.1.6",
|
||||
"next-intl": "^4.8.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
|
||||
9
apps/sample-website/sentry.client.config.ts
Normal file
9
apps/sample-website/sentry.client.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { initSentry } from "@mintel/next-observability";
|
||||
|
||||
initSentry({
|
||||
// Use a placeholder DSN on the client if you want to bypass ad-blockers via tunnel
|
||||
// Or just use the real DSN if you don't care about ad-blockers for errors.
|
||||
// The Mintel standard is to use the relay.
|
||||
dsn: "https://public@errors.infra.mintel.me/1", // Placeholder for relay
|
||||
tunnel: "/errors/api/relay",
|
||||
});
|
||||
8
apps/sample-website/sentry.edge.config.ts
Normal file
8
apps/sample-website/sentry.edge.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { initSentry } from "@mintel/next-observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
const env = validateMintelEnv();
|
||||
|
||||
initSentry({
|
||||
dsn: env.SENTRY_DSN,
|
||||
});
|
||||
8
apps/sample-website/sentry.server.config.ts
Normal file
8
apps/sample-website/sentry.server.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { initSentry } from "@mintel/next-observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
const env = validateMintelEnv();
|
||||
|
||||
initSentry({
|
||||
dsn: env.SENTRY_DSN,
|
||||
});
|
||||
45
apps/sample-website/src/app/api/image/route.ts
Normal file
45
apps/sample-website/src/app/api/image/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { processImageWithSmartCrop } from '@mintel/image-processor';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get('url');
|
||||
let width = parseInt(searchParams.get('w') || '800');
|
||||
let height = parseInt(searchParams.get('h') || '600');
|
||||
let 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);
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
6
apps/sample-website/src/app/errors/api/relay/route.ts
Normal file
6
apps/sample-website/src/app/errors/api/relay/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createSentryRelayHandler } from "@mintel/next-observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
export const POST = createSentryRelayHandler({
|
||||
dsn: validateMintelEnv().SENTRY_DSN,
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import "./globals.css";
|
||||
import {
|
||||
AnalyticsContextProvider,
|
||||
AnalyticsAutoTracker,
|
||||
} from "@mintel/next-observability/client";
|
||||
import { getAnalyticsConfig } from "@/lib/observability";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sample Website",
|
||||
@@ -11,9 +17,18 @@ export default function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const analyticsConfig = getAnalyticsConfig();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<AnalyticsContextProvider config={analyticsConfig}>
|
||||
<Suspense fallback={null}>
|
||||
<AnalyticsAutoTracker />
|
||||
</Suspense>
|
||||
{children}
|
||||
</AnalyticsContextProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
7
apps/sample-website/src/app/stats/api/send/route.ts
Normal file
7
apps/sample-website/src/app/stats/api/send/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createUmamiProxyHandler } from "@mintel/next-observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
export const POST = createUmamiProxyHandler({
|
||||
websiteId: validateMintelEnv().UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: validateMintelEnv().UMAMI_API_ENDPOINT,
|
||||
});
|
||||
13
apps/sample-website/src/instrumentation.ts
Normal file
13
apps/sample-website/src/instrumentation.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("../sentry.server.config");
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
await import("../sentry.edge.config");
|
||||
}
|
||||
}
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
54
apps/sample-website/src/lib/observability.ts
Normal file
54
apps/sample-website/src/lib/observability.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
UmamiAnalyticsService,
|
||||
GotifyNotificationService,
|
||||
NoopNotificationService,
|
||||
} from "@mintel/observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
let analyticsService: any = null;
|
||||
let notificationService: any = null;
|
||||
|
||||
export function getAnalyticsConfig() {
|
||||
const isClient = typeof window !== "undefined";
|
||||
|
||||
if (isClient) {
|
||||
return {
|
||||
enabled: true,
|
||||
apiEndpoint: "/stats",
|
||||
};
|
||||
}
|
||||
|
||||
const env = validateMintelEnv();
|
||||
return {
|
||||
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
||||
websiteId: env.UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAnalyticsService() {
|
||||
if (analyticsService) return analyticsService;
|
||||
|
||||
const config = getAnalyticsConfig();
|
||||
analyticsService = new UmamiAnalyticsService(config);
|
||||
|
||||
return analyticsService;
|
||||
}
|
||||
|
||||
export function getNotificationService() {
|
||||
if (notificationService) return notificationService;
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
const env = validateMintelEnv();
|
||||
notificationService = new GotifyNotificationService({
|
||||
enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN),
|
||||
url: env.GOTIFY_URL || "",
|
||||
token: env.GOTIFY_TOKEN || "",
|
||||
});
|
||||
} else {
|
||||
// Notifications are typically server-side only to protect tokens
|
||||
notificationService = new NoopNotificationService();
|
||||
}
|
||||
|
||||
return notificationService;
|
||||
}
|
||||
@@ -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:
|
||||
@@ -1,3 +1,26 @@
|
||||
import baseConfig from "@mintel/eslint-config";
|
||||
import { nextConfig } from "@mintel/eslint-config/next";
|
||||
|
||||
export default nextConfig;
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
"packages/cms-infra/extensions/**",
|
||||
"**/index.js",
|
||||
"**/*.db",
|
||||
"**/build/**",
|
||||
"**/data/**",
|
||||
"**/reference/**",
|
||||
"**/dist/**",
|
||||
"**/.next/**",
|
||||
],
|
||||
},
|
||||
...baseConfig,
|
||||
...nextConfig.map((config) => ({
|
||||
...config,
|
||||
files: [
|
||||
"apps/sample-website/**/*.{ts,tsx}",
|
||||
"packages/gatekeeper/**/*.{ts,tsx}",
|
||||
"../klz-2026/**/*.{ts,tsx}",
|
||||
],
|
||||
})),
|
||||
];
|
||||
|
||||
1
models/tiny_face_detector_model-shard1
Normal file
1
models/tiny_face_detector_model-shard1
Normal file
@@ -0,0 +1 @@
|
||||
404: Not Found
|
||||
30
models/tiny_face_detector_model-weights_manifest.json
Normal file
30
models/tiny_face_detector_model-weights_manifest.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
31
package.json
31
package.json
@@ -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,13 +30,14 @@
|
||||
"@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",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-next": "^0.0.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"happy-dom": "^20.4.0",
|
||||
@@ -38,5 +49,19 @@
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"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.8.6",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"next": "16.1.6",
|
||||
"@sentry/nextjs": "10.38.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
packages/acquisition-manager/package.json
Normal file
30
packages/acquisition-manager/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "acquisition-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.6",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
19
packages/acquisition-manager/src/index.ts
Normal file
19
packages/acquisition-manager/src/index.ts
Normal 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
|
||||
}
|
||||
],
|
||||
});
|
||||
468
packages/acquisition-manager/src/module.vue
Normal file
468
packages/acquisition-manager/src/module.vue
Normal 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>
|
||||
· 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>
|
||||
55
packages/acquisition/build.mjs
Normal file
55
packages/acquisition/build.mjs
Normal 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);
|
||||
});
|
||||
27
packages/acquisition/package.json
Normal file
27
packages/acquisition/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "acquisition",
|
||||
"version": "1.8.6",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
236
packages/acquisition/src/index.ts
Normal file
236
packages/acquisition/src/index.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,30 @@ The Mintel CLI is the primary automation tool for managing the monorepo and ensu
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
The CLI is intended to be used within the monorepo:
|
||||
### Using npx (Recommended)
|
||||
|
||||
Run the CLI without installing it globally. This always uses the latest version from the registry:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
npx @mintel/cli init apps/my-new-website.com
|
||||
```
|
||||
|
||||
### Global Installation
|
||||
|
||||
Install the CLI globally from the Mintel registry:
|
||||
|
||||
```bash
|
||||
npm install -g @mintel/cli
|
||||
```
|
||||
|
||||
### Development (Local Link)
|
||||
|
||||
If you are contributing to the CLI, you can link it locally:
|
||||
|
||||
```bash
|
||||
cd packages/cli
|
||||
pnpm build
|
||||
npm link
|
||||
```
|
||||
|
||||
## 🛠 Commands
|
||||
@@ -17,10 +37,11 @@ pnpm install
|
||||
Scaffolds a new, production-ready client website in the specified path.
|
||||
|
||||
```bash
|
||||
pnpm --filter @mintel/cli start init apps/my-new-website.com
|
||||
mintel init apps/my-new-website.com
|
||||
```
|
||||
|
||||
#### What it does:
|
||||
|
||||
1. **Project Structure**: Creates a modern Next.js directory layout.
|
||||
2. **Shared Configs**: Generates `package.json`, `tsconfig.json`, and `eslint.config.mjs` that extend the `@mintel` shared packages.
|
||||
3. **Localization**: Sets up a localized routing structure (`src/app/[locale]`) with `next-intl` pre-configured.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/cli",
|
||||
"version": "1.0.1",
|
||||
"version": "1.8.6",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
@@ -10,7 +10,7 @@
|
||||
"mintel": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --target es2020",
|
||||
"build": "tsup",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsup src/index.ts --format esm --watch --target es2020",
|
||||
"test": "vitest run"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
@@ -24,24 +25,25 @@ program
|
||||
console.log(chalk.cyan("Running Next.js locally..."));
|
||||
execSync("next dev", { stdio: "inherit" });
|
||||
} else {
|
||||
console.log(chalk.cyan("Starting Docker stack (App, Directus, DB)..."));
|
||||
// Ensure network exists
|
||||
try {
|
||||
execSync("docker network create infra", { stdio: "ignore" });
|
||||
} catch (e) {}
|
||||
console.log(chalk.cyan("Starting Docker stack (App, Directus, DB)..."));
|
||||
// Ensure network exists
|
||||
} catch (_e) {
|
||||
// Network already exists or docker is not running
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.yellow(`
|
||||
console.log(
|
||||
chalk.yellow(`
|
||||
📱 App: http://localhost:3000
|
||||
🗄️ CMS: http://localhost:8055/admin
|
||||
🚦 Traefik: http://localhost:8080
|
||||
`),
|
||||
);
|
||||
execSync(
|
||||
"docker-compose down --remove-orphans && docker-compose up app directus directus-db",
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
}
|
||||
);
|
||||
execSync(
|
||||
"docker compose down --remove-orphans && docker compose up -d app directus at-mintel-directus-db",
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
});
|
||||
|
||||
const directus = program
|
||||
@@ -59,6 +61,115 @@ directus
|
||||
});
|
||||
});
|
||||
|
||||
directus
|
||||
.command("bootstrap-feedback")
|
||||
.description("Setup Directus collections and flows for Feedback")
|
||||
.action(async () => {
|
||||
const { execSync } = await import("child_process");
|
||||
console.log(chalk.blue("📧 Bootstrapping Visual Feedback System..."));
|
||||
// Use the logic from setup-feedback-hardened.ts
|
||||
const bootstrapScript = `
|
||||
import { createDirectus, rest, authentication, createCollection, createDashboard, createPanel, createItems, createPermission, readPolicies, readRoles, readUsers } from '@directus/sdk';
|
||||
|
||||
async function setup() {
|
||||
const url = process.env.DIRECTUS_URL || 'http://localhost:8055';
|
||||
const email = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||
const password = process.env.DIRECTUS_ADMIN_PASSWORD;
|
||||
|
||||
if (!email || !password) {
|
||||
console.error('❌ DIRECTUS_ADMIN_EMAIL or DIRECTUS_ADMIN_PASSWORD not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = createDirectus(url).with(authentication('json')).with(rest());
|
||||
|
||||
try {
|
||||
console.log('🔑 Authenticating...');
|
||||
await client.login(email, password);
|
||||
|
||||
const roles = await client.request(readRoles());
|
||||
const adminRole = roles.find(r => r.name === 'Administrator');
|
||||
const policies = await client.request(readPolicies());
|
||||
const adminPolicy = policies.find(p => p.name === 'Administrator');
|
||||
|
||||
console.log('🏗️ Creating Collection "visual_feedback"...');
|
||||
try {
|
||||
await client.request(createCollection({
|
||||
collection: 'visual_feedback',
|
||||
meta: { icon: 'feedback', display_template: '{{user_name}}: {{text}}' },
|
||||
fields: [
|
||||
{ field: 'id', type: 'uuid', schema: { is_primary_key: true } },
|
||||
{ field: 'status', type: 'string', schema: { default_value: 'open' }, meta: { interface: 'select-dropdown' } },
|
||||
{ field: 'url', type: 'string' },
|
||||
{ field: 'selector', type: 'string' },
|
||||
{ field: 'x', type: 'float' },
|
||||
{ field: 'y', type: 'float' },
|
||||
{ field: 'type', type: 'string' },
|
||||
{ field: 'text', type: 'text' },
|
||||
{ field: 'user_name', type: 'string' },
|
||||
{ field: 'user_identity', type: 'string' },
|
||||
{ field: 'screenshot', type: 'uuid', meta: { interface: 'file' } },
|
||||
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
|
||||
]
|
||||
} as any));
|
||||
} catch (_e) { console.log(' (Collection might already exist)'); }
|
||||
|
||||
try {
|
||||
await client.request(createCollection({
|
||||
collection: 'visual_feedback_comments',
|
||||
meta: { icon: 'comment' },
|
||||
fields: [
|
||||
{ field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true } },
|
||||
{ field: 'feedback_id', type: 'uuid', meta: { interface: 'select-dropdown' } },
|
||||
{ field: 'user_name', type: 'string' },
|
||||
{ field: 'text', type: 'text' },
|
||||
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
|
||||
]
|
||||
} as any));
|
||||
} catch (e) { }
|
||||
|
||||
if (adminPolicy) {
|
||||
console.log('🔐 Granting ALL permissions to Administrator Policy...');
|
||||
for (const coll of ['visual_feedback', 'visual_feedback_comments']) {
|
||||
for (const action of ['create', 'read', 'update', 'delete']) {
|
||||
try {
|
||||
await client.request(createPermission({
|
||||
collection: coll,
|
||||
action,
|
||||
fields: ['*'],
|
||||
policy: adminPolicy.id
|
||||
} as any));
|
||||
} catch (_e) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📊 Creating Dashboard...');
|
||||
try {
|
||||
const dash = await client.request(createDashboard({ name: 'Visual Feedback', icon: 'feedback', color: '#6366f1' }));
|
||||
await client.request(createPanel({
|
||||
dashboard: dash.id,
|
||||
name: 'Total Feedbacks',
|
||||
type: 'metric',
|
||||
width: 12, height: 6, position_x: 1, position_y: 1,
|
||||
options: { collection: 'visual_feedback', function: 'count', field: 'id' }
|
||||
} as any));
|
||||
} catch (e) { }
|
||||
|
||||
console.log('✨ FEEDBACK BOOTSTRAP DONE.');
|
||||
} catch (e) { console.error('❌ FAILURE:', e); }
|
||||
}
|
||||
setup();
|
||||
`;
|
||||
const tempFile = path.join(process.cwd(), "temp-bootstrap-feedback.ts");
|
||||
await fs.writeFile(tempFile, bootstrapScript);
|
||||
try {
|
||||
execSync("npx tsx --env-file=.env " + tempFile, { stdio: "inherit" });
|
||||
} finally {
|
||||
await fs.remove(tempFile);
|
||||
}
|
||||
});
|
||||
|
||||
directus
|
||||
.command("sync <action> <env>")
|
||||
.description("Sync Directus data (push/pull) for a specific environment")
|
||||
@@ -87,7 +198,7 @@ program
|
||||
.action(async (projectPath) => {
|
||||
const fullPath = path.isAbsolute(projectPath)
|
||||
? projectPath
|
||||
: path.resolve(process.cwd(), "../../", projectPath);
|
||||
: path.resolve(process.cwd(), projectPath);
|
||||
const projectName = path.basename(fullPath);
|
||||
|
||||
console.log(chalk.blue(`Initializing new project: ${projectName}...`));
|
||||
@@ -120,10 +231,11 @@ program
|
||||
"pagespeed:test": "mintel pagespeed",
|
||||
},
|
||||
dependencies: {
|
||||
next: "15.1.6",
|
||||
next: "16.1.6",
|
||||
react: "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"@mintel/next-observability": "workspace:*",
|
||||
"@directus/sdk": "^21.0.0",
|
||||
},
|
||||
devDependencies: {
|
||||
@@ -262,11 +374,15 @@ export default createMintelI18nRequestConfig(
|
||||
// Create instrumentation.ts
|
||||
await fs.writeFile(
|
||||
path.join(fullPath, "src/instrumentation.ts"),
|
||||
`import * as Sentry from '@sentry/nextjs';
|
||||
`import { Sentry } from '@mintel/next-observability';
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
// Server-side initialization
|
||||
await import('./sentry.server.config');
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === 'edge') {
|
||||
await import('./sentry.edge.config');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,8 +512,12 @@ DIRECTUS_DB_PASSWORD=mintel-db-pass
|
||||
SENTRY_DSN=
|
||||
|
||||
# Analytics (Umami)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# Notifications (Gotify)
|
||||
GOTIFY_URL=
|
||||
GOTIFY_TOKEN=
|
||||
`;
|
||||
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
|
||||
|
||||
|
||||
11
packages/cli/tsup.config.ts
Normal file
11
packages/cli/tsup.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
target: 'es2020',
|
||||
clean: true,
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node',
|
||||
},
|
||||
});
|
||||
43
packages/cloner-library/build.mjs
Normal file
43
packages/cloner-library/build.mjs
Normal 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);
|
||||
});
|
||||
30
packages/cloner-library/package.json
Normal file
30
packages/cloner-library/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@mintel/cloner",
|
||||
"version": "1.8.6",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
98
packages/cloner-library/src/AssetManager.ts
Normal file
98
packages/cloner-library/src/AssetManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
256
packages/cloner-library/src/PageCloner.ts
Normal file
256
packages/cloner-library/src/PageCloner.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
150
packages/cloner-library/src/WebsiteCloner.ts
Normal file
150
packages/cloner-library/src/WebsiteCloner.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
3
packages/cloner-library/src/index.ts
Normal file
3
packages/cloner-library/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./AssetManager.js";
|
||||
export * from "./PageCloner.js";
|
||||
export * from "./WebsiteCloner.js";
|
||||
17
packages/cloner-library/tsconfig.json
Normal file
17
packages/cloner-library/tsconfig.json
Normal 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/**/*"
|
||||
]
|
||||
}
|
||||
11
packages/cms-infra/Dockerfile
Normal file
11
packages/cms-infra/Dockerfile
Normal 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
|
||||
BIN
packages/cms-infra/database/data.db
Normal file
BIN
packages/cms-infra/database/data.db
Normal file
Binary file not shown.
53
packages/cms-infra/docker-compose.yml
Normal file
53
packages/cms-infra/docker-compose.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
services:
|
||||
infra-cms:
|
||||
image: directus/directus:11.15.2
|
||||
ports:
|
||||
- "8059:8055"
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
environment:
|
||||
KEY: "infra-cms-key"
|
||||
SECRET: "infra-cms-secret"
|
||||
ADMIN_EMAIL: "marc@mintel.me"
|
||||
ADMIN_PASSWORD: "Tim300493."
|
||||
DB_CLIENT: "sqlite3"
|
||||
DB_FILENAME: "/directus/database/data.db"
|
||||
WEBSOCKETS_ENABLED: "true"
|
||||
PUBLIC_URL: "http://cms.localhost"
|
||||
EMAIL_TRANSPORT: "smtp"
|
||||
EMAIL_SMTP_HOST: "smtp.eu.mailgun.org"
|
||||
EMAIL_SMTP_PORT: "587"
|
||||
EMAIL_SMTP_USER: "postmaster@mg.mintel.me"
|
||||
EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6"
|
||||
EMAIL_SMTP_SECURE: "false"
|
||||
EMAIL_FROM: "postmaster@mg.mintel.me"
|
||||
LOG_LEVEL: "debug"
|
||||
SERVE_APP: "true"
|
||||
EXTENSIONS_AUTO_RELOAD: "true"
|
||||
EXTENSIONS_SANDBOX: "false"
|
||||
CONTENT_SECURITY_POLICY: "false"
|
||||
volumes:
|
||||
- ./database:/directus/database
|
||||
- ./uploads:/directus/uploads
|
||||
- ./schema:/directus/schema
|
||||
- ./extensions:/directus/extensions
|
||||
healthcheck:
|
||||
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8055/server/health" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.at-mintel-infra-cms.rule: "Host(`cms.localhost`)"
|
||||
traefik.docker.network: "infra"
|
||||
caddy: "http://cms.localhost"
|
||||
caddy.reverse_proxy: "{{upstreams 8055}}"
|
||||
caddy.header.Cache-Control: "no-store, no-cache, must-revalidate, max-age=0"
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: at-mintel-cms-network
|
||||
infra:
|
||||
external: true
|
||||
18
packages/cms-infra/package.json
Normal file
18
packages/cms-infra/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@mintel/cms-infra",
|
||||
"version": "1.8.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm run up -- --link",
|
||||
"up": "../../scripts/cms-up.sh",
|
||||
"down": "docker compose down",
|
||||
"logs": "docker compose logs -f",
|
||||
"build:extensions": "../../scripts/sync-extensions.sh",
|
||||
"schema:apply:local": "../../scripts/cms-apply.sh local",
|
||||
"schema:apply:infra": "../../scripts/cms-apply.sh infra",
|
||||
"snapshot:local": "../../scripts/cms-snapshot.sh",
|
||||
"sync:push": "../../scripts/sync-directus.sh push infra",
|
||||
"sync:pull": "../../scripts/sync-directus.sh pull infra"
|
||||
}
|
||||
}
|
||||
1203
packages/cms-infra/schema/current.yaml
Normal file
1203
packages/cms-infra/schema/current.yaml
Normal file
File diff suppressed because it is too large
Load Diff
2046
packages/cms-infra/schema/current_v2.yaml
Normal file
2046
packages/cms-infra/schema/current_v2.yaml
Normal file
File diff suppressed because it is too large
Load Diff
2383
packages/cms-infra/schema/snapshot.yaml
Normal file
2383
packages/cms-infra/schema/snapshot.yaml
Normal file
File diff suppressed because it is too large
Load Diff
30
packages/company-manager/package.json
Normal file
30
packages/company-manager/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "company-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.6",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
14
packages/company-manager/src/index.ts
Normal file
14
packages/company-manager/src/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
});
|
||||
224
packages/company-manager/src/module.vue
Normal file
224
packages/company-manager/src/module.vue
Normal 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>
|
||||
48
packages/content-engine/examples/generate-post.ts
Normal file
48
packages/content-engine/examples/generate-post.ts
Normal 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();
|
||||
58
packages/content-engine/examples/optimize-post.ts
Normal file
58
packages/content-engine/examples/optimize-post.ts
Normal 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();
|
||||
132
packages/content-engine/examples/optimize-vendor-lockin.ts
Normal file
132
packages/content-engine/examples/optimize-vendor-lockin.ts
Normal 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();
|
||||
71
packages/content-engine/examples/optimize-with-components.ts
Normal file
71
packages/content-engine/examples/optimize-with-components.ts
Normal 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();
|
||||
34
packages/content-engine/package.json
Normal file
34
packages/content-engine/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@mintel/content-engine",
|
||||
"version": "1.8.6",
|
||||
"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 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"
|
||||
}
|
||||
}
|
||||
990
packages/content-engine/src/generator.ts
Normal file
990
packages/content-engine/src/generator.ts
Normal 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 3–4 component additions total
|
||||
- There MUST be at least 3–4 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
packages/content-engine/src/index.ts
Normal file
2
packages/content-engine/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./generator";
|
||||
export * from "./orchestrator";
|
||||
661
packages/content-engine/src/orchestrator.ts
Normal file
661
packages/content-engine/src/orchestrator.ts
Normal file
@@ -0,0 +1,661 @@
|
||||
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 }[];
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
private async generateVisualPrompt(content: 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) },
|
||||
],
|
||||
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),
|
||||
);
|
||||
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.
|
||||
- Füge ein sauberes TableOfContents ein.
|
||||
- Verwende unsere Komponenten stilvoll für Visualisierungen.
|
||||
- Agiere als hochprofessioneller Digital Architect und entferne alte MDX-Metadaten im Body.
|
||||
- Fazit: Schließe JEDEN Artikel ZWINGEND mit einem starken, klaren 'Fazit' ab.
|
||||
- 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;
|
||||
}
|
||||
}
|
||||
11
packages/content-engine/tsconfig.json
Normal file
11
packages/content-engine/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
30
packages/customer-manager/package.json
Normal file
30
packages/customer-manager/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "customer-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.6",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "customer manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/directus-extension-toolkit": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
14
packages/customer-manager/src/index.ts
Normal file
14
packages/customer-manager/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineModule } from '@directus/extensions-sdk';
|
||||
import ModuleComponent from './module.vue';
|
||||
|
||||
export default defineModule({
|
||||
id: 'customer-manager',
|
||||
name: 'Customer Manager',
|
||||
icon: 'supervisor_account',
|
||||
routes: [
|
||||
{
|
||||
path: '',
|
||||
component: ModuleComponent,
|
||||
},
|
||||
],
|
||||
});
|
||||
538
packages/customer-manager/src/module.vue
Normal file
538
packages/customer-manager/src/module.vue
Normal file
@@ -0,0 +1,538 @@
|
||||
<template>
|
||||
<MintelManagerLayout
|
||||
title="Customer Manager"
|
||||
:item-title="selectedItem?.company?.name || 'Kunde wählen'"
|
||||
:is-empty="!selectedItem"
|
||||
empty-title="Kunde auswählen"
|
||||
empty-icon="handshake"
|
||||
:notice="notice"
|
||||
@close-notice="notice = null"
|
||||
>
|
||||
<template #navigation>
|
||||
<v-list nav>
|
||||
<v-list-item @click="openCreateDrawer" clickable>
|
||||
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow text="Neuen Kunden verlinken" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-list-item
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:active="selectedItem?.id === item.id"
|
||||
class="nav-item"
|
||||
clickable
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<v-list-item-icon><v-icon name="business" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow :text="item.company?.name" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<template #subtitle>
|
||||
<template v-if="selectedItem">
|
||||
{{ clientUsers.length }} Portal-Nutzer · {{ selectedItem.company?.domain }}
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<v-button secondary rounded icon v-tooltip.bottom="'Kunden-Verlinkung bearbeiten'" @click="openEditDrawer">
|
||||
<v-icon name="edit" />
|
||||
</v-button>
|
||||
<div @click="onDebugClick" style="display: inline-block; border: 2px solid lime;">
|
||||
<v-button primary @click="openCreateClientUser">
|
||||
Portal-Nutzer hinzufügen
|
||||
</v-button>
|
||||
</div>
|
||||
<button style="background: red; color: white; padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="onDebugClick">DEBUG</button>
|
||||
<button style="background: blue; color: white; padding: 8px 16px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="openCreateClientUser">NATIVE: Portal-Nutzer</button>
|
||||
</template>
|
||||
|
||||
<template #empty-state>
|
||||
Wähle einen Kunden aus der Liste oder
|
||||
<v-button x-small @click="openCreateDrawer">verlinke eine neue Firma</v-button>.
|
||||
<button id="debug-click-test" style="background: red; color: white; padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="onDebugClick">DEBUG CLICK</button>
|
||||
</template>
|
||||
|
||||
<!-- Main Content: Client Users Table -->
|
||||
<v-table
|
||||
:headers="tableHeaders"
|
||||
:items="clientUsers"
|
||||
:loading="loading"
|
||||
class="clickable-table"
|
||||
fixed-header
|
||||
@click:row="onRowClick"
|
||||
>
|
||||
<template #[`item.name`]="{ item }">
|
||||
<div class="user-cell">
|
||||
<v-avatar :name="item.first_name" x-small />
|
||||
<span class="user-name">{{ item.first_name }} {{ item.last_name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`item.last_invited`]="{ item }">
|
||||
<span v-if="item.last_invited" class="status-date">
|
||||
{{ formatDate(item.last_invited) }}
|
||||
</span>
|
||||
<v-chip v-else x-small>Noch nie</v-chip>
|
||||
</template>
|
||||
</v-table>
|
||||
|
||||
<!-- Drawer: Customer (Link) Form -->
|
||||
<v-drawer
|
||||
v-model="drawerActive"
|
||||
:title="isEditing ? 'Kunden-Verlinkung bearbeiten' : 'Kunden verlinken'"
|
||||
icon="handshake"
|
||||
@cancel="drawerActive = false"
|
||||
>
|
||||
<div v-if="drawerActive" class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Organisation / Firma</span>
|
||||
<MintelSelect
|
||||
v-model="form.company"
|
||||
:items="companyOptions"
|
||||
placeholder="Firma auswählen..."
|
||||
allow-add
|
||||
@add="openQuickAdd('company')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Haupt-Ansprechpartner (optional)</span>
|
||||
<MintelSelect
|
||||
v-model="form.contact_person"
|
||||
:items="peopleOptions"
|
||||
placeholder="Person auswählen..."
|
||||
allow-add
|
||||
@add="openQuickAdd('person')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Status</span>
|
||||
<v-select
|
||||
v-model="form.status"
|
||||
:items="[
|
||||
{ text: 'Aktiv', value: 'active' },
|
||||
{ text: 'Inaktiv', value: 'inactive' }
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Notizen</span>
|
||||
<v-textarea v-model="form.notes" placeholder="Besonderheiten zu diesem Kunden..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="saving" @click="saveItem">Speichern</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
|
||||
<!-- Drawer: Client User Form -->
|
||||
<v-drawer
|
||||
v-model="drawerUserActive"
|
||||
:title="isEditingUser ? 'Portal-Nutzer bearbeiten' : 'Neuen Portal-Nutzer anlegen'"
|
||||
icon="person"
|
||||
@cancel="drawerUserActive = false"
|
||||
>
|
||||
<div v-if="drawerUserActive" class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Vorname</span>
|
||||
<v-input v-model="userForm.first_name" placeholder="Vorname" autofocus />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Nachname</span>
|
||||
<v-input v-model="userForm.last_name" placeholder="Nachname" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">E-Mail</span>
|
||||
<v-input v-model="userForm.email" placeholder="E-Mail Adresse" type="email" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Zentrale Person (Verknüpfung)</span>
|
||||
<v-select
|
||||
v-model="userForm.person"
|
||||
:items="peopleOptions"
|
||||
placeholder="Master-Person auswählen..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-divider v-if="isEditingUser" />
|
||||
|
||||
<div v-if="isEditingUser" class="field">
|
||||
<span class="label">Temporäres Passwort</span>
|
||||
<v-input v-model="userForm.temporary_password" readonly class="password-input" />
|
||||
<p class="field-note">Wird beim Senden der Zugangsdaten automatisch generiert.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="saving" @click="saveClientUser">Daten speichern</v-button>
|
||||
|
||||
<template v-if="isEditingUser">
|
||||
<v-divider />
|
||||
<v-button
|
||||
v-tooltip.bottom="'Generiert PW, speichert es und sendet E-Mail'"
|
||||
secondary
|
||||
block
|
||||
:loading="invitingId === userForm.id"
|
||||
@click="inviteUser(userForm)"
|
||||
>
|
||||
<v-icon name="send" left /> Zugangsdaten senden
|
||||
</v-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
|
||||
<!-- Drawer: Quick Add Company -->
|
||||
<v-drawer
|
||||
v-model="quickAddCompanyActive"
|
||||
title="Firma schnell anlegen"
|
||||
icon="business"
|
||||
@cancel="quickAddCompanyActive = false"
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Firmenname</span>
|
||||
<v-input v-model="quickCompanyForm.name" placeholder="z.B. Schmidt GmbH" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Domain / Website</span>
|
||||
<v-input v-model="quickCompanyForm.domain" placeholder="example.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="savingQuick" @click="saveQuickCompany">Firma speichern</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
|
||||
<!-- Drawer: Quick Add Person -->
|
||||
<v-drawer
|
||||
v-model="quickAddPersonActive"
|
||||
title="Person schnell anlegen"
|
||||
icon="person"
|
||||
@cancel="quickAddPersonActive = false"
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Vorname</span>
|
||||
<v-input v-model="quickPersonForm.first_name" placeholder="Vorname" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Nachname</span>
|
||||
<v-input v-model="quickPersonForm.last_name" placeholder="Nachname" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">E-Mail</span>
|
||||
<v-input v-model="quickPersonForm.email" placeholder="email@example.com" type="email" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="savingQuick" @click="saveQuickPerson">Person speichern</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
</MintelManagerLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, computed, watch } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
|
||||
|
||||
const api = useApi();
|
||||
const route = useRoute();
|
||||
|
||||
function onDebugClick() {
|
||||
console.log("=== [Customer Manager] DEBUG CLICK TRAPPED ===");
|
||||
alert("Interactivity OK!");
|
||||
}
|
||||
|
||||
const items = ref<any[]>([]);
|
||||
const selectedItem = ref<any>(null);
|
||||
const clientUsers = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const invitingId = ref<string | null>(null);
|
||||
const notice = ref<{ type: string; message: string } | null>(null);
|
||||
|
||||
const companies = ref<any[]>([]);
|
||||
const people = ref<any[]>([]);
|
||||
|
||||
const drawerActive = ref(false);
|
||||
const isEditing = ref(false);
|
||||
const form = ref({ id: null, company: null, contact_person: null, status: 'active', notes: '' });
|
||||
|
||||
const drawerUserActive = ref(false);
|
||||
const isEditingUser = ref(false);
|
||||
const userForm = ref({ id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' });
|
||||
|
||||
const quickAddCompanyActive = ref(false);
|
||||
const quickAddPersonActive = ref(false);
|
||||
const savingQuick = ref(false);
|
||||
const quickCompanyForm = ref({ name: '', domain: '' });
|
||||
const quickPersonForm = ref({ first_name: '', last_name: '', email: '' });
|
||||
|
||||
const tableHeaders = [
|
||||
{ text: 'Name', value: 'name', sortable: true },
|
||||
{ text: 'E-Mail', value: 'email', sortable: true },
|
||||
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
|
||||
];
|
||||
|
||||
const companyOptions = computed(() => companies.value.map(c => ({ text: c.name, value: c.id })));
|
||||
const peopleOptions = computed(() => people.value.map(p => ({ text: `${p.first_name} ${p.last_name} (${p.email})`, value: p.id })));
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [custResp, compResp, peopleResp] = await Promise.all([
|
||||
api.get('/items/customers', { params: { fields: ['*', 'company.*', 'contact_person.*'], sort: 'company.name' } }),
|
||||
api.get('/items/companies', { params: { sort: 'name' } }),
|
||||
api.get('/items/people', { params: { sort: 'last_name' } })
|
||||
]);
|
||||
items.value = custResp.data.data;
|
||||
companies.value = compResp.data.data;
|
||||
people.value = peopleResp.data.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectItem(item: any) {
|
||||
selectedItem.value = item;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await api.get('/items/client_users', {
|
||||
params: {
|
||||
filter: { company: { _eq: item.company.id } },
|
||||
fields: ['*', 'person.*'],
|
||||
sort: 'first_name',
|
||||
},
|
||||
});
|
||||
clientUsers.value = res.data.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
isEditing.value = false;
|
||||
form.value = { id: null, company: null, contact_person: null, status: 'active', notes: '' };
|
||||
drawerActive.value = true;
|
||||
}
|
||||
|
||||
function openEditDrawer() {
|
||||
if (!selectedItem.value) return;
|
||||
isEditing.value = true;
|
||||
form.value = {
|
||||
id: selectedItem.value.id,
|
||||
company: selectedItem.value.company?.id || selectedItem.value.company,
|
||||
contact_person: selectedItem.value.contact_person?.id || selectedItem.value.contact_person,
|
||||
status: selectedItem.value.status,
|
||||
notes: selectedItem.value.notes
|
||||
};
|
||||
drawerActive.value = true;
|
||||
}
|
||||
|
||||
async function saveItem() {
|
||||
if (!form.value.company) {
|
||||
notice.value = { type: 'danger', message: 'Bitte wählen Sie eine Firma aus.' };
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await api.patch(`/items/customers/${form.value.id}`, form.value);
|
||||
notice.value = { type: 'success', message: 'Kunde aktualisiert!' };
|
||||
} else {
|
||||
await api.post('/items/customers', form.value);
|
||||
notice.value = { type: 'success', message: 'Neuer Kunde verlinkt!' };
|
||||
}
|
||||
drawerActive.value = false;
|
||||
await fetchData();
|
||||
if (form.value.id) {
|
||||
const updated = items.value.find(i => i.id === form.value.id);
|
||||
if (updated) selectItem(updated);
|
||||
}
|
||||
} catch (e: any) {
|
||||
notice.value = {
|
||||
type: 'danger',
|
||||
message: e.response?.data?.errors?.[0]?.message || e.message || 'Speichern fehlgeschlagen'
|
||||
};
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Client User Actions
|
||||
function openCreateClientUser() {
|
||||
isEditingUser.value = false;
|
||||
userForm.value = { id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' };
|
||||
drawerUserActive.value = true;
|
||||
}
|
||||
|
||||
function onRowClick(event: any) {
|
||||
const item = event?.item || event;
|
||||
if (item && item.id) {
|
||||
userForm.value = {
|
||||
id: item.id,
|
||||
first_name: item.first_name,
|
||||
last_name: item.last_name,
|
||||
email: item.email,
|
||||
person: item.person?.id || item.person,
|
||||
temporary_password: item.temporary_password
|
||||
};
|
||||
isEditingUser.value = true;
|
||||
drawerUserActive.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveClientUser() {
|
||||
if (!userForm.value.email || !selectedItem.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
first_name: userForm.value.first_name,
|
||||
last_name: userForm.value.last_name,
|
||||
email: userForm.value.email,
|
||||
person: userForm.value.person,
|
||||
company: selectedItem.value.company.id
|
||||
};
|
||||
if (isEditingUser.value) {
|
||||
await api.patch(`/items/client_users/${userForm.value.id}`, payload);
|
||||
} else {
|
||||
await api.post('/items/client_users', payload);
|
||||
}
|
||||
drawerUserActive.value = false;
|
||||
await selectItem(selectedItem.value);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function inviteUser(user: any) {
|
||||
invitingId.value = user.id;
|
||||
try {
|
||||
await api.post(`/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501`, [user.id]);
|
||||
notice.value = { type: 'success', message: `Zugangsdaten versendet. 📧` };
|
||||
await selectItem(selectedItem.value);
|
||||
} finally {
|
||||
invitingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function openQuickAdd(type: 'company' | 'person') {
|
||||
if (type === 'company') {
|
||||
quickCompanyForm.value = { name: '', domain: '' };
|
||||
quickAddCompanyActive.value = true;
|
||||
} else {
|
||||
quickPersonForm.value = { first_name: '', last_name: '', email: '' };
|
||||
quickAddPersonActive.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveQuickCompany() {
|
||||
if (!quickCompanyForm.value.name) return;
|
||||
savingQuick.value = true;
|
||||
try {
|
||||
const res = await api.post('/items/companies', quickCompanyForm.value);
|
||||
await fetchData();
|
||||
form.value.company = res.data.data.id;
|
||||
quickAddCompanyActive.value = false;
|
||||
notice.value = { type: 'success', message: 'Firma angelegt und ausgewählt.' };
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: e.message };
|
||||
} finally {
|
||||
savingQuick.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveQuickPerson() {
|
||||
if (!quickPersonForm.value.first_name || !quickPersonForm.value.last_name) return;
|
||||
savingQuick.value = true;
|
||||
try {
|
||||
const res = await api.post('/items/people', quickPersonForm.value);
|
||||
await fetchData();
|
||||
form.value.contact_person = res.data.data.id;
|
||||
quickAddPersonActive.value = false;
|
||||
notice.value = { type: 'success', message: 'Person angelegt und ausgewählt.' };
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: e.message };
|
||||
} finally {
|
||||
savingQuick.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeepLink() {
|
||||
if (route.query.create === 'true') {
|
||||
// Only open if not already open to avoid resetting form if user is already typing
|
||||
if (!drawerActive.value) {
|
||||
openCreateDrawer();
|
||||
}
|
||||
|
||||
if (route.query.company) {
|
||||
form.value.company = route.query.company as any;
|
||||
}
|
||||
if (route.query.contact_person) {
|
||||
form.value.contact_person = route.query.contact_person as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.query.create, (newVal) => {
|
||||
if (newVal === 'true') handleDeepLink();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
handleDeepLink();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.user-name { font-weight: 600; }
|
||||
.status-date { font-size: 12px; color: var(--theme--foreground-subdued); }
|
||||
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
|
||||
.form-section { display: flex; flex-direction: column; gap: 20px; }
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
|
||||
.field-note { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px; }
|
||||
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.password-input :deep(textarea) {
|
||||
font-family: var(--family-monospace);
|
||||
font-weight: 800;
|
||||
color: var(--theme--primary) !important;
|
||||
background: var(--theme--background-subdued) !important;
|
||||
}
|
||||
|
||||
.clickable-table :deep(tbody tr) { cursor: pointer; transition: background-color 0.2s ease; }
|
||||
.clickable-table :deep(tbody tr:hover) { background-color: var(--theme--background-subdued) !important; }
|
||||
</style>
|
||||
31
packages/directus-extension-toolkit/package.json
Normal file
31
packages/directus-extension-toolkit/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@mintel/directus-extension-toolkit",
|
||||
"version": "1.8.6",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
102
packages/directus-extension-toolkit/src/MintelManagerLayout.vue
Normal file
102
packages/directus-extension-toolkit/src/MintelManagerLayout.vue
Normal 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>
|
||||
62
packages/directus-extension-toolkit/src/MintelSelect.vue
Normal file
62
packages/directus-extension-toolkit/src/MintelSelect.vue
Normal 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>
|
||||
84
packages/directus-extension-toolkit/src/MintelStatCard.vue
Normal file
84
packages/directus-extension-toolkit/src/MintelStatCard.vue
Normal 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>
|
||||
3
packages/directus-extension-toolkit/src/index.ts
Normal file
3
packages/directus-extension-toolkit/src/index.ts
Normal 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';
|
||||
24
packages/directus-extension-toolkit/vite.config.ts
Normal file
24
packages/directus-extension-toolkit/vite.config.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,15 +1,33 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import globals from "globals";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "**/.next/**"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
...globals.es2021,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "**/.next/**", "**/build/**"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
"no-unused-vars": "warn",
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"no-console": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
|
||||
@@ -1,40 +1,38 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
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";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
export const nextConfig = [
|
||||
{
|
||||
ignores: [
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/out/**",
|
||||
"**/coverage/**",
|
||||
"**/.next/**",
|
||||
"**/node_modules/**",
|
||||
"**/.gitea/**",
|
||||
"**/.changeset/**",
|
||||
"**/.vercel/**",
|
||||
],
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"prefer-const": "warn",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"@next/next/no-img-element": "warn",
|
||||
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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/eslint-config",
|
||||
"version": "1.0.1",
|
||||
"version": "1.8.6",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
@@ -20,7 +20,10 @@
|
||||
"dependencies": {
|
||||
"@eslint/eslintrc": "^3.0.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"eslint-config-next": "15.1.6",
|
||||
"@next/eslint-plugin-next": "16.1.6",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"typescript-eslint": "^8.54.0"
|
||||
}
|
||||
}
|
||||
|
||||
27
packages/feedback-commander/package.json
Normal file
27
packages/feedback-commander/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "feedback-commander",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.6",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "feedback commander"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
14
packages/feedback-commander/src/index.ts
Normal file
14
packages/feedback-commander/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineModule } from '@directus/extensions-sdk';
|
||||
import ModuleComponent from './module.vue';
|
||||
|
||||
export default defineModule({
|
||||
id: 'feedback-commander',
|
||||
name: 'Feedback Commander',
|
||||
icon: 'view_kanban',
|
||||
routes: [
|
||||
{
|
||||
path: '',
|
||||
component: ModuleComponent,
|
||||
},
|
||||
],
|
||||
});
|
||||
731
packages/feedback-commander/src/module.vue
Normal file
731
packages/feedback-commander/src/module.vue
Normal file
@@ -0,0 +1,731 @@
|
||||
<template>
|
||||
<private-view title="Feedback Commander">
|
||||
<template #headline>
|
||||
<v-breadcrumb :items="[{ name: 'Feedback', to: '/feedback-commander' }]" />
|
||||
</template>
|
||||
|
||||
<template #title-outer:after>
|
||||
<v-chip v-if="loading" label color="blue" small>Loading...</v-chip>
|
||||
<v-chip v-else-if="fetchError" label color="red" small>Fetch Error</v-chip>
|
||||
<v-chip v-else label color="green" small>{{ items.length }} Items</v-chip>
|
||||
</template>
|
||||
|
||||
<template #navigation>
|
||||
<div class="sidebar-header">
|
||||
<v-text-overflow text="Websites" class="header-text" />
|
||||
</div>
|
||||
<v-list nav>
|
||||
<v-list-item
|
||||
:active="currentProject === 'all'"
|
||||
@click="currentProject = 'all'"
|
||||
clickable
|
||||
>
|
||||
<v-list-item-icon><v-icon name="language" /></v-list-item-icon>
|
||||
<v-list-item-content><v-text-overflow text="All Projects" /></v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
v-for="project in projects"
|
||||
:key="project"
|
||||
:active="currentProject === project"
|
||||
@click="currentProject = project"
|
||||
clickable
|
||||
>
|
||||
<v-list-item-icon><v-icon name="public" color="var(--primary)" /></v-list-item-icon>
|
||||
<v-list-item-content><v-text-overflow :text="project || 'Unknown'" /></v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<div class="feedback-container">
|
||||
<div v-if="!items.length && !loading && !fetchError" class="empty-state">
|
||||
<v-info icon="inbox" title="Clean Inbox" center>
|
||||
All feedback has been processed. Great job!
|
||||
</v-info>
|
||||
</div>
|
||||
|
||||
<div v-if="fetchError" class="empty-state">
|
||||
<v-info icon="error" title="Fetch Failed" :description="fetchError" center />
|
||||
<v-button @click="fetchData" secondary small>Retry</v-button>
|
||||
</div>
|
||||
|
||||
<div class="operational-layout" v-else-if="items.length">
|
||||
<!-- Detailed Triage Lane -->
|
||||
<aside class="triage-lane">
|
||||
<div class="lane-header">
|
||||
<v-select
|
||||
v-model="currentStatusFilter"
|
||||
:items="statusOptions"
|
||||
small
|
||||
placeholder="Status Filter"
|
||||
/>
|
||||
</div>
|
||||
<div class="lane-content scrollbar">
|
||||
<TransitionGroup name="list">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
class="feedback-card"
|
||||
:class="{ active: selectedItem?.id === item.id }"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<div class="card-status-bar" :style="{ background: getStatusColor(item.status || 'open') }"></div>
|
||||
<div class="card-body">
|
||||
<header class="card-header">
|
||||
<span class="card-user">{{ item.user_name }}</span>
|
||||
<span class="card-date">{{ formatDate(item.date_created || item.id) }}</span>
|
||||
</header>
|
||||
<div class="card-text">{{ item.text }}</div>
|
||||
<footer class="card-footer">
|
||||
<div class="meta-tags">
|
||||
<v-chip x-small outline>{{ item.company?.name || item.project }}</v-chip>
|
||||
<v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small />
|
||||
</div>
|
||||
<v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small />
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Elaborated Master-Detail Desk -->
|
||||
<main class="processing-desk scrollbar">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="selectedItem" :key="selectedItem.id" class="desk-content">
|
||||
<header class="desk-header">
|
||||
<div class="headline-group">
|
||||
<div class="status-indicator">
|
||||
<div class="status-dot" :style="{ background: getStatusColor(selectedItem.status || 'open') }"></div>
|
||||
<span class="status-text">{{ capitalize(selectedItem.status || 'open') }}</span>
|
||||
</div>
|
||||
<h2>{{ selectedItem.user_name }}'s Submission</h2>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<v-button primary @click="openDeepLink(selectedItem)">
|
||||
<v-icon name="open_in_new" left /> Open & Highlight
|
||||
</v-button>
|
||||
<v-select
|
||||
v-model="selectedItem.status"
|
||||
:items="statuses"
|
||||
inline
|
||||
@update:model-value="updateStatus"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="desk-grid">
|
||||
<!-- Message Container -->
|
||||
<div class="main-column">
|
||||
<v-card class="content-card">
|
||||
<v-card-title>
|
||||
<v-icon name="format_quote" left />
|
||||
Feedback Content
|
||||
</v-card-title>
|
||||
<v-card-text class="feedback-body">
|
||||
<div v-if="selectedItem.screenshot" class="visual-proof">
|
||||
<label class="proof-label"><v-icon name="photo" x-small /> Element Snapshot</label>
|
||||
<img :src="getAssetUrl(selectedItem.screenshot)" class="screenshot-img" />
|
||||
</div>
|
||||
<div class="main-text">{{ selectedItem.text }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<section class="reply-section">
|
||||
<div class="section-divider">
|
||||
<v-divider />
|
||||
<span class="divider-label">Internal Communication</span>
|
||||
<v-divider />
|
||||
</div>
|
||||
|
||||
<div class="thread">
|
||||
<TransitionGroup name="thread-list">
|
||||
<div v-for="reply in comments" :key="reply.id" class="reply-bubble">
|
||||
<header class="reply-header">
|
||||
<span class="reply-user">{{ reply.user_name || 'System' }}</span>
|
||||
<span v-if="reply.person" class="reply-person">({{ reply.person.first_name }} {{ reply.person.last_name }})</span>
|
||||
<span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span>
|
||||
</header>
|
||||
<div class="reply-text">{{ reply.text }}</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
<div v-if="!comments.length" class="empty-state-mini">
|
||||
<v-icon name="auto_awesome" small /> No replies yet. Start the thread.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
<v-textarea v-model="replyText" placeholder="Compose internal response..." auto-grow />
|
||||
<div class="composer-actions">
|
||||
<v-button secondary :loading="sending" @click="sendReply">Post Reply</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Technical Sidebar -->
|
||||
<aside class="meta-column">
|
||||
<v-card class="meta-card">
|
||||
<v-card-title>Context</v-card-title>
|
||||
<v-card-text class="meta-list">
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="business" x-small /> Organisation / Firma</label>
|
||||
<strong>{{ selectedItem.company?.name || selectedItem.project }}</strong>
|
||||
</div>
|
||||
<div v-if="selectedItem.person" class="meta-item">
|
||||
<label><v-icon name="person" x-small /> Zentrale Person</label>
|
||||
<strong>{{ selectedItem.person.first_name }} {{ selectedItem.person.last_name }}</strong>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="link" x-small /> Source Path</label>
|
||||
<span class="truncate-path" :title="selectedItem.url">{{ formatUrl(selectedItem.url) }}</span>
|
||||
<v-button icon small @click="openExternal(selectedItem.url)"><v-icon name="launch" /></v-button>
|
||||
</div>
|
||||
<v-divider />
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="layers" x-small /> Element Trace</label>
|
||||
<code class="trace-code">{{ selectedItem.selector || 'Body' }}</code>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="location_searching" x-small /> Precise Mark</label>
|
||||
<span class="coords">X: {{ Math.round(selectedItem.x) }}px / Y: {{ Math.round(selectedItem.y) }}px</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="fingerprint" x-small /> Reference ID</label>
|
||||
<code class="id-code">{{ selectedItem.id }}</code>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<div class="help-box">
|
||||
<v-icon name="help_outline" x-small />
|
||||
<span>Click "Open & Highlight" to jump directly to this element on the live site.</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-selection-desk">
|
||||
<v-info icon="touch_app" title="Select Feedback" center>
|
||||
Choose an entry from the triage list to view details and process.
|
||||
</v-info>
|
||||
</div>
|
||||
</Transition>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const api = useApi();
|
||||
const items = ref([]);
|
||||
const comments = ref([]);
|
||||
const loading = ref(true);
|
||||
const fetchError = ref(null);
|
||||
const sending = ref(false);
|
||||
const selectedItem = ref(null);
|
||||
const currentProject = ref('all');
|
||||
const currentStatusFilter = ref('open');
|
||||
const replyText = ref('');
|
||||
|
||||
const statuses = [
|
||||
{ text: 'Open', value: 'open', icon: 'warning', color: '#E91E63' },
|
||||
{ text: 'In Progress', value: 'in_progress', icon: 'play_arrow', color: '#2196F3' },
|
||||
{ text: 'Resolved', value: 'resolved', icon: 'check_circle', color: '#4CAF50' }
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ text: 'All Statuses', value: 'all' },
|
||||
...statuses
|
||||
];
|
||||
|
||||
const projects = computed(() => {
|
||||
const projSet = new Set(items.value.map(i => i.company?.name || i.project).filter(Boolean));
|
||||
return Array.from(projSet).sort();
|
||||
});
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
return items.value.filter(item => {
|
||||
const projectName = item.company?.name || item.project;
|
||||
const matchProject = currentProject.value === 'all' || projectName === currentProject.value;
|
||||
const status = item.status || 'open';
|
||||
const matchStatus = currentStatusFilter.value === 'all' || status === currentStatusFilter.value;
|
||||
return matchProject && matchStatus;
|
||||
});
|
||||
});
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true;
|
||||
fetchError.value = null;
|
||||
try {
|
||||
const response = await api.get('/items/visual_feedback', {
|
||||
params: {
|
||||
sort: '-date_created,-id',
|
||||
limit: 300,
|
||||
fields: ['*', 'company.*', 'person.*']
|
||||
}
|
||||
});
|
||||
items.value = response.data.data;
|
||||
} catch (e: any) {
|
||||
fetchError.value = e.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectItem(item) {
|
||||
selectedItem.value = null;
|
||||
setTimeout(async () => {
|
||||
selectedItem.value = item;
|
||||
comments.value = [];
|
||||
try {
|
||||
const response = await api.get('/items/visual_feedback_comments', {
|
||||
params: {
|
||||
filter: { feedback_id: { _eq: item.id } },
|
||||
sort: '-date_created,-id',
|
||||
fields: ['*', 'person.*']
|
||||
}
|
||||
});
|
||||
comments.value = response.data.data;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
async function updateStatus(val) {
|
||||
if (!selectedItem.value) return;
|
||||
try {
|
||||
await api.patch(`/items/visual_feedback/${selectedItem.value.id}`, {
|
||||
status: val
|
||||
});
|
||||
fetchData();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReply() {
|
||||
if (!replyText.value.trim() || !selectedItem.value) return;
|
||||
sending.value = true;
|
||||
try {
|
||||
const response = await api.post('/items/visual_feedback_comments', {
|
||||
feedback_id: selectedItem.value.id,
|
||||
user_name: 'Operator',
|
||||
text: replyText.value
|
||||
});
|
||||
comments.value.unshift(response.data.data);
|
||||
replyText.value = '';
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr || typeof dateStr === 'number') return 'Legacy';
|
||||
return new Date(dateStr).toLocaleDateString() + ' ' + new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatUrl(url) {
|
||||
if (!url) return '';
|
||||
return url.replace(/^https?:\/\//, '');
|
||||
}
|
||||
|
||||
function capitalize(s) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ');
|
||||
}
|
||||
|
||||
function getDeepLinkUrl(item) {
|
||||
if (!item || !item.url) return '';
|
||||
try {
|
||||
const url = new URL(item.url);
|
||||
url.searchParams.set('fb_id', item.id);
|
||||
return url.toString();
|
||||
} catch (e) {
|
||||
return item.url + '?fb_id=' + item.id;
|
||||
}
|
||||
}
|
||||
|
||||
function openDeepLink(item) {
|
||||
const url = getDeepLinkUrl(item);
|
||||
if (url) window.open(url, '_blank');
|
||||
}
|
||||
|
||||
function openExternal(url) {
|
||||
if (url) window.open(url, '_blank');
|
||||
}
|
||||
|
||||
function getAssetUrl(id) {
|
||||
if (!id) return '';
|
||||
return `/assets/${id}`;
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
const s = statuses.find(st => st.value === status);
|
||||
return s ? s.color : 'var(--foreground-subdued)';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feedback-container {
|
||||
height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-subdued);
|
||||
}
|
||||
|
||||
.operational-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Triage Lane Polish */
|
||||
.triage-lane {
|
||||
width: 360px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-normal);
|
||||
border-right: 1px solid var(--border-normal);
|
||||
box-shadow: 2px 0 8px rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
.lane-header {
|
||||
padding: 16px;
|
||||
background: var(--background-normal);
|
||||
border-bottom: 1px solid var(--border-normal);
|
||||
}
|
||||
|
||||
.lane-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feedback-card {
|
||||
background: var(--background-normal);
|
||||
border: 1px solid var(--border-subdued);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.feedback-card:hover {
|
||||
border-color: var(--border-normal);
|
||||
background: var(--background-subdued);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.feedback-card.active {
|
||||
border-color: var(--primary);
|
||||
background: var(--background-accent);
|
||||
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.card-status-bar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.card-user { font-weight: bold; color: var(--foreground-normal); }
|
||||
.card-date { color: var(--foreground-subdued); }
|
||||
|
||||
.card-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--foreground-normal);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Processing Desk Refinement */
|
||||
.processing-desk {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.desk-content {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.desk-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 32px;
|
||||
border-bottom: 2px solid var(--border-normal);
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.headline-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-text { letter-spacing: 0.5px; }
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.desk-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feedback-body {
|
||||
font-size: 18px;
|
||||
line-height: 1.6;
|
||||
padding: 24px;
|
||||
color: var(--foreground-normal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.visual-proof {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.proof-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
color: var(--foreground-subdued);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.screenshot-img {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-normal);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
background: var(--background-subdued);
|
||||
}
|
||||
|
||||
.main-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.reply-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.divider-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
color: var(--foreground-subdued);
|
||||
white-space: nowrap;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.reply-bubble {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--background-normal);
|
||||
border: 1px solid var(--border-subdued);
|
||||
}
|
||||
|
||||
.reply-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.reply-user { font-weight: 800; color: var(--primary); }
|
||||
.reply-date { color: var(--foreground-subdued); }
|
||||
|
||||
.reply-text { font-size: 14px; line-height: 1.5; }
|
||||
|
||||
.composer {
|
||||
background: var(--background-normal);
|
||||
border: 1px solid var(--border-normal);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.meta-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.meta-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meta-item label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
color: var(--foreground-subdued);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.truncate-path {
|
||||
color: var(--primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trace-code, .id-code {
|
||||
background: var(--background-subdued);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.coords { font-weight: bold; font-family: var(--family-monospace); }
|
||||
|
||||
.help-box {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: rgba(var(--primary-rgb), 0.05);
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.no-selection-desk {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state-mini {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 12px;
|
||||
color: var(--foreground-subdued);
|
||||
background: var(--background-subdued);
|
||||
border-radius: 12px;
|
||||
border: 1px dashed var(--border-normal);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.list-enter-active, .list-leave-active { transition: all 0.3s ease; }
|
||||
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(-20px); }
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
||||
.fade-enter-from { opacity: 0; transform: translateY(10px); }
|
||||
.fade-leave-to { opacity: 0; transform: translateY(-10px); }
|
||||
|
||||
.thread-list-enter-active { transition: all 0.4s ease; transform-origin: top; }
|
||||
.thread-list-enter-from { opacity: 0; transform: scaleY(0.9); }
|
||||
|
||||
.scrollbar::-webkit-scrollbar { width: 6px; }
|
||||
.scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.scrollbar::-webkit-scrollbar-thumb { background: var(--border-subdued); border-radius: 3px; }
|
||||
.scrollbar::-webkit-scrollbar-thumb:hover { background: var(--border-normal); }
|
||||
</style>
|
||||
15
packages/gatekeeper/dev-output.log
Normal file
15
packages/gatekeeper/dev-output.log
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
> @mintel/gatekeeper@1.0.0 dev
|
||||
> next dev
|
||||
|
||||
⚠ Port 3000 is in use, trying 3001 instead.
|
||||
▲ Next.js 15.1.6
|
||||
- Local: http://localhost:3001
|
||||
- Network: http://192.168.1.126:3001
|
||||
- Experiments (use with caution):
|
||||
· clientTraceMetadata
|
||||
|
||||
✓ Starting...
|
||||
warn - It seems like you don't have a global error handler set up. It is recommended that you add a global-error.js file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)
|
||||
✓ Ready in 2.7s
|
||||
[?25h
|
||||
@@ -2,7 +2,7 @@ import mintelNextConfig from "@mintel/next-config";
|
||||
import { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Gatekeeper specific overrides
|
||||
basePath: '/gatekeeper',
|
||||
};
|
||||
|
||||
export default mintelNextConfig(nextConfig);
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"name": "@mintel/gatekeeper",
|
||||
"version": "1.0.0",
|
||||
"version": "1.8.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.1.6",
|
||||
"next": "16.1.6",
|
||||
"next-intl": "^4.8.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -24,6 +24,7 @@
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/next-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
6
packages/gatekeeper/postcss.config.cjs
Normal file
6
packages/gatekeeper/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
12
packages/gatekeeper/public/icon-white.svg
Normal file
12
packages/gatekeeper/public/icon-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 29 KiB |
12
packages/gatekeeper/public/logo-black.svg
Normal file
12
packages/gatekeeper/public/logo-black.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
12
packages/gatekeeper/public/logo-white.svg
Normal file
12
packages/gatekeeper/public/logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
@@ -9,17 +9,83 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const session = cookieStore.get(authCookieName);
|
||||
|
||||
if (session?.value === password) {
|
||||
return new NextResponse("OK", { status: 200 });
|
||||
}
|
||||
|
||||
// Traefik ForwardAuth headers
|
||||
// 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";
|
||||
|
||||
const loginUrl = `${proto}://${host}/gatekeeper/login?redirect=${encodeURIComponent(originalUrl)}`;
|
||||
try {
|
||||
const url = new URL(originalUrl, `${proto}://${host}`);
|
||||
if (url.searchParams.get("gk_bypass") === password) {
|
||||
// Remove the bypass parameter from the redirect URL
|
||||
url.searchParams.delete("gk_bypass");
|
||||
const cleanUrl = url.pathname + url.search;
|
||||
const absoluteCleanUrl = `${proto}://${host}${cleanUrl}`;
|
||||
|
||||
const response = NextResponse.redirect(absoluteCleanUrl);
|
||||
|
||||
// Set the session cookie so the bypass is persistent
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||
const sessionValue = JSON.stringify({
|
||||
identity: "Bypass",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
response.cookies.set(authCookieName, sessionValue, {
|
||||
httpOnly: true,
|
||||
secure: !isDev,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
sameSite: "lax",
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
} catch (_e) {
|
||||
// URL parsing failed, proceed with normal logic
|
||||
}
|
||||
|
||||
let isAuthenticated = false;
|
||||
let identity = "Guest";
|
||||
|
||||
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) {
|
||||
console.log(`[Verify] JSON Parse failed for cookie: ${session.value.substring(0, 10)}...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return new NextResponse("OK", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"X-Auth-User": identity,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Traefik ForwardAuth headers
|
||||
const gatekeeperUrl =
|
||||
process.env.NEXT_PUBLIC_BASE_URL || `${proto}://gatekeeper.${host}`;
|
||||
const absoluteOriginalUrl = `${proto}://${host}${originalUrl}`;
|
||||
|
||||
const loginUrl = `${gatekeeperUrl}/login?redirect=${encodeURIComponent(absoluteOriginalUrl)}`;
|
||||
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
29
packages/gatekeeper/src/app/api/whoami/route.ts
Normal file
29
packages/gatekeeper/src/app/api/whoami/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function GET(_req: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
const authCookieName =
|
||||
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
||||
const session = cookieStore.get(authCookieName);
|
||||
|
||||
if (!session?.value) {
|
||||
return NextResponse.json({ authenticated: false }, { status: 401 });
|
||||
}
|
||||
|
||||
let identity = "Guest";
|
||||
let company = null;
|
||||
try {
|
||||
const payload = JSON.parse(session.value);
|
||||
identity = payload.identity || "Guest";
|
||||
company = payload.company || null;
|
||||
} catch (_e) {
|
||||
// Old format probably just the password
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
authenticated: true,
|
||||
identity: identity,
|
||||
company: company,
|
||||
});
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Lock, ShieldCheck, ArrowRight } from "lucide-react";
|
||||
|
||||
interface LoginPageProps {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const params = await searchParams;
|
||||
const redirectUrl = (params.redirect as string) || "/";
|
||||
const error = params.error === "1";
|
||||
|
||||
const projectName = process.env.PROJECT_NAME || "Mintel";
|
||||
const projectColor = process.env.PROJECT_COLOR || "#82ed20";
|
||||
|
||||
async function login(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const password = formData.get("password");
|
||||
const expectedPassword = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||
const authCookieName =
|
||||
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
||||
const targetRedirect = formData.get("redirect") as string;
|
||||
|
||||
if (password === expectedPassword) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(authCookieName, expectedPassword, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
sameSite: "lax",
|
||||
});
|
||||
redirect(targetRedirect);
|
||||
} else {
|
||||
redirect(
|
||||
`/gatekeeper/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-mintel-dark">
|
||||
{/* Background Decor */}
|
||||
<div className="absolute inset-0 bg-grid pointer-events-none opacity-20" />
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-30"
|
||||
style={{
|
||||
background: `radial-gradient(circle at center, ${projectColor}11 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-full max-w-md px-6 animate-in fade-in zoom-in duration-700">
|
||||
{/* Logo / Icon */}
|
||||
<div className="flex justify-center mb-12">
|
||||
<div
|
||||
className="w-20 h-20 rounded-3xl flex items-center justify-center border border-white/10 bg-white/5 backdrop-blur-xl shadow-2xl"
|
||||
style={{ borderBottom: `2px solid ${projectColor}44` }}
|
||||
>
|
||||
<ShieldCheck
|
||||
className="w-10 h-10"
|
||||
style={{ color: projectColor }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.03] backdrop-blur-3xl border border-white/10 p-10 rounded-[48px] shadow-2xl relative overflow-hidden group">
|
||||
{/* Subtle accent line */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-1 opacity-50"
|
||||
style={{
|
||||
background: `linear-gradient(to right, transparent, ${projectColor}, transparent)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mb-10 text-center">
|
||||
<h1 className="text-3xl font-black mb-3 tracking-tighter uppercase italic flex items-center justify-center gap-2">
|
||||
{projectName.split(" ")[0]}
|
||||
<span style={{ color: projectColor }}>GATEKEEPER</span>
|
||||
</h1>
|
||||
<p className="text-white/40 text-sm font-medium">
|
||||
Restricted Infrastructure Access
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-200 p-4 rounded-2xl mb-8 text-sm flex items-center gap-3 animate-pulse">
|
||||
<Lock className="w-5 h-5 flex-shrink-0" />
|
||||
<span>Invalid access password. Please try again.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={login} className="space-y-8">
|
||||
<input type="hidden" name="redirect" value={redirectUrl} />
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="block text-[10px] font-black uppercase tracking-[0.3em] text-white/20 ml-5">
|
||||
Authentication Code
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoFocus
|
||||
className="w-full bg-white/5 border border-white/10 rounded-3xl px-8 py-6 focus:outline-none focus:ring-2 transition-all text-xl tracking-[0.5em] text-center placeholder:tracking-normal placeholder:text-white/10"
|
||||
style={{ "--tw-ring-color": `${projectColor}33` } as any}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full font-black uppercase tracking-[0.2em] py-6 rounded-3xl transition-all active:scale-[0.98] flex items-center justify-center gap-3 shadow-lg hover:shadow-mintel-green/10"
|
||||
style={{ backgroundColor: projectColor, color: "#000" }}
|
||||
>
|
||||
Verify Identity
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-[10px] font-bold text-white/10 uppercase tracking-[0.5em]">
|
||||
© 2026 {projectName} Infrastructure
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,83 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #000c1f;
|
||||
--foreground: #ffffff;
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-slate-800 font-serif antialiased selection:bg-slate-900 selection:text-white;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-sans font-bold text-slate-900 tracking-tighter;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply mb-4 text-base leading-relaxed text-slate-700;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-slate-900 hover:text-slate-700 transition-colors no-underline;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
min-height: 100vh;
|
||||
@layer components {
|
||||
.narrow-container {
|
||||
@apply max-w-4xl mx-auto px-6 py-10;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-6 py-3 border border-slate-200 bg-white text-slate-600 font-sans font-bold text-sm uppercase tracking-widest rounded-full transition-all duration-500 ease-industrial hover:border-slate-400 hover:text-slate-900 hover:bg-slate-50 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-slate-100 active:translate-y-0 active:shadow-sm;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply border-slate-900 text-slate-900 hover:bg-slate-900 hover:text-white;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.2s ease-in-out 0s 2;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, Newsreader } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
const newsreader = Newsreader({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-newsreader",
|
||||
style: "italic",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Gatekeeper | Access Control",
|
||||
description: "Mintel Infrastructure Protection",
|
||||
@@ -12,7 +21,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className={`${inter.variable} ${newsreader.variable}`}>
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
248
packages/gatekeeper/src/app/login/page.tsx
Normal file
248
packages/gatekeeper/src/app/login/page.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ArrowRight, ShieldCheck } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
interface LoginPageProps {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const params = await searchParams;
|
||||
const redirectUrl = (params.redirect as string) || "/";
|
||||
const error = params.error === "1";
|
||||
|
||||
const projectName = process.env.PROJECT_NAME || "Mintel";
|
||||
|
||||
async function login(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
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;
|
||||
const adminPassword = process.env.DIRECTUS_ADMIN_PASSWORD;
|
||||
const authCookieName =
|
||||
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
||||
const targetRedirect = formData.get("redirect") as string;
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||
|
||||
let userIdentity = "";
|
||||
let userCompany: any = null;
|
||||
|
||||
// 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.trim() &&
|
||||
password === adminPassword.trim()
|
||||
) {
|
||||
userIdentity = "Admin";
|
||||
}
|
||||
// 3. Check Lightweight Client Users (dedicated collection)
|
||||
if (email && password && process.env.INFRA_DIRECTUS_URL) {
|
||||
try {
|
||||
const clientUsersRes = await fetch(
|
||||
`${process.env.INFRA_DIRECTUS_URL}/items/client_users?filter[email][_eq]=${encodeURIComponent(
|
||||
email,
|
||||
)}&fields=*,company.*`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.INFRA_DIRECTUS_TOKEN}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (clientUsersRes.ok) {
|
||||
const { data: users } = await clientUsersRes.json();
|
||||
const clientUser = users[0];
|
||||
|
||||
// ⚠️ NOTE: Plain text check for demo/dev, should use argon2 in production
|
||||
if (
|
||||
clientUser &&
|
||||
(clientUser.password === password ||
|
||||
clientUser.temporary_password === password)
|
||||
) {
|
||||
userIdentity = clientUser.first_name || clientUser.email;
|
||||
userCompany = {
|
||||
id: clientUser.company?.id,
|
||||
name: clientUser.company?.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Client User Auth Error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback to Directus Staff Auth if still not identified
|
||||
if (!userIdentity && email && password && process.env.DIRECTUS_URL) {
|
||||
try {
|
||||
const loginRes = await fetch(`${process.env.DIRECTUS_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (loginRes.ok) {
|
||||
const { data } = await loginRes.json();
|
||||
const accessToken = data.access_token;
|
||||
|
||||
// Fetch user info with company depth
|
||||
const userRes = await fetch(
|
||||
`${process.env.DIRECTUS_URL}/users/me?fields=*,company.*`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (userRes.ok) {
|
||||
const { data: user } = await userRes.json();
|
||||
userIdentity = user.first_name || user.email;
|
||||
userCompany = {
|
||||
id: user.company?.id,
|
||||
name: user.company?.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Directus Auth Error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
identity: userIdentity,
|
||||
company: userCompany,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
console.log(`[Login] Setting Cookie: ${authCookieName} | Domain: ${cookieDomain || "Default"}`);
|
||||
|
||||
cookieStore.set(authCookieName, sessionValue, {
|
||||
httpOnly: true,
|
||||
secure: !isDev,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
sameSite: "lax",
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
redirect(targetRedirect);
|
||||
} else {
|
||||
console.log(`[Login] Failed for inputs. Redirecting back with error.`);
|
||||
redirect(`/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative bg-white font-serif antialiased overflow-hidden">
|
||||
{/* Background Decor - Signature mintel.me style */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-[0.03] scale-[1.01]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, #000 1px, transparent 1px), linear-gradient(to bottom, #000 1px, transparent 1px)`,
|
||||
backgroundSize: "clamp(30px, 8vw, 40px) clamp(30px, 8vw, 40px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="relative z-10 w-full max-w-sm px-8 sm:px-6">
|
||||
<div className="space-y-12 sm:space-y-16 animate-fade-in">
|
||||
{/* Top Icon Box - Signature mintel.me Black Square */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 bg-black rounded-xl flex items-center justify-center shadow-xl shadow-slate-100 hover:scale-105 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] rotate-2 hover:rotate-0">
|
||||
<Image
|
||||
src="/icon-white.svg"
|
||||
alt="Mintel"
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-12 animate-slide-up">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-xs font-sans font-bold uppercase tracking-[0.4em] text-slate-900 border-b border-slate-50 pb-4 inline-block mx-auto min-w-[200px]">
|
||||
{projectName} <span className="text-slate-300">Gatekeeper</span>
|
||||
</h1>
|
||||
<p className="text-[10px] text-slate-400 font-sans uppercase tracking-widest italic flex items-center justify-center gap-2">
|
||||
<span className="w-1 h-1 bg-slate-200 rounded-full" />
|
||||
Infrastructure Protection
|
||||
<span className="w-1 h-1 bg-slate-200 rounded-full" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 px-5 py-3 rounded-2xl text-[9px] font-sans font-bold uppercase tracking-widest flex items-center gap-3 border border-red-100 animate-shake">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span>Access Denied. Try Again.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={login} className="space-y-4">
|
||||
<input type="hidden" name="redirect" value={redirectUrl} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="EMAIL (OPTIONAL)"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-[10px] font-sans font-bold tracking-[0.2em] uppercase placeholder:text-slate-300 shadow-sm shadow-slate-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
placeholder="ACCESS CODE"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-sm font-sans font-bold tracking-[0.3em] uppercase placeholder:text-slate-300 placeholder:tracking-widest shadow-sm shadow-slate-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100 flex items-center justify-center"
|
||||
>
|
||||
Unlock Access
|
||||
<ArrowRight className="ml-3 w-3 h-3 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Bottom Section - Full Branding Parity */}
|
||||
<div className="pt-12 sm:pt-20 flex flex-col items-center gap-6 sm:gap-8">
|
||||
<div className="h-px w-8 bg-slate-100" />
|
||||
<div className="opacity-80 transition-opacity hover:opacity-100">
|
||||
<Image
|
||||
src="/logo-black.svg"
|
||||
alt={projectName}
|
||||
width={140}
|
||||
height={40}
|
||||
className="h-7 sm:h-auto grayscale contrast-125 w-auto"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[8px] font-sans font-bold text-slate-300 uppercase tracking-[0.4em] sm:tracking-[0.5em] text-center">
|
||||
© 2026 MINTEL
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
packages/gatekeeper/src/app/page.tsx
Normal file
5
packages/gatekeeper/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/login");
|
||||
}
|
||||
60
packages/gatekeeper/tailwind.config.cjs
Normal file
60
packages/gatekeeper/tailwind.config.cjs
Normal file
@@ -0,0 +1,60 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
xl: "1rem",
|
||||
"2xl": "1.5rem",
|
||||
"3xl": "2rem",
|
||||
full: "9999px",
|
||||
},
|
||||
colors: {
|
||||
slate: {
|
||||
850: "#1e293b",
|
||||
900: "#0f172a",
|
||||
950: "#020617",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-inter)", "Inter", "system-ui", "sans-serif"],
|
||||
serif: ["var(--font-newsreader)", "Georgia", "serif"],
|
||||
mono: ["JetBrains Mono", "monospace"],
|
||||
},
|
||||
animation: {
|
||||
"fade-in": "fadeIn 0.5s ease-in-out",
|
||||
"slide-up": "slideUp 0.6s ease-out",
|
||||
"slide-down": "slideDown 0.6s ease-out",
|
||||
shake: "shake 0.2s ease-in-out 0s 2",
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
"0%": { opacity: "0" },
|
||||
"100%": { opacity: "1" },
|
||||
},
|
||||
slideUp: {
|
||||
"0%": { transform: "translateY(20px)", opacity: "0" },
|
||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||
},
|
||||
slideDown: {
|
||||
"0%": { transform: "translateY(-20px)", opacity: "0" },
|
||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||
},
|
||||
shake: {
|
||||
"0%, 100%": { transform: "translateX(0)" },
|
||||
"25%": { transform: "translateX(-4px)" },
|
||||
"75%": { transform: "translateX(4px)" },
|
||||
},
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
industrial: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
},
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
mintel: {
|
||||
green: "#82ed20",
|
||||
blue: "#001a4d",
|
||||
dark: "#000c1f",
|
||||
},
|
||||
},
|
||||
backgroundImage: {
|
||||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
const config = {
|
||||
extends: ["@commitlint/config-conventional"],
|
||||
rules: {
|
||||
"header-max-length": [2, "always", 150],
|
||||
"header-max-length": [2, "always", 250],
|
||||
"subject-case": [0],
|
||||
"subject-full-stop": [0],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import path from "path";
|
||||
import path from "node:path";
|
||||
|
||||
const buildLintCommand = (filenames) => {
|
||||
const isNext =
|
||||
@@ -11,7 +11,7 @@ const buildLintCommand = (filenames) => {
|
||||
.join(" --file ")}`;
|
||||
}
|
||||
|
||||
return "eslint --fix";
|
||||
return "eslint --fix --no-warn-ignored";
|
||||
};
|
||||
|
||||
const config = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user