Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e4e296e3b |
@@ -1,82 +0,0 @@
|
|||||||
---
|
|
||||||
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`).
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
"@mintel/monorepo": patch
|
|
||||||
"acquisition-manager": patch
|
|
||||||
"feedback-commander": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: make directus extension build scripts more resilient
|
|
||||||
@@ -1,26 +1,12 @@
|
|||||||
node_modules
|
node_modules
|
||||||
**/node_modules
|
|
||||||
.next
|
.next
|
||||||
**/.next
|
|
||||||
.git
|
.git
|
||||||
# .npmrc is allowed as it contains the registry template
|
# .npmrc is allowed as it contains the registry template
|
||||||
dist
|
dist
|
||||||
**/dist
|
|
||||||
build
|
build
|
||||||
**/build
|
|
||||||
out
|
out
|
||||||
**/out
|
|
||||||
coverage
|
coverage
|
||||||
**/coverage
|
|
||||||
.vercel
|
.vercel
|
||||||
**/.vercel
|
|
||||||
.turbo
|
.turbo
|
||||||
**/.turbo
|
|
||||||
*.log
|
*.log
|
||||||
**/*.log
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
**/.DS_Store
|
|
||||||
.pnpm-store
|
|
||||||
**/.pnpm-store
|
|
||||||
.gitea
|
|
||||||
**/.gitea
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
# Project
|
|
||||||
IMAGE_TAG=v1.8.19
|
|
||||||
PROJECT_NAME=at-mintel
|
|
||||||
PROJECT_COLOR=#82ed20
|
|
||||||
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
|
|
||||||
OPENROUTER_API_KEY=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
|
|
||||||
|
|
||||||
# Authentication
|
|
||||||
GATEKEEPER_PASSWORD=mintel
|
|
||||||
AUTH_COOKIE_NAME=mintel_gatekeeper_session
|
|
||||||
|
|
||||||
# Host Config (Local)
|
|
||||||
TRAEFIK_HOST=at-mintel.localhost
|
|
||||||
DIRECTUS_HOST=cms-legacy.localhost
|
|
||||||
|
|
||||||
# Next.js
|
|
||||||
NEXT_PUBLIC_BASE_URL=http://at-mintel.localhost
|
|
||||||
|
|
||||||
# Directus
|
|
||||||
DIRECTUS_URL=http://cms-legacy.localhost
|
|
||||||
DIRECTUS_KEY=F9IIfahEjPq6NZhKyRLw516D8GotuFj79EGK7pGfIWg=
|
|
||||||
DIRECTUS_SECRET=OZfxMu8lBxzaEnFGRKreNBoJpRiRu58U+HsVg2yWk4o=
|
|
||||||
CORS_ENABLED=true
|
|
||||||
CORS_ORIGIN=true
|
|
||||||
LOG_LEVEL=debug
|
|
||||||
DIRECTUS_ADMIN_EMAIL=mmintel@mintel.me
|
|
||||||
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
|
||||||
DIRECTUS_DB_NAME=directus
|
|
||||||
DIRECTUS_DB_USER=directus
|
|
||||||
DIRECTUS_DB_PASSWORD=mintel-db-pass
|
|
||||||
|
|
||||||
# Sentry / Glitchtip
|
|
||||||
SENTRY_DSN=
|
|
||||||
|
|
||||||
# Analytics (Umami)
|
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Project
|
# Project
|
||||||
IMAGE_TAG=v1.8.20
|
IMAGE_TAG=v1.7.0
|
||||||
PROJECT_NAME=sample-website
|
PROJECT_NAME=sample-website
|
||||||
PROJECT_COLOR=#82ed20
|
PROJECT_COLOR=#82ed20
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
**/index.js
|
|
||||||
**/dist/**
|
|
||||||
packages/cms-infra/extensions/**
|
|
||||||
packages/cms-infra/extensions/**
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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
|
|
||||||
+32
-120
@@ -5,74 +5,15 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- '**'
|
- '**'
|
||||||
tags:
|
tags:
|
||||||
- '*'
|
- 'v*'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prioritize:
|
qa:
|
||||||
name: ⚡ Prioritize Release
|
name: 🧪 Quality Assurance
|
||||||
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
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
@@ -81,67 +22,37 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node_version: 20
|
node_version: 20
|
||||||
- name: Enable pnpm
|
|
||||||
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: 🏷️ Sync Versions (if Tagged)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
run: pnpm sync-versions
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm 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
|
- name: Test
|
||||||
run: pnpm 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
|
- name: Build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: 🚀 Release
|
name: 🚀 Release
|
||||||
needs: [lint, test, build]
|
needs: qa
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
@@ -153,16 +64,20 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node_version: 20
|
node_version: 20
|
||||||
- name: Enable pnpm
|
|
||||||
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
|
run: pnpm install --frozen-lockfile
|
||||||
- name: 🏷️ Sync Versions (if Tagged)
|
|
||||||
run: pnpm sync-versions
|
|
||||||
- name: 🏷️ Release Packages (Tag-Driven)
|
- name: 🏷️ Release Packages (Tag-Driven)
|
||||||
run: |
|
run: |
|
||||||
echo "🏷️ Tag detected [${{ github.ref_name }}], performing sync release..."
|
echo "🏷️ Tag detected [${{ github.ref_name }}], performing sync release..."
|
||||||
@@ -170,14 +85,13 @@ jobs:
|
|||||||
|
|
||||||
build-images:
|
build-images:
|
||||||
name: 🐳 Build ${{ matrix.name }}
|
name: 🐳 Build ${{ matrix.name }}
|
||||||
needs: [lint, test, build]
|
needs: qa
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 1
|
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- image: nextjs
|
- image: nextjs
|
||||||
@@ -192,9 +106,6 @@ jobs:
|
|||||||
- image: directus
|
- image: directus
|
||||||
file: packages/infra/docker/Dockerfile.directus
|
file: packages/infra/docker/Dockerfile.directus
|
||||||
name: Directus (Base)
|
name: Directus (Base)
|
||||||
- image: image-processor
|
|
||||||
file: apps/image-service/Dockerfile
|
|
||||||
name: Image Processor
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -214,13 +125,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ${{ matrix.file }}
|
file: ${{ matrix.file }}
|
||||||
platforms: linux/amd64
|
platforms: linux/arm64
|
||||||
pull: true
|
pull: true
|
||||||
provenance: false
|
|
||||||
push: true
|
push: true
|
||||||
secrets: |
|
secrets: |
|
||||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
tags: |
|
tags: |
|
||||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
||||||
|
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache
|
||||||
|
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache,mode=max
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,3 @@ Thumbs.db
|
|||||||
|
|
||||||
# Changesets
|
# Changesets
|
||||||
.changeset/*.lock
|
.changeset/*.lock
|
||||||
directus/extensions/
|
|
||||||
packages/cms-infra/extensions/
|
|
||||||
packages/cms-infra/uploads/
|
|
||||||
|
|
||||||
directus/uploads/directus-health-file
|
|
||||||
+6
-35
@@ -1,45 +1,16 @@
|
|||||||
# 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
|
# Check if we are pushing a tag
|
||||||
while read local_ref local_sha remote_ref remote_sha
|
while read local_ref local_sha remote_ref remote_sha
|
||||||
do
|
do
|
||||||
if [[ "$remote_ref" == refs/tags/* ]]; then
|
if [[ "$remote_ref" == refs/tags/v* ]]; then
|
||||||
TAG=${remote_ref#refs/tags/}
|
TAG=${remote_ref#refs/tags/}
|
||||||
echo "🏷️ Tag detected: $TAG, ensuring versions are synced..."
|
echo "🏷️ Tag detected: $TAG, syncing versions..."
|
||||||
|
|
||||||
# Run sync script
|
|
||||||
pnpm sync-versions "$TAG"
|
pnpm sync-versions "$TAG"
|
||||||
|
|
||||||
# Check for changes in relevant files
|
# Stage the changed files
|
||||||
SYNC_FILES="package.json packages/*/package.json apps/*/package.json .env.example"
|
git add package.json packages/*/package.json apps/*/package.json .env .env.example
|
||||||
CHANGES=$(git status --porcelain $SYNC_FILES)
|
|
||||||
|
|
||||||
if [[ -n "$CHANGES" ]]; then
|
echo "⚠️ package.json and .env files updated to match tag $TAG."
|
||||||
echo "📝 Version sync made changes. Integrating into tag..."
|
echo "⚠️ Note: You might need to push again if these changes were not already in your commit/tag."
|
||||||
|
|
||||||
# Stage and commit
|
|
||||||
git add $SYNC_FILES
|
|
||||||
git commit -m "chore: sync versions to $TAG" --no-verify
|
|
||||||
|
|
||||||
# Force update the local tag to point to the new commit
|
|
||||||
git tag -f "$TAG" > /dev/null
|
|
||||||
|
|
||||||
echo "✅ Tag $TAG has been updated locally with synced versions."
|
|
||||||
echo "🚀 Auto-pushing updated tag..."
|
|
||||||
|
|
||||||
# Push the updated tag directly (using --no-verify to avoid recursion)
|
|
||||||
git push origin "$TAG" --force --no-verify
|
|
||||||
|
|
||||||
echo "✨ Success! The hook synchronized the versions and pushed the updated tag for you."
|
|
||||||
echo "ℹ️ Note: The original push command was aborted in favor of the auto-push. This is normal."
|
|
||||||
exit 0 # Change to exit 0 to not show as an 'error' in vscode/terminal, though original push will still be technically 'failed' by git
|
|
||||||
else
|
|
||||||
echo "✨ Versions already in sync for $TAG."
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
# 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,5 +80,3 @@ Client websites scaffolded via the CLI use a **tag-based deployment** strategy:
|
|||||||
- **Git Tag `v*.*.*`**: Deploys to the `production` environment.
|
- **Git Tag `v*.*.*`**: Deploys to the `production` environment.
|
||||||
|
|
||||||
See the [`@mintel/infra`](packages/infra/README.md) package for detailed template documentation.
|
See the [`@mintel/infra`](packages/infra/README.md) package for detailed template documentation.
|
||||||
|
|
||||||
Trigger rebuilding for x86 architecture.
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
FROM node:20.18-bookworm-slim AS base
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
build-essential \
|
|
||||||
python3 \
|
|
||||||
libcairo2-dev \
|
|
||||||
libpango1.0-dev \
|
|
||||||
libjpeg-dev \
|
|
||||||
libgif-dev \
|
|
||||||
librsvg2-dev \
|
|
||||||
libexpat1 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
RUN npm install -g pnpm@10.30.1
|
|
||||||
|
|
||||||
FROM base AS build
|
|
||||||
WORKDIR /app
|
|
||||||
COPY . .
|
|
||||||
# We only need standard pnpm install now, no C++ tools needed for basic Sharp
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
RUN pnpm --filter @mintel/image-processor build
|
|
||||||
RUN pnpm --filter image-service build
|
|
||||||
|
|
||||||
FROM base
|
|
||||||
WORKDIR /app
|
|
||||||
# Instead of copying node_modules which contains native C++ bindings for canvas and tfjs-node,
|
|
||||||
# we copy the package.json files and install natively in the final stage so the bindings are correct.
|
|
||||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
|
||||||
COPY apps/image-service/package.json ./apps/image-service/package.json
|
|
||||||
COPY packages/image-processor/package.json ./packages/image-processor/package.json
|
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile --filter image-service...
|
|
||||||
|
|
||||||
COPY --from=build /app/apps/image-service/dist ./apps/image-service/dist
|
|
||||||
COPY --from=build /app/packages/image-processor/dist ./packages/image-processor/dist
|
|
||||||
COPY --from=build /app/packages/image-processor/models ./packages/image-processor/models
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
WORKDIR /app/apps/image-service
|
|
||||||
CMD ["npm", "run", "start"]
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "image-service",
|
|
||||||
"version": "1.8.20",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "tsx watch src/index.ts",
|
|
||||||
"build": "tsc",
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"lint": "eslint src"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@mintel/image-processor": "workspace:*",
|
|
||||||
"fastify": "^4.26.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@mintel/eslint-config": "workspace:*",
|
|
||||||
"@mintel/tsconfig": "workspace:*",
|
|
||||||
"@types/node": "^20.0.0",
|
|
||||||
"tsx": "^4.7.1",
|
|
||||||
"typescript": "^5.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import Fastify from "fastify";
|
|
||||||
import {
|
|
||||||
processImageWithSmartCrop,
|
|
||||||
parseImgproxyOptions,
|
|
||||||
mapUrl,
|
|
||||||
} from "@mintel/image-processor";
|
|
||||||
|
|
||||||
const fastify = Fastify({
|
|
||||||
logger: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.get("/unsafe/:options/:urlSafeB64", async (request, reply) => {
|
|
||||||
const { options, urlSafeB64 } = request.params as {
|
|
||||||
options: string;
|
|
||||||
urlSafeB64: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// urlSafeB64 might be "plain/http://..." or a Base64 string
|
|
||||||
let url = "";
|
|
||||||
if (urlSafeB64.startsWith("plain/")) {
|
|
||||||
url = urlSafeB64.substring(6);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
url = Buffer.from(urlSafeB64, "base64").toString("utf-8");
|
|
||||||
} catch (e) {
|
|
||||||
return reply.status(400).send({ error: "Invalid Base64 URL" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedOptions = parseImgproxyOptions(options);
|
|
||||||
const mappedUrl = mapUrl(url, process.env.IMGPROXY_URL_MAPPING);
|
|
||||||
|
|
||||||
return handleProcessing(mappedUrl, parsedOptions, reply);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper to avoid duplication
|
|
||||||
async function handleProcessing(url: string, options: any, reply: any) {
|
|
||||||
const width = options.width || 800;
|
|
||||||
const height = options.height || 600;
|
|
||||||
const quality = options.quality || 80;
|
|
||||||
const format = options.format || "webp";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) {
|
|
||||||
return reply.status(response.status).send({
|
|
||||||
error: `Failed to fetch source image: ${response.statusText}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
|
||||||
|
|
||||||
const processedBuffer = await processImageWithSmartCrop(buffer, {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
format,
|
|
||||||
quality,
|
|
||||||
});
|
|
||||||
|
|
||||||
reply.header("Content-Type", `image/${format}`);
|
|
||||||
reply.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
||||||
return reply.send(processedBuffer);
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.error(err);
|
|
||||||
return reply
|
|
||||||
.status(500)
|
|
||||||
.send({ error: "Internal Server Error processing image" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fastify.get("/process", async (request, reply) => {
|
|
||||||
const query = request.query as {
|
|
||||||
url?: string;
|
|
||||||
w?: string;
|
|
||||||
h?: string;
|
|
||||||
q?: string;
|
|
||||||
format?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const { url } = query;
|
|
||||||
const width = parseInt(query.w || "800", 10);
|
|
||||||
const height = parseInt(query.h || "600", 10);
|
|
||||||
const quality = parseInt(query.q || "80", 10);
|
|
||||||
const format = (query.format || "webp") as any;
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return reply.status(400).send({ error: 'Parameter "url" is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const mappedUrl = mapUrl(url, process.env.IMGPROXY_URL_MAPPING);
|
|
||||||
return handleProcessing(mappedUrl, { width, height, quality, format }, reply);
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.get("/health", async () => {
|
|
||||||
return { status: "ok" };
|
|
||||||
});
|
|
||||||
|
|
||||||
const start = async () => {
|
|
||||||
try {
|
|
||||||
await fastify.listen({ port: 8080, host: "0.0.0.0" });
|
|
||||||
console.log(`Server listening on 8080`);
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
start();
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@mintel/tsconfig/base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"noEmit": false
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"]
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# Start from the pre-built Nextjs Base image
|
# Start from the pre-built Nextjs Base image
|
||||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
ARG IMAGE_TAG=latest
|
||||||
|
FROM registry.infra.mintel.me/mintel/nextjs:${IMAGE_TAG} AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ ENV DIRECTUS_URL=$DIRECTUS_URL
|
|||||||
RUN pnpm --filter sample-website build
|
RUN pnpm --filter sample-website build
|
||||||
|
|
||||||
# Production runner image
|
# Production runner image
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
FROM registry.infra.mintel.me/mintel/runtime:${IMAGE_TAG} AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/apps/sample-website/public ./apps/sample-website/public
|
COPY --from=builder /app/apps/sample-website/public ./apps/sample-website/public
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import mintelNextConfig from "@mintel/next-config";
|
import mintelNextConfig from "@mintel/next-config";
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {};
|
||||||
serverExternalPackages: [
|
|
||||||
"@mintel/image-processor",
|
|
||||||
"@tensorflow/tfjs-node",
|
|
||||||
"sharp",
|
|
||||||
"canvas",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default mintelNextConfig(nextConfig);
|
export default mintelNextConfig(nextConfig);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sample-website",
|
"name": "sample-website",
|
||||||
"version": "1.8.20",
|
"version": "1.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
"@mintel/next-utils": "workspace:*",
|
"@mintel/next-utils": "workspace:*",
|
||||||
"@mintel/observability": "workspace:*",
|
"@mintel/observability": "workspace:*",
|
||||||
"@mintel/next-observability": "workspace:*",
|
"@mintel/next-observability": "workspace:*",
|
||||||
"@mintel/image-processor": "workspace:*",
|
|
||||||
"@sentry/nextjs": "10.38.0",
|
"@sentry/nextjs": "10.38.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-intl": "^4.8.2",
|
"next-intl": "^4.8.2",
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const url = searchParams.get("url");
|
|
||||||
const width = parseInt(searchParams.get("w") || "800");
|
|
||||||
const height = parseInt(searchParams.get("h") || "600");
|
|
||||||
const q = parseInt(searchParams.get("q") || "80");
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Missing url parameter" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Fetch image from original URL
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch original image" },
|
|
||||||
{ status: response.status },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
|
||||||
|
|
||||||
// Dynamically import to prevent Next.js from trying to bundle tfjs-node/sharp locally at build time
|
|
||||||
const { processImageWithSmartCrop } =
|
|
||||||
await import("@mintel/image-processor");
|
|
||||||
|
|
||||||
// 2. Process image with Face-API and Sharp
|
|
||||||
const processedBuffer = await processImageWithSmartCrop(buffer, {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
format: "webp",
|
|
||||||
quality: q,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Return the processed image
|
|
||||||
return new NextResponse(new Uint8Array(processedBuffer), {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "image/webp",
|
|
||||||
"Cache-Control": "public, max-age=31536000, immutable",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Image Processing Error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to process image" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
version: 1
|
||||||
|
directus: 11.15.1
|
||||||
|
vendor: postgres
|
||||||
|
collections: []
|
||||||
|
fields: []
|
||||||
|
systemFields:
|
||||||
|
- collection: directus_activity
|
||||||
|
field: timestamp
|
||||||
|
schema:
|
||||||
|
is_indexed: true
|
||||||
|
- collection: directus_revisions
|
||||||
|
field: activity
|
||||||
|
schema:
|
||||||
|
is_indexed: true
|
||||||
|
- collection: directus_revisions
|
||||||
|
field: parent
|
||||||
|
schema:
|
||||||
|
is_indexed: true
|
||||||
|
relations: []
|
||||||
+2
-12
@@ -21,17 +21,9 @@ services:
|
|||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.sample-website.rule=Host(`${TRAEFIK_HOST:-sample-website.localhost}`)"
|
- "traefik.http.routers.sample-website.rule=Host(`${TRAEFIK_HOST:-sample-website.localhost}`)"
|
||||||
- "traefik.http.services.sample-website.loadbalancer.server.port=3000"
|
- "traefik.http.services.sample-website.loadbalancer.server.port=3000"
|
||||||
- "caddy=http://${TRAEFIK_HOST:-acquisition.localhost}"
|
|
||||||
- "caddy.reverse_proxy={{upstreams 3000}}"
|
|
||||||
|
|
||||||
directus:
|
directus:
|
||||||
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-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
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- infra
|
- infra
|
||||||
@@ -43,7 +35,7 @@ services:
|
|||||||
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
|
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
|
||||||
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
|
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
|
||||||
DB_CLIENT: 'pg'
|
DB_CLIENT: 'pg'
|
||||||
DB_HOST: 'at-mintel-directus-db'
|
DB_HOST: 'directus-db'
|
||||||
DB_PORT: '5432'
|
DB_PORT: '5432'
|
||||||
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
||||||
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
||||||
@@ -60,10 +52,8 @@ services:
|
|||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
|
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
|
||||||
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
|
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
|
||||||
- "caddy=http://${DIRECTUS_HOST:-cms.at.localhost}"
|
|
||||||
- "caddy.reverse_proxy={{upstreams 8055}}"
|
|
||||||
|
|
||||||
at-mintel-directus-db:
|
directus-db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ export default [
|
|||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
"packages/cms-infra/extensions/**",
|
"packages/cms-infra/extensions/**",
|
||||||
"**/index.js",
|
"packages/customer-manager/index.js",
|
||||||
"**/*.db",
|
"**/*.db",
|
||||||
"**/build/**",
|
"**/build/**",
|
||||||
"**/data/**",
|
"**/data/**",
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
404: Not Found
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Ghost Image Optimizer
|
|
||||||
# Target directory for Ghost content
|
|
||||||
TARGET_DIR="/home/deploy/sites/marisas.world/content/images"
|
|
||||||
|
|
||||||
echo "Starting image optimization for $TARGET_DIR..."
|
|
||||||
|
|
||||||
# Find all original images, excluding the 'size/' directory where Ghost stores thumbnails
|
|
||||||
# Resize images larger than 2500px down to 2500px width
|
|
||||||
# Compress JPEG/PNG to 80% quality
|
|
||||||
find "$TARGET_DIR" -type d -name "size" -prune -o \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" \) -type f -exec mogrify -resize '2500x>' -quality 80 {} +
|
|
||||||
|
|
||||||
echo "Optimization complete."
|
|
||||||
+5
-22
@@ -10,16 +10,12 @@
|
|||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"version-packages": "changeset version",
|
"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:push:infra": "./scripts/sync-directus.sh push infra",
|
||||||
"cms:up": "pnpm --filter @mintel/cms-infra up",
|
"cms:pull:infra": "./scripts/sync-directus.sh pull infra",
|
||||||
"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:snapshot": "./scripts/cms-snapshot.sh",
|
||||||
"cms:schema:apply": "./scripts/cms-apply.sh local",
|
"cms:schema:apply": "./scripts/cms-apply.sh local",
|
||||||
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
|
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
|
||||||
"cms:sync:push": "./scripts/sync-directus.sh push infra",
|
"dev:infra": "docker-compose up -d directus directus-db",
|
||||||
"cms:sync:pull": "./scripts/sync-directus.sh pull infra",
|
|
||||||
"build:extensions": "./scripts/sync-extensions.sh",
|
|
||||||
"release": "pnpm build && changeset publish",
|
"release": "pnpm build && changeset publish",
|
||||||
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
|
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
@@ -30,7 +26,6 @@
|
|||||||
"@commitlint/config-conventional": "^20.4.0",
|
"@commitlint/config-conventional": "^20.4.0",
|
||||||
"@mintel/eslint-config": "workspace:*",
|
"@mintel/eslint-config": "workspace:*",
|
||||||
"@mintel/husky-config": "workspace:*",
|
"@mintel/husky-config": "workspace:*",
|
||||||
"@next/eslint-plugin-next": "16.1.6",
|
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^20.17.16",
|
"@types/node": "^20.17.16",
|
||||||
@@ -38,6 +33,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
|
"@next/eslint-plugin-next": "16.1.6",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"happy-dom": "^20.4.0",
|
"happy-dom": "^20.4.0",
|
||||||
@@ -51,26 +47,13 @@
|
|||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"globals": "^17.3.0",
|
|
||||||
"import-in-the-middle": "^3.0.0",
|
"import-in-the-middle": "^3.0.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"require-in-the-middle": "^8.0.1"
|
"require-in-the-middle": "^8.0.1"
|
||||||
},
|
},
|
||||||
"version": "1.8.20",
|
"version": "1.7.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
|
||||||
"@parcel/watcher",
|
|
||||||
"@sentry/cli",
|
|
||||||
"@swc/core",
|
|
||||||
"@tensorflow/tfjs-node",
|
|
||||||
"canvas",
|
|
||||||
"core-js",
|
|
||||||
"esbuild",
|
|
||||||
"sharp",
|
|
||||||
"unrs-resolver",
|
|
||||||
"vue-demi"
|
|
||||||
],
|
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"@sentry/nextjs": "10.38.0"
|
"@sentry/nextjs": "10.38.0"
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "acquisition-manager",
|
|
||||||
"description": "Custom High-Fidelity Management for Directus",
|
|
||||||
"icon": "extension",
|
|
||||||
"version": "1.8.20",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "acquisition",
|
"name": "@mintel/acquisition",
|
||||||
"version": "1.8.20",
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"directus:extension": {
|
"main": "./dist/index.js",
|
||||||
"type": "endpoint",
|
"module": "./dist/index.js",
|
||||||
"path": "dist/index.js",
|
"types": "./dist/index.d.ts",
|
||||||
"source": "src/index.ts",
|
|
||||||
"host": "^11.0.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.mjs",
|
"build": "tsup src/index.ts --format esm --dts --clean",
|
||||||
"dev": "node build.mjs --watch"
|
"dev": "tsup src/index.ts --format esm --watch --dts",
|
||||||
},
|
"lint": "eslint src",
|
||||||
"devDependencies": {
|
"test": "vitest run",
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
"typecheck": "tsc --noEmit"
|
||||||
"@mintel/pdf": "workspace:*",
|
|
||||||
"@mintel/mail": "workspace:*",
|
|
||||||
"esbuild": "^0.25.0",
|
|
||||||
"typescript": "^5.6.3"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jquery": "^3.7.1",
|
"axios": "^1.7.9",
|
||||||
"react": "^19.2.4",
|
"crawlee": "^3.12.2",
|
||||||
"react-dom": "^19.2.4"
|
"cheerio": "^1.0.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"@react-pdf/renderer": "^4.3.0",
|
||||||
|
"framer-motion": "^12.4.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@mintel/tsconfig": "workspace:*",
|
||||||
|
"@mintel/eslint-config": "workspace:*",
|
||||||
|
"tsup": "^8.3.5",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"vitest": "^3.0.4",
|
||||||
|
"@types/node": "^20.17.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+6
-8
@@ -18,8 +18,6 @@ import { calculatePositions } from "../logic/pricing/calculator.js";
|
|||||||
interface PDFProps {
|
interface PDFProps {
|
||||||
state: any;
|
state: any;
|
||||||
totalPrice: number;
|
totalPrice: number;
|
||||||
monthlyPrice?: number;
|
|
||||||
totalPagesCount?: number;
|
|
||||||
pricing: any;
|
pricing: any;
|
||||||
headerIcon?: string;
|
headerIcon?: string;
|
||||||
footerLogo?: string;
|
footerLogo?: string;
|
||||||
@@ -50,7 +48,7 @@ export const EstimationPDF = ({
|
|||||||
const commonProps = {
|
const commonProps = {
|
||||||
state,
|
state,
|
||||||
date,
|
date,
|
||||||
headerIcon,
|
icon: headerIcon,
|
||||||
footerLogo,
|
footerLogo,
|
||||||
companyData,
|
companyData,
|
||||||
};
|
};
|
||||||
@@ -64,17 +62,17 @@ export const EstimationPDF = ({
|
|||||||
<FrontPageModule state={state} headerIcon={headerIcon} date={date} />
|
<FrontPageModule state={state} headerIcon={headerIcon} date={date} />
|
||||||
</PDFPage>
|
</PDFPage>
|
||||||
|
|
||||||
<SimpleLayout {...commonProps}>
|
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||||
<BriefingModule state={state} />
|
<BriefingModule state={state} />
|
||||||
</SimpleLayout>
|
</SimpleLayout>
|
||||||
|
|
||||||
{state.sitemap && state.sitemap.length > 0 && (
|
{state.sitemap && state.sitemap.length > 0 && (
|
||||||
<SimpleLayout {...commonProps}>
|
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||||
<SitemapModule state={state} />
|
<SitemapModule state={state} />
|
||||||
</SimpleLayout>
|
</SimpleLayout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SimpleLayout {...commonProps}>
|
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||||
<EstimationModule
|
<EstimationModule
|
||||||
state={state}
|
state={state}
|
||||||
positions={positions}
|
positions={positions}
|
||||||
@@ -83,11 +81,11 @@ export const EstimationPDF = ({
|
|||||||
/>
|
/>
|
||||||
</SimpleLayout>
|
</SimpleLayout>
|
||||||
|
|
||||||
<SimpleLayout {...commonProps}>
|
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||||
<TransparenzModule pricing={pricing} />
|
<TransparenzModule pricing={pricing} />
|
||||||
</SimpleLayout>
|
</SimpleLayout>
|
||||||
|
|
||||||
<SimpleLayout {...commonProps}>
|
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||||
<ClosingModule />
|
<ClosingModule />
|
||||||
</SimpleLayout>
|
</SimpleLayout>
|
||||||
</PDFDocument>
|
</PDFDocument>
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
View as PDFView,
|
||||||
|
Text as PDFText,
|
||||||
|
StyleSheet,
|
||||||
|
Image as PDFImage,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
|
||||||
|
// INDUSTRIAL DESIGN SYSTEM TOKENS
|
||||||
|
export const COLORS = {
|
||||||
|
CHARCOAL: "#0f172a", // Slate 900
|
||||||
|
TEXT_MAIN: "#334155", // Slate 700
|
||||||
|
TEXT_DIM: "#64748b", // Slate 500
|
||||||
|
TEXT_LIGHT: "#94a3b8", // Slate 400
|
||||||
|
DIVIDER: "#cbd5e1", // Slate 300
|
||||||
|
GRID: "#f1f5f9", // Slate 100
|
||||||
|
BLUEPRINT: "#e2e8f0", // Slate 200
|
||||||
|
WHITE: "#ffffff",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FONT_SIZES = {
|
||||||
|
HERO: 24, // Main Page Titles
|
||||||
|
HEADING: 14, // Section Headers
|
||||||
|
BODY: 11, // Standard Content
|
||||||
|
LABEL: 10, // Bold Labels / Keys
|
||||||
|
SMALL: 9, // Descriptions / Footnotes
|
||||||
|
TINY: 8, // Metadata / Unit prices
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pdfStyles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
paddingTop: 45, // DIN 5008
|
||||||
|
paddingLeft: 70, // ~25mm
|
||||||
|
paddingRight: 57, // ~20mm
|
||||||
|
paddingBottom: 80, // Safe buffer for absolute footer
|
||||||
|
backgroundColor: COLORS.WHITE,
|
||||||
|
fontFamily: "Helvetica",
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
},
|
||||||
|
titlePage: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: COLORS.WHITE,
|
||||||
|
fontFamily: "Helvetica",
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
marginBottom: 20,
|
||||||
|
minHeight: 120,
|
||||||
|
},
|
||||||
|
addressBlock: {
|
||||||
|
width: "55%",
|
||||||
|
marginTop: 45,
|
||||||
|
},
|
||||||
|
senderLine: {
|
||||||
|
fontSize: FONT_SIZES.TINY,
|
||||||
|
textDecoration: "underline",
|
||||||
|
color: COLORS.TEXT_DIM,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
recipientAddress: {
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
brandLogoContainer: {
|
||||||
|
width: "40%",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
},
|
||||||
|
brandIconContainer: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
backgroundColor: "#0f172a",
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
brandIconText: {
|
||||||
|
color: COLORS.WHITE,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
titleInfo: {
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
mainTitle: {
|
||||||
|
fontSize: FONT_SIZES.HEADING,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 4,
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
subTitle: {
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
color: COLORS.TEXT_DIM,
|
||||||
|
marginTop: 2,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: FONT_SIZES.LABEL,
|
||||||
|
fontWeight: "bold",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
color: COLORS.TEXT_LIGHT,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 32,
|
||||||
|
left: 70,
|
||||||
|
right: 57,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: COLORS.GRID,
|
||||||
|
paddingTop: 16,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
footerColumn: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
footerLogo: {
|
||||||
|
height: 20,
|
||||||
|
width: "auto",
|
||||||
|
objectFit: "contain",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
fontSize: FONT_SIZES.TINY,
|
||||||
|
color: COLORS.TEXT_LIGHT,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
asymmetryContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 32,
|
||||||
|
},
|
||||||
|
asymmetryLeft: {
|
||||||
|
width: "32%",
|
||||||
|
},
|
||||||
|
asymmetryRight: {
|
||||||
|
width: "63%",
|
||||||
|
},
|
||||||
|
specRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: COLORS.GRID,
|
||||||
|
},
|
||||||
|
specLabel: {
|
||||||
|
fontSize: FONT_SIZES.TINY,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.TEXT_LIGHT,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
specValue: {
|
||||||
|
fontSize: FONT_SIZES.SMALL,
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
blueprintBox: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: COLORS.GRID,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: "#fafafa",
|
||||||
|
},
|
||||||
|
footerLabel: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.TEXT_DIM,
|
||||||
|
},
|
||||||
|
pageNumber: {
|
||||||
|
fontSize: FONT_SIZES.TINY,
|
||||||
|
color: COLORS.DIVIDER,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginTop: 8,
|
||||||
|
textAlign: "right",
|
||||||
|
},
|
||||||
|
foldingMark: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 20,
|
||||||
|
width: 10,
|
||||||
|
borderTopWidth: 0.5,
|
||||||
|
borderTopColor: COLORS.DIVIDER,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
width: "100%",
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: COLORS.DIVIDER,
|
||||||
|
marginVertical: 12,
|
||||||
|
},
|
||||||
|
industrialListItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
industrialBulletBox: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
backgroundColor: COLORS.DIVIDER,
|
||||||
|
marginRight: 8,
|
||||||
|
marginTop: 5,
|
||||||
|
},
|
||||||
|
industrialTitle: {
|
||||||
|
fontSize: FONT_SIZES.HERO,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
marginBottom: 6,
|
||||||
|
letterSpacing: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const IndustrialListItem = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<PDFView style={pdfStyles.industrialListItem}>
|
||||||
|
<PDFView style={pdfStyles.industrialBulletBox} />
|
||||||
|
{children}
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Divider = ({ style = {} }: { style?: any }) => (
|
||||||
|
<PDFView style={[pdfStyles.divider, style]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Footer = ({
|
||||||
|
logo,
|
||||||
|
companyData,
|
||||||
|
showDetails = true,
|
||||||
|
showPageNumber = true,
|
||||||
|
}: {
|
||||||
|
logo?: string;
|
||||||
|
companyData: any;
|
||||||
|
showDetails?: boolean;
|
||||||
|
showPageNumber?: boolean;
|
||||||
|
}) => (
|
||||||
|
<PDFView style={pdfStyles.footer}>
|
||||||
|
<PDFView style={pdfStyles.footerColumn}>
|
||||||
|
{logo ? (
|
||||||
|
<PDFImage src={logo} style={pdfStyles.footerLogo} />
|
||||||
|
) : (
|
||||||
|
<PDFText style={{ fontSize: 12, fontWeight: "bold", marginBottom: 8 }}>
|
||||||
|
marc mintel
|
||||||
|
</PDFText>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
{showDetails && (
|
||||||
|
<>
|
||||||
|
<PDFView style={pdfStyles.footerColumn}>
|
||||||
|
<PDFText style={pdfStyles.footerText}>
|
||||||
|
<PDFText style={pdfStyles.footerLabel}>{companyData.name}</PDFText>
|
||||||
|
{"\n"}
|
||||||
|
{companyData.address1}
|
||||||
|
{"\n"}
|
||||||
|
{companyData.address2}
|
||||||
|
{"\n"}UST: {companyData.ustId}
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
<PDFView style={[pdfStyles.footerColumn, { alignItems: "flex-end" }]}>
|
||||||
|
{showPageNumber && (
|
||||||
|
<PDFText
|
||||||
|
style={pdfStyles.pageNumber}
|
||||||
|
render={({ pageNumber, totalPages }) =>
|
||||||
|
`${pageNumber} / ${totalPages}`
|
||||||
|
}
|
||||||
|
fixed
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Header = ({
|
||||||
|
sender,
|
||||||
|
recipient,
|
||||||
|
icon,
|
||||||
|
showAddress = true,
|
||||||
|
}: {
|
||||||
|
sender?: string;
|
||||||
|
recipient?: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
email?: string;
|
||||||
|
address?: string;
|
||||||
|
phone?: string;
|
||||||
|
taxId?: string;
|
||||||
|
};
|
||||||
|
icon?: string;
|
||||||
|
showAddress?: boolean;
|
||||||
|
}) => (
|
||||||
|
<PDFView
|
||||||
|
style={[
|
||||||
|
pdfStyles.header,
|
||||||
|
showAddress ? {} : { minHeight: 40, marginBottom: 0 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<PDFView style={pdfStyles.addressBlock}>
|
||||||
|
{showAddress && sender && (
|
||||||
|
<>
|
||||||
|
<PDFText style={pdfStyles.senderLine}>{sender}</PDFText>
|
||||||
|
{recipient && (
|
||||||
|
<PDFView style={pdfStyles.recipientAddress}>
|
||||||
|
<PDFText style={{ fontWeight: "bold" }}>
|
||||||
|
{recipient.title}
|
||||||
|
</PDFText>
|
||||||
|
{recipient.subtitle && <PDFText>{recipient.subtitle}</PDFText>}
|
||||||
|
{recipient.address && <PDFText>{recipient.address}</PDFText>}
|
||||||
|
{recipient.phone && <PDFText>{recipient.phone}</PDFText>}
|
||||||
|
{recipient.email && <PDFText>{recipient.email}</PDFText>}
|
||||||
|
{recipient.taxId && <PDFText>USt-ID: {recipient.taxId}</PDFText>}
|
||||||
|
</PDFView>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
<PDFView style={pdfStyles.brandLogoContainer}>
|
||||||
|
<PDFView style={pdfStyles.brandIconContainer}>
|
||||||
|
{icon ? (
|
||||||
|
<PDFImage src={icon} style={{ width: 24, height: 24 }} />
|
||||||
|
) : (
|
||||||
|
<PDFText style={pdfStyles.brandIconText}>M</PDFText>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DocumentTitle = ({
|
||||||
|
title,
|
||||||
|
subLines,
|
||||||
|
isHero = false,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subLines?: string[];
|
||||||
|
isHero?: boolean;
|
||||||
|
}) => (
|
||||||
|
<PDFView style={pdfStyles.titleInfo}>
|
||||||
|
<PDFText
|
||||||
|
style={[
|
||||||
|
pdfStyles.mainTitle,
|
||||||
|
{ fontSize: isHero ? FONT_SIZES.HERO : FONT_SIZES.HEADING },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</PDFText>
|
||||||
|
{subLines?.map((line, i) => (
|
||||||
|
<PDFText
|
||||||
|
key={i}
|
||||||
|
style={[
|
||||||
|
pdfStyles.subTitle,
|
||||||
|
i === 1 ? { fontWeight: "bold", color: COLORS.CHARCOAL } : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</PDFText>
|
||||||
|
))}
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TechnicalSpec = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) => (
|
||||||
|
<PDFView style={pdfStyles.specRow}>
|
||||||
|
<PDFText style={pdfStyles.specLabel}>{label}</PDFText>
|
||||||
|
<PDFText style={pdfStyles.specValue}>{value}</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AsymmetryView = ({
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
style = {},
|
||||||
|
}: {
|
||||||
|
left: React.ReactNode;
|
||||||
|
right: React.ReactNode;
|
||||||
|
style?: any;
|
||||||
|
}) => (
|
||||||
|
<PDFView style={[pdfStyles.asymmetryContainer, style]}>
|
||||||
|
<PDFView style={pdfStyles.asymmetryLeft}>{left}</PDFView>
|
||||||
|
<PDFView style={pdfStyles.asymmetryRight}>{right}</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Page as PDFPage, View as PDFView, Text as PDFText, StyleSheet } from '@react-pdf/renderer';
|
||||||
|
import { Header, Footer, pdfStyles } from './SharedUI.js';
|
||||||
|
|
||||||
|
const simpleStyles = StyleSheet.create({
|
||||||
|
industrialPage: {
|
||||||
|
padding: 30,
|
||||||
|
paddingTop: 20,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
industrialNumber: {
|
||||||
|
fontSize: 60,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#f1f5f9',
|
||||||
|
position: 'absolute',
|
||||||
|
top: -10,
|
||||||
|
right: 0,
|
||||||
|
zIndex: -1,
|
||||||
|
},
|
||||||
|
industrialSection: {
|
||||||
|
marginTop: 16,
|
||||||
|
paddingTop: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SimpleLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
pageNumber?: string;
|
||||||
|
icon?: string;
|
||||||
|
footerLogo?: string;
|
||||||
|
companyData: any;
|
||||||
|
showPageNumber?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SimpleLayout = ({
|
||||||
|
children,
|
||||||
|
pageNumber,
|
||||||
|
icon,
|
||||||
|
footerLogo,
|
||||||
|
companyData,
|
||||||
|
showPageNumber = true
|
||||||
|
}: SimpleLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<PDFPage size="A4" style={[pdfStyles.page, simpleStyles.industrialPage]}>
|
||||||
|
<Header icon={icon} showAddress={false} />
|
||||||
|
{pageNumber && <PDFText style={simpleStyles.industrialNumber}>{pageNumber}</PDFText>}
|
||||||
|
<PDFView style={simpleStyles.industrialSection}>
|
||||||
|
<PDFView style={{ width: '100%' }}>
|
||||||
|
{children}
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
<Footer
|
||||||
|
logo={footerLogo}
|
||||||
|
companyData={companyData}
|
||||||
|
showDetails={false}
|
||||||
|
showPageNumber={showPageNumber}
|
||||||
|
/>
|
||||||
|
</PDFPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
View as PDFView,
|
||||||
|
Text as PDFText,
|
||||||
|
StyleSheet,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI.js";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
section: { marginBottom: 24 },
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: FONT_SIZES.LABEL,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 8,
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
},
|
||||||
|
visionText: {
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
color: COLORS.TEXT_MAIN,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
textAlign: "justify",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BriefingModule = ({ state }: any) => (
|
||||||
|
<>
|
||||||
|
<DocumentTitle title="Projektdetails" isHero={true} />
|
||||||
|
{state.briefingSummary && (
|
||||||
|
<PDFView style={styles.section}>
|
||||||
|
<PDFText style={styles.sectionTitle}>Briefing Analyse</PDFText>
|
||||||
|
<PDFText
|
||||||
|
style={{
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
color: COLORS.TEXT_MAIN,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
textAlign: "justify",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{state.briefingSummary}
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
)}
|
||||||
|
{state.designVision && (
|
||||||
|
<PDFView
|
||||||
|
style={[
|
||||||
|
styles.section,
|
||||||
|
{
|
||||||
|
padding: 12,
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: COLORS.DIVIDER,
|
||||||
|
backgroundColor: COLORS.GRID,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<PDFText
|
||||||
|
style={[
|
||||||
|
styles.sectionTitle,
|
||||||
|
{ color: COLORS.CHARCOAL, marginBottom: 4 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Strategische Vision
|
||||||
|
</PDFText>
|
||||||
|
<PDFText style={styles.visionText}>{state.designVision}</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { View as PDFView, Text as PDFText, StyleSheet } from "@react-pdf/renderer";
|
||||||
|
import { DocumentTitle, COLORS, FONT_SIZES, IndustrialListItem } from "../SharedUI.js";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
section: { marginBottom: 24 },
|
||||||
|
categoryBox: {
|
||||||
|
marginBottom: 20,
|
||||||
|
padding: 12,
|
||||||
|
backgroundColor: COLORS.GRID,
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: COLORS.DIVIDER,
|
||||||
|
},
|
||||||
|
categoryTitle: {
|
||||||
|
fontSize: FONT_SIZES.TINY,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.TEXT_LIGHT,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
marginBottom: 10,
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
pageTitle: {
|
||||||
|
fontSize: FONT_SIZES.LABEL,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
pageDesc: {
|
||||||
|
fontSize: FONT_SIZES.TINY,
|
||||||
|
color: COLORS.TEXT_DIM,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SitemapModule = ({ state }: any) => (
|
||||||
|
<>
|
||||||
|
<DocumentTitle title="Informations-Architektur" isHero={true} />
|
||||||
|
<PDFView style={styles.section}>
|
||||||
|
{state.sitemap?.map((cat: any, i: number) => (
|
||||||
|
<PDFView key={i} style={styles.categoryBox}>
|
||||||
|
<PDFText style={styles.categoryTitle}>{cat.category}</PDFText>
|
||||||
|
{cat.pages?.map((p: any, j: number) => (
|
||||||
|
<IndustrialListItem key={j}>
|
||||||
|
<PDFView style={{ marginBottom: 8 }}>
|
||||||
|
<PDFText style={styles.pageTitle}>{p.title}</PDFText>
|
||||||
|
<PDFText style={styles.pageDesc}>{p.desc}</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
</IndustrialListItem>
|
||||||
|
))}
|
||||||
|
</PDFView>
|
||||||
|
))}
|
||||||
|
</PDFView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
@@ -1,236 +1,6 @@
|
|||||||
import { defineEndpoint } from "@directus/extensions-sdk";
|
export * from "./logic/pricing/types.js";
|
||||||
import { AcquisitionService, PdfEngine } from "@mintel/pdf/server";
|
export * from "./logic/pricing/constants.js";
|
||||||
import {
|
export * from "./logic/pricing/calculator.js";
|
||||||
render,
|
export * from "./services/AcquisitionService.js";
|
||||||
SiteAuditTemplate,
|
export * from "./services/PdfEngine.js";
|
||||||
ProjectEstimateTemplate,
|
export * from "./components/EstimationPDF.js";
|
||||||
} 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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { CheerioCrawler } from "@crawlee/cheerio";
|
import { CheerioCrawler } from "crawlee";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { FileCacheAdapter } from "../utils/cache/FileCacheAdapter.js";
|
import { FileCacheAdapter } from "../utils/cache/FileCacheAdapter.js";
|
||||||
import { initialState } from "../logic/pricing/constants.js";
|
import { initialState } from "../logic/pricing/constants.js";
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { calculateTotals, calculatePositions } from "../src/logic/pricing/calculator.js";
|
||||||
|
import { PRICING, initialState } from "../src/logic/pricing/constants.js";
|
||||||
|
import { FormState } from "../src/logic/pricing/types.js";
|
||||||
|
|
||||||
|
describe("Pricing Logic", () => {
|
||||||
|
it("should calculate base website price correctly", () => {
|
||||||
|
const state: FormState = {
|
||||||
|
...initialState,
|
||||||
|
projectType: "website",
|
||||||
|
selectedPages: [] // Clear for base test
|
||||||
|
};
|
||||||
|
const totals = calculateTotals(state, PRICING);
|
||||||
|
expect(totals.totalPrice).toBe(PRICING.BASE_WEBSITE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add page costs correctly", () => {
|
||||||
|
const state: FormState = {
|
||||||
|
...initialState,
|
||||||
|
projectType: "website",
|
||||||
|
selectedPages: [], // Clear for clean test
|
||||||
|
otherPagesCount: 5
|
||||||
|
};
|
||||||
|
const totals = calculateTotals(state, PRICING);
|
||||||
|
expect(totals.totalPrice).toBe(PRICING.BASE_WEBSITE + (5 * PRICING.PAGE));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply multi-language multiplier", () => {
|
||||||
|
const state: FormState = {
|
||||||
|
...initialState,
|
||||||
|
projectType: "website",
|
||||||
|
selectedPages: [], // Clear for clean test
|
||||||
|
languagesList: ["Deutsch", "Englisch"]
|
||||||
|
};
|
||||||
|
const totals = calculateTotals(state, PRICING);
|
||||||
|
expect(totals.totalPrice).toBe(Math.round(PRICING.BASE_WEBSITE * 1.2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate correct positions for a website", () => {
|
||||||
|
const state: FormState = {
|
||||||
|
...initialState,
|
||||||
|
projectType: "website",
|
||||||
|
selectedPages: ["Home"],
|
||||||
|
otherPagesCount: 2
|
||||||
|
};
|
||||||
|
const positions = calculatePositions(state, PRICING);
|
||||||
|
|
||||||
|
// Find "Fundament" position (Das technische Fundament)
|
||||||
|
const basePos = positions.find(p => p.title.includes("Fundament"));
|
||||||
|
expect(basePos).toBeDefined();
|
||||||
|
expect(basePos?.price).toBe(PRICING.BASE_WEBSITE);
|
||||||
|
|
||||||
|
// Find "Individuelle Seiten" position
|
||||||
|
const pagesPos = positions.find(p => p.title.includes("Seiten"));
|
||||||
|
expect(pagesPos).toBeDefined();
|
||||||
|
expect(pagesPos?.qty).toBe(3); // 1 selected + 2 other
|
||||||
|
expect(pagesPos?.price).toBe(3 * PRICING.PAGE);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "@mintel/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/cli",
|
"name": "@mintel/cli",
|
||||||
"version": "1.8.20",
|
"version": "1.7.0",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ program
|
|||||||
`),
|
`),
|
||||||
);
|
);
|
||||||
execSync(
|
execSync(
|
||||||
"docker compose down --remove-orphans && docker compose up -d app directus at-mintel-directus-db",
|
"docker-compose down --remove-orphans && docker-compose up app directus directus-db",
|
||||||
{ stdio: "inherit" },
|
{ stdio: "inherit" },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { build } from 'esbuild';
|
|
||||||
import { resolve, dirname } from 'path';
|
|
||||||
import { mkdirSync } from 'fs';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
const 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);
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@mintel/cloner",
|
|
||||||
"version": "1.8.20",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from "./AssetManager.js";
|
|
||||||
export * from "./PageCloner.js";
|
|
||||||
export * from "./WebsiteCloner.js";
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"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/**/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,42 @@
|
|||||||
|
# Build Stage
|
||||||
|
FROM node:20-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Core environment for pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
# Copy root configurations
|
||||||
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
|
|
||||||
|
# Copy all packages for extensions build
|
||||||
|
COPY packages ./packages
|
||||||
|
|
||||||
|
# Install dependencies (only what's needed for extensions)
|
||||||
|
RUN pnpm install --no-frozen-lockfile \
|
||||||
|
--filter "@mintel/directus-extension-*" \
|
||||||
|
--filter "acquisition" \
|
||||||
|
--filter "acquisition-manager" \
|
||||||
|
--filter "customer-manager" \
|
||||||
|
--filter "feedback-commander" \
|
||||||
|
--filter "people-manager" \
|
||||||
|
--filter "./packages/acquisition" \
|
||||||
|
--filter "./packages/mail"
|
||||||
|
|
||||||
|
|
||||||
|
# Runtime Stage
|
||||||
FROM directus/directus:11
|
FROM directus/directus:11
|
||||||
|
|
||||||
USER root
|
WORKDIR /directus
|
||||||
# Install dependencies in a way that avoids metadata conflicts in the root
|
|
||||||
RUN mkdir -p /directus/lib-dependencies && \
|
# Copy built extensions
|
||||||
cd /directus/lib-dependencies && \
|
COPY --from=builder /app/packages/cms-infra/extensions ./extensions
|
||||||
npm init -y && \
|
|
||||||
npm install vue @vueuse/core vue-router
|
# Environment defaults (can be overridden)
|
||||||
# Ensure they are in the NODE_PATH
|
ENV KEY="infra-cms-key"
|
||||||
ENV NODE_PATH="/directus/lib-dependencies/node_modules:${NODE_PATH}"
|
ENV SECRET="infra-cms-secret"
|
||||||
USER node
|
ENV DB_CLIENT="sqlite3"
|
||||||
|
ENV DB_FILENAME="/directus/database/data.db"
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8055
|
||||||
|
|||||||
Binary file not shown.
@@ -1,6 +1,9 @@
|
|||||||
services:
|
services:
|
||||||
infra-cms:
|
infra-cms:
|
||||||
image: directus/directus:11.15.2
|
build:
|
||||||
|
context: ../../
|
||||||
|
dockerfile: packages/cms-infra/Dockerfile
|
||||||
|
image: mintel/cms-infra:latest
|
||||||
ports:
|
ports:
|
||||||
- "8059:8055"
|
- "8059:8055"
|
||||||
networks:
|
networks:
|
||||||
@@ -14,7 +17,6 @@ services:
|
|||||||
DB_CLIENT: "sqlite3"
|
DB_CLIENT: "sqlite3"
|
||||||
DB_FILENAME: "/directus/database/data.db"
|
DB_FILENAME: "/directus/database/data.db"
|
||||||
WEBSOCKETS_ENABLED: "true"
|
WEBSOCKETS_ENABLED: "true"
|
||||||
PUBLIC_URL: "http://cms.localhost"
|
|
||||||
EMAIL_TRANSPORT: "smtp"
|
EMAIL_TRANSPORT: "smtp"
|
||||||
EMAIL_SMTP_HOST: "smtp.eu.mailgun.org"
|
EMAIL_SMTP_HOST: "smtp.eu.mailgun.org"
|
||||||
EMAIL_SMTP_PORT: "587"
|
EMAIL_SMTP_PORT: "587"
|
||||||
@@ -22,32 +24,20 @@ services:
|
|||||||
EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6"
|
EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6"
|
||||||
EMAIL_SMTP_SECURE: "false"
|
EMAIL_SMTP_SECURE: "false"
|
||||||
EMAIL_FROM: "postmaster@mg.mintel.me"
|
EMAIL_FROM: "postmaster@mg.mintel.me"
|
||||||
LOG_LEVEL: "debug"
|
LOG_LEVEL: "trace"
|
||||||
SERVE_APP: "true"
|
|
||||||
EXTENSIONS_AUTO_RELOAD: "true"
|
|
||||||
EXTENSIONS_SANDBOX: "false"
|
|
||||||
CONTENT_SECURITY_POLICY: "false"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./database:/directus/database
|
- ./database:/directus/database
|
||||||
- ./uploads:/directus/uploads
|
- ./uploads:/directus/uploads
|
||||||
- ./schema:/directus/schema
|
- ./schema:/directus/schema
|
||||||
- ./extensions:/directus/extensions
|
- ./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:
|
labels:
|
||||||
traefik.enable: "true"
|
- "traefik.enable=true"
|
||||||
traefik.http.routers.at-mintel-infra-cms.rule: "Host(`cms.localhost`)"
|
- "traefik.http.routers.infra-cms.rule=Host(`cms.localhost`)"
|
||||||
traefik.docker.network: "infra"
|
- "traefik.http.services.infra-cms.loadbalancer.server.port=8055"
|
||||||
caddy: "http://cms.localhost"
|
- "traefik.docker.network=infra"
|
||||||
caddy.reverse_proxy: "{{upstreams 8055}}"
|
|
||||||
caddy.header.Cache-Control: "no-store, no-cache, must-revalidate, max-age=0"
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: at-mintel-cms-network
|
name: mintel-infra-cms-internal
|
||||||
infra:
|
infra:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "acquisition-manager",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "module",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "src/index.ts",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "directus-extension build",
|
||||||
|
"dev": "directus-extension build -w"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
|
"vue": "^3.4.34",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
-157
@@ -1,16 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<MintelManagerLayout
|
<private-view title="Acquisition Manager">
|
||||||
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>
|
<template #navigation>
|
||||||
<v-list nav>
|
<v-list nav>
|
||||||
<v-list-item @click="openCreateDrawer" clickable>
|
<v-list-item @click="showAddLead = true" clickable>
|
||||||
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
|
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
|
||||||
<v-list-item-content>
|
<v-list-item-content>
|
||||||
<v-text-overflow text="Neuen Lead anlegen" />
|
<v-text-overflow text="Neuen Lead anlegen" />
|
||||||
@@ -23,7 +15,7 @@
|
|||||||
v-for="lead in leads"
|
v-for="lead in leads"
|
||||||
:key="lead.id"
|
:key="lead.id"
|
||||||
:active="selectedLeadId === lead.id"
|
:active="selectedLeadId === lead.id"
|
||||||
class="nav-item"
|
class="lead-item"
|
||||||
clickable
|
clickable
|
||||||
@click="selectLead(lead.id)"
|
@click="selectLead(lead.id)"
|
||||||
>
|
>
|
||||||
@@ -31,25 +23,45 @@
|
|||||||
<v-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" />
|
<v-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" />
|
||||||
</v-list-item-icon>
|
</v-list-item-icon>
|
||||||
<v-list-item-content>
|
<v-list-item-content>
|
||||||
<v-text-overflow :text="getCompanyName(lead)" />
|
<v-text-overflow :text="lead.company_name" />
|
||||||
</v-list-item-content>
|
</v-list-item-content>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #subtitle>
|
<template #title-outer:after>
|
||||||
<template v-if="selectedLead">
|
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible>
|
||||||
|
{{ notice.message }}
|
||||||
|
</v-notice>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="content-wrapper">
|
||||||
|
<v-notice type="success" style="margin-bottom: 16px;">
|
||||||
|
DEBUG: Module Version 1.1.0 - Native Build - {{ new Date().toISOString() }}
|
||||||
|
</v-notice>
|
||||||
|
|
||||||
|
<div v-if="!selectedLead" class="empty-state">
|
||||||
|
<v-info title="Lead auswählen" icon="auto_awesome" center>
|
||||||
|
Wähle einen Lead in der Navigation aus oder
|
||||||
|
<v-button x-small @click="showAddLead = true">registriere einen neuen Lead</v-button>.
|
||||||
|
</v-info>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1 class="title">{{ selectedLead.company_name }}</h1>
|
||||||
|
<p class="subtitle">
|
||||||
<v-icon name="language" x-small />
|
<v-icon name="language" x-small />
|
||||||
<a :href="selectedLead.website_url" target="_blank" class="url-link">
|
<a :href="selectedLead.website_url" target="_blank" class="url-link">
|
||||||
{{ selectedLead.website_url.replace(/^https?:\/\//, '') }}
|
{{ selectedLead.website_url.replace(/^https?:\/\//, '') }}
|
||||||
</a>
|
</a>
|
||||||
· Status: {{ selectedLead.status.toUpperCase() }}
|
· Status: {{ selectedLead.status.toUpperCase() }}
|
||||||
</template>
|
</p>
|
||||||
</template>
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
<template #actions>
|
|
||||||
<v-button
|
<v-button
|
||||||
v-if="selectedLead?.status === 'new'"
|
v-if="selectedLead.status === 'new'"
|
||||||
secondary
|
secondary
|
||||||
:loading="loadingAudit"
|
:loading="loadingAudit"
|
||||||
@click="runAudit"
|
@click="runAudit"
|
||||||
@@ -58,7 +70,7 @@
|
|||||||
Audit starten
|
Audit starten
|
||||||
</v-button>
|
</v-button>
|
||||||
|
|
||||||
<template v-if="selectedLead?.status === 'audit_ready'">
|
<template v-if="selectedLead.status === 'audit_ready'">
|
||||||
<v-button secondary :loading="loadingEmail" @click="sendAuditEmail">
|
<v-button secondary :loading="loadingEmail" @click="sendAuditEmail">
|
||||||
<v-icon name="mail" left />
|
<v-icon name="mail" left />
|
||||||
Audit E-Mail
|
Audit E-Mail
|
||||||
@@ -69,21 +81,12 @@
|
|||||||
</v-button>
|
</v-button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-button v-if="selectedLead?.audit_pdf_path" secondary icon v-tooltip.bottom="'PDF öffnen'" @click="openPdf">
|
<v-button v-if="selectedLead.audit_pdf_path" secondary icon v-tooltip.bottom="'PDF öffnen'" @click="openPdf">
|
||||||
<v-icon name="open_in_new" />
|
<v-icon name="open_in_new" />
|
||||||
</v-button>
|
</v-button>
|
||||||
|
|
||||||
<v-button
|
<v-button
|
||||||
v-if="selectedLead && !isCustomer(selectedLead.company)"
|
v-if="selectedLead.audit_pdf_path"
|
||||||
secondary
|
|
||||||
@click="linkAsCustomer"
|
|
||||||
>
|
|
||||||
<v-icon name="handshake" left />
|
|
||||||
Kunde verlinken
|
|
||||||
</v-button>
|
|
||||||
|
|
||||||
<v-button
|
|
||||||
v-if="selectedLead?.audit_pdf_path"
|
|
||||||
primary
|
primary
|
||||||
:loading="loadingEmail"
|
:loading="loadingEmail"
|
||||||
@click="sendEstimateEmail"
|
@click="sendEstimateEmail"
|
||||||
@@ -91,14 +94,10 @@
|
|||||||
<v-icon name="send" left />
|
<v-icon name="send" left />
|
||||||
Angebot senden
|
Angebot senden
|
||||||
</v-button>
|
</v-button>
|
||||||
</template>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<template #empty-state>
|
<div class="sections">
|
||||||
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="main-info">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -108,6 +107,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="value text-subdued">Keine Person verknüpft</div>
|
<div v-else class="value text-subdued">Keine Person verknüpft</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">E-Mail (Legacy)</span>
|
||||||
|
<div class="value">{{ selectedLead.contact_email || '—' }}</div>
|
||||||
|
</div>
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<span class="label">Briefing / Fokus</span>
|
<span class="label">Briefing / Fokus</span>
|
||||||
<div class="value text-block">{{ selectedLead.briefing || 'Kein Briefing hinterlegt.' }}</div>
|
<div class="value text-block">{{ selectedLead.briefing || 'Kein Briefing hinterlegt.' }}</div>
|
||||||
@@ -121,8 +124,8 @@
|
|||||||
<h3 class="section-title">AI Observations & Estimation</h3>
|
<h3 class="section-title">AI Observations & Estimation</h3>
|
||||||
|
|
||||||
<div class="metrics">
|
<div class="metrics">
|
||||||
<MintelStatCard label="Projekt-Modus" :value="selectedLead.ai_state.projectType || 'Unbekannt'" icon="category" />
|
<v-info label="Projekt-Modus" :value="selectedLead.ai_state.projectType || 'Unbekannt'" />
|
||||||
<MintelStatCard label="Seitenanzahl" :value="selectedLead.ai_state.sitemap?.length || '0'" icon="description" />
|
<v-info label="Seitenanzahl" :value="selectedLead.ai_state.sitemap?.length || '0'" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-table
|
<v-table
|
||||||
@@ -140,42 +143,44 @@
|
|||||||
</v-table>
|
</v-table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Drawer: New Lead -->
|
<!-- Drawer: New Lead -->
|
||||||
<v-drawer
|
<v-drawer
|
||||||
v-model="drawerActive"
|
v-model="showAddLead"
|
||||||
title="Neuen Lead registrieren"
|
title="Neuen Lead registrieren"
|
||||||
icon="person_add"
|
icon="person_add"
|
||||||
@cancel="drawerActive = false"
|
@cancel="showAddLead = false"
|
||||||
>
|
>
|
||||||
<div class="drawer-content">
|
<div class="drawer-content">
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="label">Organisation / Firma (Zentral)</span>
|
<span class="label">Firma</span>
|
||||||
<MintelSelect
|
<v-input v-model="newLead.company_name" placeholder="z.B. Schmidt GmbH" autofocus />
|
||||||
v-model="newLead.company"
|
|
||||||
:items="companyOptions"
|
|
||||||
placeholder="Bestehende Firma auswählen..."
|
|
||||||
allow-add
|
|
||||||
@add="openQuickAdd('company')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="label">Website URL</span>
|
<span class="label">Website URL</span>
|
||||||
<v-input v-model="newLead.website_url" placeholder="https://..." />
|
<v-input v-model="newLead.website_url" placeholder="https://..." />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Ansprechpartner</span>
|
||||||
|
<v-input v-model="newLead.contact_name" placeholder="Vorname Nachname" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">E-Mail Adresse</span>
|
||||||
|
<v-input v-model="newLead.contact_email" placeholder="email@beispiel.de" type="email" />
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="label">Briefing / Fokus</span>
|
<span class="label">Briefing / Fokus</span>
|
||||||
<v-textarea v-model="newLead.briefing" placeholder="Besonderheiten für das Audit..." />
|
<v-textarea v-model="newLead.briefing" placeholder="Besonderheiten für das Audit..." />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="label">Kontaktperson (Optional)</span>
|
<span class="label">Kontaktperson (Optional)</span>
|
||||||
<MintelSelect
|
<v-select
|
||||||
v-model="newLead.contact_person"
|
v-model="newLead.contact_person"
|
||||||
:items="peopleOptions"
|
:items="peopleOptions"
|
||||||
placeholder="Person auswählen..."
|
placeholder="Person auswählen..."
|
||||||
allow-add
|
|
||||||
@add="openQuickAdd('person')"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,45 +190,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-drawer>
|
</v-drawer>
|
||||||
</MintelManagerLayout>
|
</private-view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
import { useApi } from '@directus/extensions-sdk';
|
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 api = useApi();
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
const leads = ref<any[]>([]);
|
const leads = ref<any[]>([]);
|
||||||
const selectedLeadId = ref<string | null>(null);
|
const selectedLeadId = ref<string | null>(null);
|
||||||
const loadingAudit = ref(false);
|
const loadingAudit = ref(false);
|
||||||
const loadingPdf = ref(false);
|
const loadingPdf = ref(false);
|
||||||
const loadingEmail = ref(false);
|
const loadingEmail = ref(false);
|
||||||
const drawerActive = ref(false);
|
const showAddLead = ref(false);
|
||||||
const savingLead = ref(false);
|
const savingLead = ref(false);
|
||||||
const notice = ref<{ type: string; message: string } | null>(null);
|
const notice = ref<{ type: string; message: string } | null>(null);
|
||||||
|
|
||||||
const newLead = ref({
|
const newLead = ref({
|
||||||
company: null,
|
company_name: '',
|
||||||
website_url: '',
|
website_url: '',
|
||||||
|
contact_name: '',
|
||||||
|
contact_email: '',
|
||||||
contact_person: null,
|
contact_person: null,
|
||||||
briefing: '',
|
briefing: '',
|
||||||
status: 'new'
|
status: 'new'
|
||||||
});
|
});
|
||||||
|
|
||||||
const companies = ref<any[]>([]);
|
|
||||||
const people = 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(() =>
|
const peopleOptions = computed(() =>
|
||||||
people.value.map(p => ({
|
people.value.map(p => ({
|
||||||
@@ -232,99 +226,36 @@ const peopleOptions = computed(() =>
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
function getCompanyName(lead: any) {
|
function getPersonName(id: string) {
|
||||||
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);
|
const person = people.value.find(p => p.id === id);
|
||||||
return person ? `${person.first_name} ${person.last_name}` : id;
|
return person ? `${person.first_name} ${person.last_name}` : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToPerson(id: string) {
|
function goToPerson(id: string) {
|
||||||
|
// Logic to navigate to people manager or open details
|
||||||
notice.value = { type: 'info', message: `Navigiere zu Person: ${id}` };
|
notice.value = { type: 'info', message: `Navigiere zu Person: ${id}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value));
|
const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value));
|
||||||
|
|
||||||
async function fetchData() {
|
onMounted(fetchLeads);
|
||||||
try {
|
|
||||||
const [leadsResp, peopleResp, companiesResp, customersResp] = await Promise.all([
|
async function fetchLeads() {
|
||||||
api.get('/items/leads', {
|
const [leadsResp, peopleResp] = await Promise.all([
|
||||||
params: {
|
api.get('/items/leads', { params: { sort: '-date_created' } }),
|
||||||
sort: '-date_created',
|
api.get('/items/people', { params: { sort: 'last_name' } })
|
||||||
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;
|
leads.value = leadsResp.data.data;
|
||||||
people.value = peopleResp.data.data;
|
people.value = peopleResp.data.data;
|
||||||
companies.value = companiesResp.data.data;
|
|
||||||
customers.value = customersResp.data.data;
|
|
||||||
|
|
||||||
if (!selectedLeadId.value && leads.value.length > 0) {
|
if (!selectedLeadId.value && leads.value.length > 0) {
|
||||||
selectedLeadId.value = leads.value[0].id;
|
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) {
|
function selectLead(id: string) {
|
||||||
selectedLeadId.value = id;
|
selectedLeadId.value = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateDrawer() {
|
|
||||||
newLead.value = {
|
|
||||||
company: null,
|
|
||||||
website_url: '',
|
|
||||||
contact_person: null,
|
|
||||||
briefing: '',
|
|
||||||
status: 'new'
|
|
||||||
};
|
|
||||||
drawerActive.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runAudit() {
|
async function runAudit() {
|
||||||
if (!selectedLeadId.value) return;
|
if (!selectedLeadId.value) return;
|
||||||
loadingAudit.value = true;
|
loadingAudit.value = true;
|
||||||
@@ -387,10 +318,7 @@ function openPdf() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveLead() {
|
async function saveLead() {
|
||||||
if (!newLead.value.company) {
|
if (!newLead.value.company_name) return;
|
||||||
notice.value = { type: 'danger', message: 'Organisation erforderlich.' };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
savingLead.value = true;
|
savingLead.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -399,9 +327,18 @@ async function saveLead() {
|
|||||||
};
|
};
|
||||||
await api.post('/items/leads', payload);
|
await api.post('/items/leads', payload);
|
||||||
notice.value = { type: 'success', message: 'Lead erfolgreich registriert!' };
|
notice.value = { type: 'success', message: 'Lead erfolgreich registriert!' };
|
||||||
drawerActive.value = false;
|
showAddLead.value = false;
|
||||||
await fetchLeads();
|
await fetchLeads();
|
||||||
selectedLeadId.value = payload.id;
|
selectedLeadId.value = payload.id;
|
||||||
|
newLead.value = {
|
||||||
|
company_name: '',
|
||||||
|
website_url: '',
|
||||||
|
contact_name: '',
|
||||||
|
contact_email: '',
|
||||||
|
contact_person: null,
|
||||||
|
briefing: '',
|
||||||
|
status: 'new'
|
||||||
|
};
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
notice.value = { type: 'danger', message: `Fehler beim Speichern: ${e.message}` };
|
notice.value = { type: 'danger', message: `Fehler beim Speichern: ${e.message}` };
|
||||||
} finally {
|
} finally {
|
||||||
@@ -409,10 +346,6 @@ async function saveLead() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openQuickAdd(type: string) {
|
|
||||||
notice.value = { type: 'info', message: `${type === 'company' ? 'Firma' : 'Person'} im jeweiligen Manager anlegen.` };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusIcon(status: string) {
|
function getStatusIcon(status: string) {
|
||||||
switch(status) {
|
switch(status) {
|
||||||
case 'new': return 'fiber_new';
|
case 'new': return 'fiber_new';
|
||||||
@@ -432,18 +365,18 @@ function getStatusColor(status: string) {
|
|||||||
default: return 'var(--theme--foreground-subdued)';
|
default: return 'var(--theme--foreground-subdued)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await fetchData();
|
|
||||||
if (route.query.create === 'true') {
|
|
||||||
openCreateDrawer();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; overflow-y: auto; }
|
||||||
|
.lead-item { cursor: pointer; }
|
||||||
|
.header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end; }
|
||||||
|
.header-right { display: flex; gap: 12px; }
|
||||||
|
.title { font-size: 24px; font-weight: 800; margin-bottom: 4px; color: var(--theme--foreground); }
|
||||||
|
.subtitle { color: var(--theme--foreground-subdued); font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
||||||
.url-link { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; }
|
.url-link { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; }
|
||||||
.url-link:hover { border-bottom-color: currentColor; }
|
.url-link:hover { border-bottom-color: currentColor; }
|
||||||
|
.empty-state { height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||||
|
|
||||||
.sections { display: flex; flex-direction: column; gap: 32px; }
|
.sections { display: flex; flex-direction: column; gap: 32px; }
|
||||||
|
|
||||||
@@ -456,7 +389,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.ai-observations { display: flex; flex-direction: column; gap: 16px; }
|
.ai-observations { display: flex; flex-direction: column; gap: 16px; }
|
||||||
.section-title { font-size: 16px; font-weight: 700; color: var(--theme--foreground); margin-bottom: 8px; }
|
.section-title { font-size: 16px; font-weight: 700; color: var(--theme--foreground); margin-bottom: 8px; }
|
||||||
.metrics { display: flex; gap: 24px; margin-bottom: 16px; }
|
.metrics { display: flex; gap: 32px; margin-bottom: 16px; }
|
||||||
|
|
||||||
.observation-table { border: 1px solid var(--theme--border); border-radius: 8px; overflow: hidden; }
|
.observation-table { border: 1px solid var(--theme--border); border-radius: 8px; overflow: hidden; }
|
||||||
.page-title { font-weight: 600; }
|
.page-title { font-weight: 600; }
|
||||||
@@ -465,4 +398,6 @@ onMounted(async () => {
|
|||||||
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
|
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
|
||||||
.form-section { display: flex; flex-direction: column; gap: 20px; }
|
.form-section { display: flex; flex-direction: column; gap: 20px; }
|
||||||
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
|
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
:deep(.v-list-item) { cursor: pointer !important; }
|
||||||
</style>
|
</style>
|
||||||
+11
-16
@@ -11,9 +11,7 @@ const outfile = resolve(__dirname, 'dist/index.js');
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
mkdirSync(dirname(outfile), { recursive: true });
|
mkdirSync(dirname(outfile), { recursive: true });
|
||||||
} catch {
|
} catch (e) { }
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Building from ${entryPoint} to ${outfile}...`);
|
console.log(`Building from ${entryPoint} to ${outfile}...`);
|
||||||
|
|
||||||
@@ -23,28 +21,25 @@ build({
|
|||||||
platform: 'node',
|
platform: 'node',
|
||||||
target: 'node18',
|
target: 'node18',
|
||||||
outfile: outfile,
|
outfile: outfile,
|
||||||
jsx: 'automatic',
|
|
||||||
format: 'esm',
|
format: 'esm',
|
||||||
// footer: {
|
// Bundle everything, including Directus SDK, to avoid resolution issues in Docker
|
||||||
// js: "module.exports = module.exports.default || module.exports;",
|
external: [],
|
||||||
// },
|
|
||||||
loader: {
|
|
||||||
'.tsx': 'tsx',
|
|
||||||
'.ts': 'ts',
|
|
||||||
'.js': 'js',
|
|
||||||
},
|
|
||||||
external: ["canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"],
|
|
||||||
plugins: [{
|
plugins: [{
|
||||||
|
name: 'mock-jquery',
|
||||||
|
setup(build) {
|
||||||
|
build.onResolve({ filter: /^jquery$/ }, args => ({ path: args.path, namespace: 'mock-jquery' }));
|
||||||
|
build.onLoad({ filter: /.*/, namespace: 'mock-jquery' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
||||||
|
}
|
||||||
|
}, {
|
||||||
name: 'mock-canvas',
|
name: 'mock-canvas',
|
||||||
setup(build) {
|
setup(build) {
|
||||||
build.onResolve({ filter: /^canvas/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
|
build.onResolve({ filter: /^canvas$/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
|
||||||
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
name: 'mock-jsdom',
|
name: 'mock-jsdom',
|
||||||
setup(build) {
|
setup(build) {
|
||||||
build.onResolve({ filter: /^jsdom/ }, args => ({ path: args.path, namespace: 'mock-jsdom' }));
|
return;
|
||||||
build.onLoad({ filter: /.*/, namespace: 'mock-jsdom' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "acquisition",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "src/index.ts",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.js",
|
||||||
|
"dev": "node build.js --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
|
"@mintel/acquisition": "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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import "./shim";
|
||||||
|
import { defineEndpoint } from "@directus/extensions-sdk";
|
||||||
|
import { AcquisitionService, PdfEngine } from "@mintel/acquisition";
|
||||||
|
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 leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||||
|
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
|
||||||
|
const mailService = new MailService({ 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 Audit not ready" });
|
||||||
|
|
||||||
|
let recipientEmail = lead.contact_email;
|
||||||
|
let companyName = lead.company_name;
|
||||||
|
|
||||||
|
if (lead.contact_person) {
|
||||||
|
const person = await peopleService.readOne(lead.contact_person);
|
||||||
|
if (person && person.email) {
|
||||||
|
recipientEmail = person.email;
|
||||||
|
companyName = person.company || lead.company_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 mailService = new MailService({ schema: req.schema, accountability: req.accountability });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lead = await leadsService.readOne(id);
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (lead.contact_person) {
|
||||||
|
const person = await peopleService.readOne(lead.contact_person);
|
||||||
|
if (person && person.email) {
|
||||||
|
recipientEmail = person.email;
|
||||||
|
companyName = person.company || lead.company_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = import.meta?.url;
|
||||||
|
// Hardcode fallback path for Directus Docker environment
|
||||||
|
const fallbackPath = '/directus/extensions/acquisition/dist/index.js';
|
||||||
|
const filename = url ? fileURLToPath(url) : fallbackPath;
|
||||||
|
const dir = dirname(filename);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
globalThis.__filename = filename;
|
||||||
|
// @ts-ignore
|
||||||
|
globalThis.__dirname = dir;
|
||||||
|
// @ts-ignore
|
||||||
|
globalThis.require = createRequire(url || `file://${fallbackPath}`);
|
||||||
|
|
||||||
|
console.log(`[Shim] Loaded. __dirname: ${dir}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Shim] Failed to shim __dirname/require", e);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "customer-manager",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "module",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "src/index.ts",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "directus-extension build",
|
||||||
|
"dev": "directus-extension build -w"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
|
"vue": "^3.4.34",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -2,9 +2,9 @@ import { defineModule } from '@directus/extensions-sdk';
|
|||||||
import ModuleComponent from './module.vue';
|
import ModuleComponent from './module.vue';
|
||||||
|
|
||||||
export default defineModule({
|
export default defineModule({
|
||||||
id: 'company-manager',
|
id: 'customer-manager',
|
||||||
name: 'Company Manager',
|
name: 'Customer Manager',
|
||||||
icon: 'business',
|
icon: 'supervisor_account',
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
<template>
|
||||||
|
<private-view title="Customer Manager">
|
||||||
|
<template #navigation>
|
||||||
|
<v-list nav>
|
||||||
|
<v-list-item @click="openCreateCompany" 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="company-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 #title-outer:after>
|
||||||
|
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible>
|
||||||
|
{{ notice.message }}
|
||||||
|
</v-notice>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="content-wrapper">
|
||||||
|
<div v-if="!selectedCompany" class="empty-state">
|
||||||
|
<v-info title="Firmen auswählen" icon="business" center>
|
||||||
|
Wähle eine Firma in der Navigation aus oder
|
||||||
|
<v-button x-small @click="openCreateCompany">erstelle eine neue Firma</v-button>.
|
||||||
|
</v-info>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1 class="title">{{ selectedCompany.name }}</h1>
|
||||||
|
<p class="subtitle">{{ employees.length }} Kunden-Mitarbeiter</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<v-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openEditCompany">
|
||||||
|
<v-icon name="edit" />
|
||||||
|
</v-button>
|
||||||
|
<v-button primary @click="openCreateEmployee">
|
||||||
|
Mitarbeiter hinzufügen
|
||||||
|
</v-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<v-table
|
||||||
|
:headers="tableHeaders"
|
||||||
|
:items="employees"
|
||||||
|
:loading="loading"
|
||||||
|
class="clickable-table"
|
||||||
|
fixed-header
|
||||||
|
@click:row="onRowClick"
|
||||||
|
>
|
||||||
|
<template #[`item.name`]="{ item }">
|
||||||
|
<div class="user-cell">
|
||||||
|
<v-avatar :name="item.first_name" x-small />
|
||||||
|
<span class="user-name">{{ item.first_name }} {{ item.last_name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`item.last_invited`]="{ item }">
|
||||||
|
<span v-if="item.last_invited" class="status-date">
|
||||||
|
{{ formatDate(item.last_invited) }}
|
||||||
|
</span>
|
||||||
|
<v-chip v-else x-small>Noch nie</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-table>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer: Company Form -->
|
||||||
|
<v-drawer
|
||||||
|
v-model="drawerCompanyActive"
|
||||||
|
:title="isEditingCompany ? 'Firma bearbeiten' : 'Neue Firma anlegen'"
|
||||||
|
icon="business"
|
||||||
|
@cancel="drawerCompanyActive = false"
|
||||||
|
>
|
||||||
|
<div v-if="drawerCompanyActive" class="drawer-content">
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Firmenname</span>
|
||||||
|
<v-input v-model="companyForm.name" placeholder="z.B. KLZ Cables" autofocus />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-actions">
|
||||||
|
<v-button primary block :loading="saving" @click="saveCompany">Speichern</v-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-drawer>
|
||||||
|
|
||||||
|
<!-- Drawer: Employee Form -->
|
||||||
|
<v-drawer
|
||||||
|
v-model="drawerEmployeeActive"
|
||||||
|
:title="isEditingEmployee ? 'Mitarbeiter bearbeiten' : 'Neuen Mitarbeiter anlegen'"
|
||||||
|
icon="person"
|
||||||
|
@cancel="drawerEmployeeActive = false"
|
||||||
|
>
|
||||||
|
<div v-if="drawerEmployeeActive" class="drawer-content">
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Vorname</span>
|
||||||
|
<v-input v-model="employeeForm.first_name" placeholder="Vorname" autofocus />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Nachname</span>
|
||||||
|
<v-input v-model="employeeForm.last_name" placeholder="Nachname" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">E-Mail</span>
|
||||||
|
<v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Zugehörige Person (Zentral)</span>
|
||||||
|
<v-select
|
||||||
|
v-model="employeeForm.contact_person"
|
||||||
|
:items="peopleOptions"
|
||||||
|
placeholder="Zentrale Person auswählen..."
|
||||||
|
show-deselect
|
||||||
|
/>
|
||||||
|
<p class="field-note">Verknüpft diesen Mitarbeiter mit dem globalen Personen-Verzeichnis.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider v-if="isEditingEmployee" />
|
||||||
|
|
||||||
|
<div v-if="isEditingEmployee" class="field">
|
||||||
|
<span class="label">Temporäres Passwort</span>
|
||||||
|
<v-input v-model="employeeForm.temporary_password" readonly class="password-input" />
|
||||||
|
<p class="field-note">Wird beim Senden der Zugangsdaten automatisch generiert.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-actions">
|
||||||
|
<v-button primary block :loading="saving" @click="saveEmployee">Daten speichern</v-button>
|
||||||
|
|
||||||
|
<template v-if="isEditingEmployee">
|
||||||
|
<v-divider />
|
||||||
|
<v-button
|
||||||
|
v-tooltip.bottom="'Generiert PW, speichert es und sendet E-Mail'"
|
||||||
|
secondary
|
||||||
|
block
|
||||||
|
:loading="invitingId === employeeForm.id"
|
||||||
|
@click="inviteUser(employeeForm)"
|
||||||
|
>
|
||||||
|
<v-icon name="send" left /> Zugangsdaten senden
|
||||||
|
</v-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-drawer>
|
||||||
|
</private-view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, nextTick, computed } from 'vue';
|
||||||
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
const companies = ref<any[]>([]);
|
||||||
|
const selectedCompany = ref<any>(null);
|
||||||
|
const employees = ref<any[]>([]);
|
||||||
|
const people = 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 peopleOptions = computed(() =>
|
||||||
|
people.value.map(p => ({
|
||||||
|
text: `${p.first_name} ${p.last_name} (${p.company || 'Keine Firma'})`,
|
||||||
|
value: p.id
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Forms State
|
||||||
|
const drawerCompanyActive = ref(false);
|
||||||
|
const isEditingCompany = ref(false);
|
||||||
|
const companyForm = ref({ id: '', name: '' });
|
||||||
|
|
||||||
|
const drawerEmployeeActive = ref(false);
|
||||||
|
const isEditingEmployee = ref(false);
|
||||||
|
const employeeForm = ref({
|
||||||
|
id: '',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
email: '',
|
||||||
|
contact_person: null as string | null,
|
||||||
|
temporary_password: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableHeaders = [
|
||||||
|
{ text: 'Name', value: 'name', sortable: true },
|
||||||
|
{ text: 'E-Mail', value: 'email', sortable: true },
|
||||||
|
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
async function fetchCompanies() {
|
||||||
|
const [companyRes, peopleRes] = await Promise.all([
|
||||||
|
api.get('/items/companies', { params: { fields: ['id', 'name'], sort: 'name' } }),
|
||||||
|
api.get('/items/people', { params: { sort: 'last_name' } })
|
||||||
|
]);
|
||||||
|
companies.value = companyRes.data.data;
|
||||||
|
people.value = peopleRes.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectCompany(company: any) {
|
||||||
|
selectedCompany.value = company;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get('/items/client_users', {
|
||||||
|
params: {
|
||||||
|
filter: { company: { _eq: company.id } },
|
||||||
|
fields: ['*'],
|
||||||
|
sort: 'first_name',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
employees.value = res.data.data;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Company Actions
|
||||||
|
function openCreateCompany() {
|
||||||
|
isEditingCompany.value = false;
|
||||||
|
companyForm.value = { id: '', name: '' };
|
||||||
|
drawerCompanyActive.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditCompany() {
|
||||||
|
if (!selectedCompany.value) return;
|
||||||
|
companyForm.value = {
|
||||||
|
id: selectedCompany.value.id,
|
||||||
|
name: selectedCompany.value.name
|
||||||
|
};
|
||||||
|
isEditingCompany.value = true;
|
||||||
|
await nextTick();
|
||||||
|
drawerCompanyActive.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCompany() {
|
||||||
|
if (!companyForm.value.name) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (isEditingCompany.value) {
|
||||||
|
await api.patch(`/items/companies/${companyForm.value.id}`, { name: companyForm.value.name });
|
||||||
|
notice.value = { type: 'success', message: 'Firma aktualisiert!' };
|
||||||
|
} else {
|
||||||
|
await api.post('/items/companies', { name: companyForm.value.name });
|
||||||
|
notice.value = { type: 'success', message: 'Firma angelegt!' };
|
||||||
|
}
|
||||||
|
drawerCompanyActive.value = false;
|
||||||
|
await fetchCompanies();
|
||||||
|
if (selectedCompany.value?.id === companyForm.value.id) {
|
||||||
|
selectedCompany.value.name = companyForm.value.name;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
notice.value = { type: 'danger', message: e.message };
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Employee Actions
|
||||||
|
function openCreateEmployee() {
|
||||||
|
isEditingEmployee.value = false;
|
||||||
|
employeeForm.value = { id: '', first_name: '', last_name: '', email: '', contact_person: null, temporary_password: '' };
|
||||||
|
drawerEmployeeActive.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditEmployee(item: any) {
|
||||||
|
employeeForm.value = {
|
||||||
|
id: item.id || '',
|
||||||
|
first_name: item.first_name || '',
|
||||||
|
last_name: item.last_name || '',
|
||||||
|
email: item.email || '',
|
||||||
|
contact_person: item.contact_person || null,
|
||||||
|
temporary_password: item.temporary_password || ''
|
||||||
|
};
|
||||||
|
isEditingEmployee.value = true;
|
||||||
|
await nextTick();
|
||||||
|
drawerEmployeeActive.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEmployee() {
|
||||||
|
if (!employeeForm.value.email || !selectedCompany.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (isEditingEmployee.value) {
|
||||||
|
await api.patch(`/items/client_users/${employeeForm.value.id}`, {
|
||||||
|
first_name: employeeForm.value.first_name,
|
||||||
|
last_name: employeeForm.value.last_name,
|
||||||
|
email: employeeForm.value.email,
|
||||||
|
contact_person: employeeForm.value.contact_person
|
||||||
|
});
|
||||||
|
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
|
||||||
|
} else {
|
||||||
|
await api.post('/items/client_users', {
|
||||||
|
first_name: employeeForm.value.first_name,
|
||||||
|
last_name: employeeForm.value.last_name,
|
||||||
|
email: employeeForm.value.email,
|
||||||
|
company: selectedCompany.value.id,
|
||||||
|
contact_person: employeeForm.value.contact_person
|
||||||
|
});
|
||||||
|
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
|
||||||
|
}
|
||||||
|
drawerEmployeeActive.value = false;
|
||||||
|
await selectCompany(selectedCompany.value);
|
||||||
|
} catch (e: any) {
|
||||||
|
notice.value = { type: 'danger', message: e.message };
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inviteUser(user: any) {
|
||||||
|
invitingId.value = user.id;
|
||||||
|
try {
|
||||||
|
await api.post(`/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501`, [user.id]);
|
||||||
|
notice.value = { type: 'success', message: `Zugangsdaten für ${user.first_name} versendet. 📧` };
|
||||||
|
await selectCompany(selectedCompany.value);
|
||||||
|
if (drawerEmployeeActive.value && employeeForm.value.id === user.id) {
|
||||||
|
const updated = employees.value.find(e => e.id === user.id);
|
||||||
|
if (updated) {
|
||||||
|
employeeForm.value.temporary_password = updated.temporary_password;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
notice.value = { type: 'danger', message: `Fehler: ${e.message}` };
|
||||||
|
} finally {
|
||||||
|
invitingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowClick(event: any) {
|
||||||
|
const item = event?.item || event;
|
||||||
|
if (item && item.id) {
|
||||||
|
openEditEmployee(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchCompanies();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; }
|
||||||
|
.company-item { cursor: pointer; }
|
||||||
|
.header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end; }
|
||||||
|
.header-right { display: flex; gap: 12px; }
|
||||||
|
.title { font-size: 24px; font-weight: 800; margin-bottom: 4px; }
|
||||||
|
.subtitle { color: var(--theme--foreground-subdued); font-size: 14px; }
|
||||||
|
.empty-state { height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.user-cell { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.user-name { font-weight: 600; }
|
||||||
|
.status-date { font-size: 12px; color: var(--theme--foreground-subdued); }
|
||||||
|
.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; }
|
||||||
|
:deep(.v-list-item) { cursor: pointer !important; }
|
||||||
|
</style>
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "feedback-commander",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "module",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "src/index.ts",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "directus-extension build",
|
||||||
|
"dev": "directus-extension build -w"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
|
"vue": "^3.4.34",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -2,9 +2,9 @@ import { defineModule } from '@directus/extensions-sdk';
|
|||||||
import ModuleComponent from './module.vue';
|
import ModuleComponent from './module.vue';
|
||||||
|
|
||||||
export default defineModule({
|
export default defineModule({
|
||||||
id: 'unified-dashboard',
|
id: 'feedback-commander',
|
||||||
name: 'Overview',
|
name: 'Feedback Commander',
|
||||||
icon: 'dashboard',
|
icon: 'view_kanban',
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
@@ -0,0 +1,746 @@
|
|||||||
|
<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.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-select
|
||||||
|
v-model="selectedItem.contact_person"
|
||||||
|
:items="peopleOptions"
|
||||||
|
inline
|
||||||
|
placeholder="Bezugsperson..."
|
||||||
|
show-deselect
|
||||||
|
@update:model-value="updatePerson"
|
||||||
|
/>
|
||||||
|
<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 }}</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="public" x-small /> Website</label>
|
||||||
|
<strong>{{ selectedItem.project }}</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 people = 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.project).filter(Boolean));
|
||||||
|
return Array.from(projSet).sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const peopleOptions = computed(() =>
|
||||||
|
people.value.map(p => ({
|
||||||
|
text: `${p.first_name} ${p.last_name}`,
|
||||||
|
value: p.id
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
loading.value = true;
|
||||||
|
fetchError.value = null;
|
||||||
|
try {
|
||||||
|
const [feedbackRes, peopleRes] = await Promise.all([
|
||||||
|
api.get('/items/visual_feedback', {
|
||||||
|
params: {
|
||||||
|
sort: '-date_created,-id',
|
||||||
|
limit: 300
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
api.get('/items/people', { params: { sort: 'last_name' } })
|
||||||
|
]);
|
||||||
|
items.value = feedbackRes.data.data;
|
||||||
|
people.value = peopleRes.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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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 updatePerson(val) {
|
||||||
|
if (!selectedItem.value) return;
|
||||||
|
try {
|
||||||
|
await api.patch(`/items/visual_feedback/${selectedItem.value.id}`, {
|
||||||
|
contact_person: 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>
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "people-manager",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "module",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "src/index.ts",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "directus-extension build",
|
||||||
|
"dev": "directus-extension build -w"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
|
"vue": "^3.4.34",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
-118
@@ -1,13 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<MintelManagerLayout
|
<private-view title="People Manager">
|
||||||
title="People Manager"
|
|
||||||
:item-title="`${selectedPerson?.first_name} ${selectedPerson?.last_name}` || 'Person wählen'"
|
|
||||||
:is-empty="!selectedPerson"
|
|
||||||
empty-title="Person auswählen"
|
|
||||||
empty-icon="person"
|
|
||||||
:notice="feedback"
|
|
||||||
@close-notice="feedback = null"
|
|
||||||
>
|
|
||||||
<template #navigation>
|
<template #navigation>
|
||||||
<v-list nav>
|
<v-list nav>
|
||||||
<v-list-item @click="openCreateDrawer" clickable>
|
<v-list-item @click="openCreateDrawer" clickable>
|
||||||
@@ -25,7 +17,7 @@
|
|||||||
v-for="person in people"
|
v-for="person in people"
|
||||||
:key="person.id"
|
:key="person.id"
|
||||||
:active="selectedPerson?.id === person.id"
|
:active="selectedPerson?.id === person.id"
|
||||||
class="nav-item"
|
class="person-item"
|
||||||
clickable
|
clickable
|
||||||
@click="selectPerson(person)"
|
@click="selectPerson(person)"
|
||||||
>
|
>
|
||||||
@@ -39,42 +31,47 @@
|
|||||||
</v-list>
|
</v-list>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #subtitle>
|
<div class="content-wrapper">
|
||||||
<template v-if="selectedPerson">
|
<v-notice v-if="feedback" :type="feedback.type" @close="feedback = null" dismissible>
|
||||||
{{ getCompanyName(selectedPerson) }}
|
{{ feedback.message }}
|
||||||
</template>
|
</v-notice>
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
<div v-if="!selectedPerson" class="empty-state">
|
||||||
<v-button secondary rounded icon v-tooltip.bottom="'Person bearbeiten'" @click="openEditDrawer">
|
<v-info title="Person auswählen" icon="person" center>
|
||||||
<v-icon name="edit" />
|
|
||||||
</v-button>
|
|
||||||
<v-button danger rounded icon v-tooltip.bottom="'Person löschen'" @click="deletePerson">
|
|
||||||
<v-icon name="delete" />
|
|
||||||
</v-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #empty-state>
|
|
||||||
Wähle eine Person in der Navigation aus oder
|
Wähle eine Person in der Navigation aus oder
|
||||||
<v-button x-small @click="openCreateDrawer">erstelle eine neue Person</v-button>.
|
<v-button x-small @click="openCreateDrawer">erstelle eine neue Person</v-button>.
|
||||||
</template>
|
</v-info>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedPerson" class="details-grid">
|
<div v-else>
|
||||||
<div class="detail-item">
|
<header class="header">
|
||||||
<span class="label">Vorname</span>
|
<div class="header-left">
|
||||||
<p class="value">{{ selectedPerson.first_name }}</p>
|
<h1 class="title">{{ selectedPerson.first_name }} {{ selectedPerson.last_name }}</h1>
|
||||||
|
<p class="subtitle">{{ selectedPerson.email || 'Keine E-Mail angegeben' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
|
||||||
<span class="label">Nachname</span>
|
<div class="header-right">
|
||||||
<p class="value">{{ selectedPerson.last_name }}</p>
|
<v-button secondary rounded icon v-tooltip="'Person bearbeiten'" @click="openEditDrawer">
|
||||||
</div>
|
<v-icon name="edit" />
|
||||||
<div class="detail-item">
|
</v-button>
|
||||||
<span class="label">E-Mail</span>
|
<v-button danger rounded icon v-tooltip="'Person löschen'" @click="deletePerson">
|
||||||
<p class="value">{{ selectedPerson.email || '---' }}</p>
|
<v-icon name="delete" />
|
||||||
|
</v-button>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<div class="details-grid">
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="label">Organisation</span>
|
<span class="label">Organisation</span>
|
||||||
<p class="value">{{ getCompanyName(selectedPerson) }}</p>
|
<p class="value">{{ selectedPerson.company || '---' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="label">Telefon</span>
|
||||||
|
<p class="value">{{ selectedPerson.phone || '---' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,17 +95,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="label">E-Mail</span>
|
<span class="label">E-Mail</span>
|
||||||
<v-input v-model="form.email" placeholder="E-Mail Adresse" type="email" />
|
<v-input v-model="form.email" placeholder="email@beispiel.de" type="email" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="label">Zentrale Firma</span>
|
<span class="label">Organisation / Firma</span>
|
||||||
<MintelSelect
|
<v-input v-model="form.company" placeholder="z.B. Mintel" />
|
||||||
v-model="form.company"
|
</div>
|
||||||
:items="companyOptions"
|
<div class="field">
|
||||||
placeholder="Bestehende Firma auswählen..."
|
<span class="label">Telefon</span>
|
||||||
allow-add
|
<v-input v-model="form.phone" placeholder="+49 ..." />
|
||||||
@add="openQuickAdd('company')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,19 +115,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-drawer>
|
</v-drawer>
|
||||||
</MintelManagerLayout>
|
</private-view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, nextTick } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { useApi } from '@directus/extensions-sdk';
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
|
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const route = useRoute();
|
|
||||||
const people = ref([]);
|
const people = ref([]);
|
||||||
const companies = ref([]);
|
|
||||||
const selectedPerson = ref(null);
|
const selectedPerson = ref(null);
|
||||||
const feedback = ref(null);
|
const feedback = ref(null);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
@@ -144,41 +135,18 @@ const form = ref({
|
|||||||
first_name: '',
|
first_name: '',
|
||||||
last_name: '',
|
last_name: '',
|
||||||
email: '',
|
email: '',
|
||||||
company: null
|
company: '',
|
||||||
|
phone: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const companyOptions = computed(() =>
|
async function fetchPeople() {
|
||||||
companies.value.map(c => ({
|
|
||||||
text: c.name,
|
|
||||||
value: c.id
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
function getCompanyName(person: any) {
|
|
||||||
if (!person) return '---';
|
|
||||||
if (person.company) {
|
|
||||||
return typeof person.company === 'object' ? person.company.name : (companies.value.find(c => c.id === person.company)?.name || 'Unbekannte Firma');
|
|
||||||
}
|
|
||||||
return '---';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchData() {
|
|
||||||
try {
|
try {
|
||||||
const [peopleResp, companiesResp] = await Promise.all([
|
const response = await api.get('/items/people', {
|
||||||
api.get('/items/people', {
|
params: { sort: 'last_name' }
|
||||||
params: {
|
});
|
||||||
sort: 'last_name',
|
people.ref = response.data.data;
|
||||||
fields: '*.*'
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
api.get('/items/companies', {
|
|
||||||
params: { sort: 'name' }
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
people.value = peopleResp.data.data;
|
|
||||||
companies.value = companiesResp.data.data;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch data:', error);
|
console.error('Failed to fetch people:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,26 +156,13 @@ function selectPerson(person) {
|
|||||||
|
|
||||||
function openCreateDrawer() {
|
function openCreateDrawer() {
|
||||||
isEditing.value = false;
|
isEditing.value = false;
|
||||||
form.value = {
|
form.value = { id: null, first_name: '', last_name: '', email: '', company: '', phone: '' };
|
||||||
id: null,
|
|
||||||
first_name: '',
|
|
||||||
last_name: '',
|
|
||||||
email: '',
|
|
||||||
company: null
|
|
||||||
};
|
|
||||||
drawerActive.value = true;
|
drawerActive.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditDrawer() {
|
function openEditDrawer() {
|
||||||
isEditing.value = true;
|
isEditing.value = true;
|
||||||
const person = selectedPerson.value;
|
form.value = { ...selectedPerson.value };
|
||||||
form.value = {
|
|
||||||
id: person.id,
|
|
||||||
first_name: person.first_name,
|
|
||||||
last_name: person.last_name,
|
|
||||||
email: person.email,
|
|
||||||
company: person.company?.id || person.company
|
|
||||||
};
|
|
||||||
drawerActive.value = true;
|
drawerActive.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,20 +174,17 @@ async function savePerson() {
|
|||||||
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
let updatedItem;
|
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
const res = await api.patch(`/items/people/${form.value.id}`, form.value);
|
await api.patch(`/items/people/${form.value.id}`, form.value);
|
||||||
updatedItem = res.data.data;
|
|
||||||
feedback.value = { type: 'success', message: 'Person aktualisiert!' };
|
feedback.value = { type: 'success', message: 'Person aktualisiert!' };
|
||||||
} else {
|
} else {
|
||||||
const res = await api.post('/items/people', form.value);
|
await api.post('/items/people', form.value);
|
||||||
updatedItem = res.data.data;
|
|
||||||
feedback.value = { type: 'success', message: 'Person angelegt!' };
|
feedback.value = { type: 'success', message: 'Person angelegt!' };
|
||||||
}
|
}
|
||||||
drawerActive.value = false;
|
drawerActive.value = false;
|
||||||
await fetchData();
|
await fetchPeople();
|
||||||
if (updatedItem) {
|
if (isEditing.value) {
|
||||||
selectedPerson.value = people.value.find(p => p.id === updatedItem.id) || updatedItem;
|
selectedPerson.value = form.value;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
feedback.value = { type: 'danger', message: error.message };
|
feedback.value = { type: 'danger', message: error.message };
|
||||||
@@ -248,29 +200,56 @@ async function deletePerson() {
|
|||||||
await api.delete(`/items/people/${selectedPerson.value.id}`);
|
await api.delete(`/items/people/${selectedPerson.value.id}`);
|
||||||
feedback.value = { type: 'success', message: 'Person gelöscht.' };
|
feedback.value = { type: 'success', message: 'Person gelöscht.' };
|
||||||
selectedPerson.value = null;
|
selectedPerson.value = null;
|
||||||
await fetchData();
|
await fetchPeople();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
feedback.value = { type: 'danger', message: error.message };
|
feedback.value = { type: 'danger', message: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openQuickAdd(type: string) {
|
onMounted(fetchPeople);
|
||||||
feedback.value = { type: 'info', message: `Firma im Company Manager anlegen.` };
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await fetchData();
|
|
||||||
if (route.query.create === 'true') {
|
|
||||||
openCreateDrawer();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.content-wrapper {
|
||||||
|
padding: 32px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--theme--foreground-subdued);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.details-grid {
|
.details-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 32px;
|
gap: 32px;
|
||||||
|
margin-top: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-item {
|
.detail-item {
|
||||||
@@ -1,18 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/cms-infra",
|
"name": "@mintel/cms-infra",
|
||||||
"version": "1.8.20",
|
"version": "1.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run up -- --link",
|
"build": "pnpm --filter \"./extensions/**\" build",
|
||||||
"up": "../../scripts/cms-up.sh",
|
"dev": "pnpm --filter \"./extensions/**\" dev",
|
||||||
|
"up": "docker compose up -d",
|
||||||
|
"up:build": "docker compose up -d --build",
|
||||||
"down": "docker compose down",
|
"down": "docker compose down",
|
||||||
"logs": "docker compose logs -f",
|
"logs": "docker compose logs -f"
|
||||||
"build:extensions": "../../scripts/sync-extensions.sh",
|
|
||||||
"schema:apply:local": "../../scripts/cms-apply.sh local",
|
|
||||||
"schema:apply:infra": "../../scripts/cms-apply.sh infra",
|
|
||||||
"snapshot:local": "../../scripts/cms-snapshot.sh",
|
|
||||||
"sync:push": "../../scripts/sync-directus.sh push infra",
|
|
||||||
"sync:pull": "../../scripts/sync-directus.sh pull infra"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default (router) => {
|
||||||
|
router.get('/ping', (req, res) => res.send('pong'));
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "test-extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"path": "index.js",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+236
-1203
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
API_URL="http://localhost:8059"
|
||||||
|
EMAIL="marc@mintel.me"
|
||||||
|
PASSWORD="Tim300493."
|
||||||
|
|
||||||
|
echo "Logging in to Directus..."
|
||||||
|
TOKEN=$(curl -s -X POST "${API_URL}/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"email\":\"${EMAIL}\", \"password\":\"${PASSWORD}\"}" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "Login failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Hiding 'leads' collection..."
|
||||||
|
curl -s -X PATCH "${API_URL}/collections/leads" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"meta": {"hidden": true}}'
|
||||||
|
|
||||||
|
echo "Creating 'people' collection..."
|
||||||
|
curl -s -X POST "${API_URL}/collections" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"collection": "people",
|
||||||
|
"schema": {},
|
||||||
|
"meta": {
|
||||||
|
"icon": "person",
|
||||||
|
"display_template": "{{first_name}} {{last_name}}",
|
||||||
|
"show_status_indicator": true
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
|
||||||
|
echo "Adding fields to 'people'..."
|
||||||
|
FIELDS='[
|
||||||
|
{"field": "first_name", "type": "string", "meta": {"interface": "input", "width": "half"}},
|
||||||
|
{"field": "last_name", "type": "string", "meta": {"interface": "input", "width": "half"}},
|
||||||
|
{"field": "email", "type": "string", "meta": {"interface": "input", "width": "half"}},
|
||||||
|
{"field": "phone", "type": "string", "meta": {"interface": "input", "width": "half"}},
|
||||||
|
{"field": "company", "type": "string", "meta": {"interface": "input", "width": "full"}}
|
||||||
|
]'
|
||||||
|
|
||||||
|
for field in $(echo "${FIELDS}" | jq -c '.[]'); do
|
||||||
|
curl -s -X POST "${API_URL}/fields/people" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "${field}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Adding 'contact_person' to 'leads', 'client_users', and 'visual_feedback'..."
|
||||||
|
for collection in leads client_users visual_feedback; do
|
||||||
|
curl -s -X POST "${API_URL}/fields/${collection}" \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"field": "contact_person",
|
||||||
|
"type": "uuid",
|
||||||
|
"meta": {
|
||||||
|
"interface": "select-dropdown-m2o",
|
||||||
|
"options": {
|
||||||
|
"template": "{{first_name}} {{last_name}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"foreign_key_column": "id",
|
||||||
|
"foreign_key_table": "people"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Done!"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
xmKX5
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "company-manager",
|
|
||||||
"description": "Custom High-Fidelity Management for Directus",
|
|
||||||
"icon": "extension",
|
|
||||||
"version": "1.8.20",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
<template>
|
|
||||||
<MintelManagerLayout
|
|
||||||
title="Company Manager"
|
|
||||||
:item-title="selectedCompany?.name || 'Firma wählen'"
|
|
||||||
:is-empty="!selectedCompany"
|
|
||||||
empty-title="Firma auswählen"
|
|
||||||
empty-icon="business"
|
|
||||||
:notice="feedback"
|
|
||||||
@close-notice="feedback = null"
|
|
||||||
>
|
|
||||||
<template #navigation>
|
|
||||||
<v-list nav>
|
|
||||||
<v-list-item @click="openCreateDrawer" clickable>
|
|
||||||
<v-list-item-icon>
|
|
||||||
<v-icon name="add" color="var(--theme--primary)" />
|
|
||||||
</v-list-item-icon>
|
|
||||||
<v-list-item-content>
|
|
||||||
<v-text-overflow text="Neue Firma anlegen" />
|
|
||||||
</v-list-item-content>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<v-list-item
|
|
||||||
v-for="company in companies"
|
|
||||||
:key="company.id"
|
|
||||||
:active="selectedCompany?.id === company.id"
|
|
||||||
class="nav-item"
|
|
||||||
clickable
|
|
||||||
@click="selectCompany(company)"
|
|
||||||
>
|
|
||||||
<v-list-item-icon>
|
|
||||||
<v-icon name="business" />
|
|
||||||
</v-list-item-icon>
|
|
||||||
<v-list-item-content>
|
|
||||||
<v-text-overflow :text="company.name" />
|
|
||||||
</v-list-item-content>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #subtitle>
|
|
||||||
<template v-if="selectedCompany">
|
|
||||||
{{ selectedCompany.domain || 'Keine Domain angegeben' }}
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<v-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openEditDrawer">
|
|
||||||
<v-icon name="edit" />
|
|
||||||
</v-button>
|
|
||||||
<v-button danger rounded icon v-tooltip.bottom="'Firma löschen'" @click="deleteCompany">
|
|
||||||
<v-icon name="delete" />
|
|
||||||
</v-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #empty-state>
|
|
||||||
Wähle eine Firma in der Navigation aus oder
|
|
||||||
<v-button x-small @click="openCreateDrawer">erstelle eine neue Firma</v-button>.
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="selectedCompany" class="details-grid">
|
|
||||||
<div class="detail-item full">
|
|
||||||
<span class="label">Notizen / Adresse</span>
|
|
||||||
<p class="value">{{ selectedCompany.notes || '---' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create/Edit Drawer -->
|
|
||||||
<v-drawer
|
|
||||||
v-model="drawerActive"
|
|
||||||
:title="isEditing ? 'Firma bearbeiten' : 'Neue Firma anlegen'"
|
|
||||||
icon="business"
|
|
||||||
@cancel="drawerActive = false"
|
|
||||||
>
|
|
||||||
<template #default>
|
|
||||||
<div class="drawer-content">
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Firmenname</span>
|
|
||||||
<v-input v-model="form.name" placeholder="z.B. Schmidt GmbH" autofocus />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Domain / Website</span>
|
|
||||||
<v-input v-model="form.domain" placeholder="example.com" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Notizen / Adresse</span>
|
|
||||||
<v-textarea v-model="form.notes" placeholder="z.B. Branche, Adresse, etc." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="drawer-actions">
|
|
||||||
<v-button primary block :loading="saving" @click="saveCompany">
|
|
||||||
Firma speichern
|
|
||||||
</v-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</v-drawer>
|
|
||||||
</MintelManagerLayout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue';
|
|
||||||
import { useApi } from '@directus/extensions-sdk';
|
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import { MintelManagerLayout } from '@mintel/directus-extension-toolkit';
|
|
||||||
|
|
||||||
const api = useApi();
|
|
||||||
const route = useRoute();
|
|
||||||
const companies = ref([]);
|
|
||||||
const selectedCompany = ref(null);
|
|
||||||
const feedback = ref(null);
|
|
||||||
const saving = ref(false);
|
|
||||||
const drawerActive = ref(false);
|
|
||||||
const isEditing = ref(false);
|
|
||||||
|
|
||||||
const form = ref({
|
|
||||||
id: null,
|
|
||||||
name: '',
|
|
||||||
domain: '',
|
|
||||||
notes: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchData() {
|
|
||||||
try {
|
|
||||||
const resp = await api.get('/items/companies', {
|
|
||||||
params: { sort: 'name' }
|
|
||||||
});
|
|
||||||
companies.value = resp.data.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch companies:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectCompany(company: any) {
|
|
||||||
selectedCompany.value = company;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateDrawer() {
|
|
||||||
isEditing.value = false;
|
|
||||||
form.value = {
|
|
||||||
id: null,
|
|
||||||
name: '',
|
|
||||||
domain: '',
|
|
||||||
notes: ''
|
|
||||||
};
|
|
||||||
drawerActive.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditDrawer() {
|
|
||||||
isEditing.value = true;
|
|
||||||
form.value = {
|
|
||||||
id: selectedCompany.value.id,
|
|
||||||
name: selectedCompany.value.name,
|
|
||||||
domain: selectedCompany.value.domain,
|
|
||||||
notes: selectedCompany.value.notes
|
|
||||||
};
|
|
||||||
drawerActive.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCompany() {
|
|
||||||
if (!form.value.name) {
|
|
||||||
feedback.value = { type: 'danger', message: 'Firmenname ist erforderlich.' };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
let updatedItem;
|
|
||||||
if (isEditing.value) {
|
|
||||||
const res = await api.patch(`/items/companies/${form.value.id}`, form.value);
|
|
||||||
updatedItem = res.data.data;
|
|
||||||
feedback.value = { type: 'success', message: 'Firma aktualisiert!' };
|
|
||||||
} else {
|
|
||||||
const res = await api.post('/items/companies', form.value);
|
|
||||||
updatedItem = res.data.data;
|
|
||||||
feedback.value = { type: 'success', message: 'Firma angelegt!' };
|
|
||||||
}
|
|
||||||
drawerActive.value = false;
|
|
||||||
await fetchData();
|
|
||||||
if (updatedItem) {
|
|
||||||
selectedCompany.value = companies.value.find(c => c.id === updatedItem.id) || updatedItem;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
feedback.value = { type: 'danger', message: error.message };
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteCompany() {
|
|
||||||
if (!confirm('Soll diese Firma wirklich gelöscht werden?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.delete(`/items/companies/${selectedCompany.value.id}`);
|
|
||||||
feedback.value = { type: 'success', message: 'Firma gelöscht.' };
|
|
||||||
selectedCompany.value = null;
|
|
||||||
await fetchData();
|
|
||||||
} catch (error) {
|
|
||||||
feedback.value = { type: 'danger', message: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await fetchData();
|
|
||||||
if (route.query.create === 'true') {
|
|
||||||
openCreateDrawer();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.details-grid { display: flex; flex-direction: column; gap: 24px; }
|
|
||||||
.detail-item { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.detail-item.full { width: 100%; }
|
|
||||||
.label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
|
|
||||||
.value { font-size: 16px; font-weight: 500; }
|
|
||||||
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
|
|
||||||
.form-section { display: flex; flex-direction: column; gap: 20px; }
|
|
||||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.drawer-actions { margin-top: 24px; }
|
|
||||||
</style>
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@mintel/content-engine",
|
|
||||||
"version": "1.8.20",
|
|
||||||
"private": false,
|
|
||||||
"type": "module",
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"module": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
||||||
"dev": "tsup src/index.ts --format esm --watch --dts",
|
|
||||||
"lint": "eslint src"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@mintel/journaling": "workspace:*",
|
|
||||||
"@mintel/meme-generator": "workspace:*",
|
|
||||||
"@mintel/thumbnail-generator": "workspace:*",
|
|
||||||
"dotenv": "^17.3.1",
|
|
||||||
"openai": "^4.82.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@mintel/eslint-config": "workspace:*",
|
|
||||||
"@mintel/tsconfig": "workspace:*",
|
|
||||||
"@types/node": "^20.0.0",
|
|
||||||
"tsup": "^8.3.5",
|
|
||||||
"typescript": "^5.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,990 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from "./generator";
|
|
||||||
export * from "./orchestrator";
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user