Compare commits
104 Commits
v1.9.4
...
fix/analyt
| Author | SHA1 | Date | |
|---|---|---|---|
| 87445975cb | |||
| 24625c20f2 | |||
| 9a92874011 | |||
| a54c3a784b | |||
| 7b975ed88f | |||
| 1a5a60304f | |||
| 48374528ad | |||
| 541da2db20 | |||
| 39bb862fd6 | |||
| 09a85000bb | |||
| b6e8628970 | |||
| 53ad9f23d8 | |||
| 7a9dd06fc6 | |||
| 985e532c61 | |||
| 04462f21e1 | |||
| 11babf3347 | |||
| 3de92accc4 | |||
| c8961411ec | |||
| 42ea30c11f | |||
| 7b2cc3723a | |||
| 0fc4ddbc28 | |||
| 9f8eab2ad2 | |||
| 69c483ac46 | |||
| ac178ccbb0 | |||
| 3f16828bdf | |||
| 0f7cd94e14 | |||
| 2cc6512afa | |||
| 151c98c884 | |||
| ba17d7940e | |||
| 46120dbd56 | |||
| 571c4797fa | |||
| c7d107a578 | |||
| 5f50e98a0a | |||
| 45f831ca1c | |||
| 73356cca30 | |||
| b7e630c1d8 | |||
| 13c8bc29ae | |||
| 51a892fd76 | |||
| 9cc8e573ec | |||
| a80464abc0 | |||
| 0b929d00a1 | |||
| 8ec2ecb454 | |||
| 2a9c5780c3 | |||
| fbfc9feab0 | |||
| 8486261555 | |||
| 5e1f2669e6 | |||
| 541f1c17b7 | |||
| 048fafa3db | |||
| 77e2c4f9b6 | |||
| 4eb1aaf640 | |||
| 61f2f83e0c | |||
| 2dc61e4937 | |||
| a6ca876823 | |||
| f615565323 | |||
| fcbf388ef8 | |||
| cbed10052b | |||
| 560213680c | |||
| 7e2542bf1f | |||
| df6bef7345 | |||
| aa57e8c48b | |||
| 822e8a9d0f | |||
| f0d1fb6647 | |||
| 751ffd59a0 | |||
| d0a17a8a31 | |||
| daa2750f89 | |||
| 29423123b3 | |||
| 5c10eb0009 | |||
| dca35a9900 | |||
| 4430d473cb | |||
| 0c27e3b5d8 | |||
| 616d8a039b | |||
| ee3d7714c2 | |||
| ddf896e3f9 | |||
| b9d0199115 | |||
| 1670b8e5ef | |||
| 1c43d12e4d | |||
| 5cf9922822 | |||
| 9a4a95feea | |||
| d3902c4c77 | |||
| 21ec8a33ae | |||
| 79d221de5e | |||
| 24fde20030 | |||
| 4a4409ca85 | |||
| d96d6a4b13 | |||
| 8f6b12d827 | |||
| a11714d07d | |||
| 52f7e68f25 | |||
| 217ac33675 | |||
| f2b8b136af | |||
| 2e07b213d1 | |||
| a2c1eaefba | |||
| 80ff266f9c | |||
| 6b1c5b7e30 | |||
| 80eefad5ea | |||
| 72556af24c | |||
| 2a5466c6c0 | |||
| 2d36a4ec71 | |||
| ded9da7d32 | |||
| 36ed26ad79 | |||
| 4e72a0baac | |||
| 8ca7eb3f49 | |||
| 32d3ff010a | |||
| cb68e1fb5c | |||
| 1bd7c6aba5 |
5
.changeset/next-config-server-actions.md
Normal file
5
.changeset/next-config-server-actions.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@mintel/next-config": patch
|
||||
---
|
||||
|
||||
fix: add serverActions.allowedOrigins to support branch deployments
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@mintel/monorepo": patch
|
||||
"acquisition-manager": patch
|
||||
"feedback-commander": patch
|
||||
---
|
||||
|
||||
fix: make directus extension build scripts more resilient
|
||||
23
.env
23
.env
@@ -3,6 +3,7 @@ IMAGE_TAG=v1.8.19
|
||||
PROJECT_NAME=at-mintel
|
||||
PROJECT_COLOR=#82ed20
|
||||
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
|
||||
GITEA_HOST=https://git.infra.mintel.me
|
||||
OPENROUTER_API_KEY=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
|
||||
ZYTE_API_KEY=1f0f74726f044f55aaafc7ead32cd489
|
||||
REPLICATE_API_KEY=r8_W3grtpXMRfi0u3AM9VdkKbuWdZMmhwU2Tn0yt
|
||||
@@ -11,6 +12,11 @@ DATA_FOR_SEO_API_KEY=bWFyY0BtaW50ZWwubWU6MjQ0YjBjZmIzOGY3NTIzZA==
|
||||
DATA_FOR_SEO_LOGIN=marc@mintel.me
|
||||
DATA_FOR_SEO_PASSWORD=244b0cfb38f7523d
|
||||
|
||||
# Kabelfachmann LLM Configuration
|
||||
KABELFACHMANN_LLM_PROVIDER=openrouter
|
||||
KABELFACHMANN_OLLAMA_MODEL=qwen3.5
|
||||
KABELFACHMANN_OLLAMA_HOST=http://host.docker.internal:11434
|
||||
|
||||
# Authentication
|
||||
GATEKEEPER_PASSWORD=mintel
|
||||
AUTH_COOKIE_NAME=mintel_gatekeeper_session
|
||||
@@ -41,3 +47,20 @@ SENTRY_DSN=
|
||||
# Analytics (Umami)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
UMAMI_USERNAME=mmintel
|
||||
UMAMI_PASSWORD=Tim300493.
|
||||
|
||||
# MCP Services
|
||||
QDRANT_URL=http://qdrant:6333
|
||||
SERPBEAR_API_KEY=Tim300493.
|
||||
GLITCHTIP_API_KEY=3d37db66fa1fc3a1cdcd1357728b056225aca950f0163b50c5e922344807ede9
|
||||
|
||||
# Corrected MCP Base URLs
|
||||
UMAMI_BASE_URL=https://analytics.infra.mintel.me
|
||||
GLITCHTIP_BASE_URL=https://errors.infra.mintel.me
|
||||
SERPBEAR_BASE_URL=https://seo.infra.mintel.me
|
||||
|
||||
# Vikunja MCP
|
||||
VIKUNJA_API_TOKEN=tk_468a6f7a59b329aa42796378e55075b79eb4d3d4
|
||||
VIKUNJA_BASE_URL=https://tasks.infra.mintel.me
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Project
|
||||
IMAGE_TAG=v1.9.4
|
||||
IMAGE_TAG=v1.9.17
|
||||
PROJECT_NAME=sample-website
|
||||
PROJECT_COLOR=#82ed20
|
||||
|
||||
|
||||
49
.gitea/workflows/analytics-mailer-monthly.yml
Normal file
49
.gitea/workflows/analytics-mailer-monthly.yml
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Analytics Mailer (Monthly)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run monthly on the 1st day of the month at 08:30 UTC
|
||||
- cron: '30 8 1 * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
send-reports:
|
||||
name: 📊 Send Analytics Reports (Monthly)
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
UMAMI_BASE_URL: ${{ secrets.UMAMI_BASE_URL }}
|
||||
UMAMI_USERNAME: ${{ secrets.UMAMI_USERNAME }}
|
||||
UMAMI_PASSWORD: ${{ secrets.UMAMI_PASSWORD }}
|
||||
SMTP_HOST: ${{ secrets.SMTP_HOST }}
|
||||
SMTP_PORT: ${{ secrets.SMTP_PORT }}
|
||||
SMTP_USER: ${{ secrets.SMTP_USER }}
|
||||
SMTP_PASS: ${{ secrets.SMTP_PASS }}
|
||||
SMTP_SENDER: ${{ secrets.SMTP_SENDER }}
|
||||
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||
GOTIFY_TOKEN: ${{ secrets.GOTIFY_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 mail templates
|
||||
run: pnpm --filter @mintel/mail build
|
||||
|
||||
- name: Build analytics mailer
|
||||
run: pnpm --filter @mintel/analytics-mailer build
|
||||
|
||||
- name: Run Analytics Mailer
|
||||
run: pnpm --filter @mintel/analytics-mailer run start
|
||||
@@ -192,9 +192,6 @@ jobs:
|
||||
file: packages/infra/docker/Dockerfile.gatekeeper
|
||||
name: Gatekeeper (Product)
|
||||
|
||||
- image: image-processor
|
||||
file: apps/image-service/Dockerfile
|
||||
name: Image Processor
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -202,12 +199,31 @@ jobs:
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 🔐 Registry Login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.infra.mintel.me
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASS }}
|
||||
- name: 🔐 Discover Valid Registry Token
|
||||
id: discover_token
|
||||
run: |
|
||||
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
|
||||
TOKENS="${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}"
|
||||
USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel"
|
||||
|
||||
for TOKEN in $TOKENS; do
|
||||
if [ -n "$TOKEN" ]; then
|
||||
for U in $USERS; do
|
||||
if [ -n "$U" ]; then
|
||||
echo "Attempting docker login for a token with user $U..."
|
||||
if echo "$TOKEN" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then
|
||||
echo "✅ Successfully authenticated with a token."
|
||||
echo "::add-mask::$TOKEN"
|
||||
echo "token=$TOKEN" >> $GITHUB_OUTPUT
|
||||
echo "user=$U" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
echo "❌ All available tokens failed to authenticate!"
|
||||
exit 1
|
||||
|
||||
- name: 🏗️ Build & Push ${{ matrix.name }}
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -219,8 +235,8 @@ jobs:
|
||||
provenance: false
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN=${{ steps.discover_token.outputs.token }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
||||
git.infra.mintel.me/mmintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||
git.infra.mintel.me/mmintel/${{ matrix.image }}:latest
|
||||
|
||||
|
||||
@@ -18,10 +18,55 @@ on:
|
||||
required: true
|
||||
GATEKEEPER_PASSWORD:
|
||||
required: true
|
||||
NPM_TOKEN:
|
||||
required: false
|
||||
MINTEL_PRIVATE_TOKEN:
|
||||
required: false
|
||||
GITEA_PAT:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
qa_suite:
|
||||
name: 🛡️ Nightly QA Suite
|
||||
prepare:
|
||||
name: 🏗️ Prepare & Install
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
|
||||
echo "@mintel:registry=https://$REGISTRY" > .npmrc
|
||||
echo "//$REGISTRY/:_authToken=${{ secrets.NPM_TOKEN || secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}" >> .npmrc
|
||||
- name: 🔍 Diagnostics
|
||||
run: |
|
||||
echo "👤 User: $(id)"
|
||||
echo "📂 Directory: $(pwd)"
|
||||
echo "📂 Disk Space:"
|
||||
df -h
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
rm -rf .next .turbo node_modules || true
|
||||
pnpm install --no-frozen-lockfile --reporter=append-only
|
||||
- name: 📦 Archive dependencies
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: node_modules
|
||||
path: node_modules
|
||||
retention-days: 1
|
||||
|
||||
static:
|
||||
name: 🔍 Static Analysis
|
||||
needs: prepare
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -36,98 +81,126 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.MINTEL_PRIVATE_TOKEN || secrets.GITEA_PAT }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
id: deps
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: 📦 Cache APT Packages
|
||||
uses: actions/cache@v4
|
||||
- name: 📥 Restore dependencies
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-cache-${{ runner.os }}-${{ runner.arch }}-chromium
|
||||
|
||||
- name: 💾 Cache Chromium
|
||||
id: cache-chromium
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /usr/bin/chromium
|
||||
key: ${{ runner.os }}-chromium-native-${{ hashFiles('package.json') }}
|
||||
|
||||
- name: 🔍 Install Chromium (Native & ARM64)
|
||||
if: steps.cache-chromium.outputs.cache-hit != 'true' && steps.deps.outcome == 'success'
|
||||
run: |
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
apt-get update
|
||||
apt-get install -y gnupg wget ca-certificates
|
||||
OS_ID=$(. /etc/os-release && echo $ID)
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
if [ "$OS_ID" = "debian" ]; then
|
||||
apt-get install -y chromium
|
||||
else
|
||||
mkdir -p /etc/apt/keyrings
|
||||
KEY_ID="82BB6851C64F6880"
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||
apt-get update
|
||||
apt-get install -y --allow-downgrades chromium
|
||||
fi
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||
|
||||
# ── Quality Gates ─────────────────────────────────────────────────────────
|
||||
|
||||
- name: 🌐 Full Sitemap HTML Validation
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
name: node_modules
|
||||
- name: 🌐 HTML Validation
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:html
|
||||
|
||||
- name: 🌐 Dynamic Asset Presence & Error Scan
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
- name: 🖼️ Asset Scan
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:assets
|
||||
|
||||
- name: ♿ Accessibility Scan (WCAG)
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
accessibility:
|
||||
name: ♿ Accessibility
|
||||
needs: prepare
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 📥 Restore dependencies
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: node_modules
|
||||
- name: 🔍 Install Chromium
|
||||
run: |
|
||||
apt-get update && apt-get install -y gnupg wget ca-certificates
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
mkdir -p /etc/apt/keyrings
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x82BB6851C64F6880" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||
apt-get update && apt-get install -y --allow-downgrades chromium
|
||||
ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
- name: ♿ WCAG Scan
|
||||
continue-on-error: true
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:wcag
|
||||
|
||||
- name: 📦 Unused Dependencies Scan (depcheck)
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
analysis:
|
||||
name: 🧪 Maintenance & Links
|
||||
needs: prepare
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 📥 Restore dependencies
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: node_modules
|
||||
- name: 📦 Depcheck
|
||||
continue-on-error: true
|
||||
run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*"
|
||||
|
||||
- name: 🔗 Markdown & HTML Link Check (Lychee)
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
- name: 🔗 Lychee Link Check
|
||||
uses: lycheeverse/lychee-action@v2
|
||||
with:
|
||||
args: --accept 200,204,429 --timeout 15 content/ app/ public/
|
||||
fail: true
|
||||
|
||||
- name: 🎭 LHCI Desktop Audit
|
||||
id: lhci_desktop
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
performance:
|
||||
name: 🎭 Lighthouse
|
||||
needs: prepare
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: 📥 Restore dependencies
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: node_modules
|
||||
- name: 🔍 Install Chromium
|
||||
run: |
|
||||
apt-get update && apt-get install -y gnupg wget ca-certificates
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
mkdir -p /etc/apt/keyrings
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x82BB6851C64F6880" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||
apt-get update && apt-get install -y --allow-downgrades chromium
|
||||
ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
- name: 🎭 LHCI Desktop
|
||||
env:
|
||||
LHCI_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run pagespeed:test -- --collect.settings.preset=desktop
|
||||
|
||||
- name: 📱 LHCI Mobile Audit
|
||||
id: lhci_mobile
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
- name: 📱 LHCI Mobile
|
||||
env:
|
||||
LHCI_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
@@ -135,7 +208,7 @@ jobs:
|
||||
|
||||
notifications:
|
||||
name: 🔔 Notify
|
||||
needs: [qa_suite]
|
||||
needs: [prepare, static, accessibility, analysis, performance]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -144,22 +217,30 @@ jobs:
|
||||
- name: 🔔 Gotify
|
||||
shell: bash
|
||||
run: |
|
||||
SUITE="${{ needs.qa_suite.result }}"
|
||||
PREPARE="${{ needs.prepare.result }}"
|
||||
STATIC="${{ needs.static.result }}"
|
||||
A11Y="${{ needs.accessibility.result }}"
|
||||
ANALYSIS="${{ needs.analysis.result }}"
|
||||
PERF="${{ needs.performance.result }}"
|
||||
|
||||
PROJECT="${{ inputs.PROJECT_NAME }}"
|
||||
URL="${{ inputs.TARGET_URL }}"
|
||||
|
||||
if [[ "$SUITE" != "success" ]]; then
|
||||
if [[ "$PREPARE" != "success" || "$STATIC" != "success" || "$PERF" != "success" ]]; then
|
||||
PRIORITY=8
|
||||
EMOJI="⚠️"
|
||||
EMOJI="🚨"
|
||||
STATUS_LINE="Nightly QA Failed! Action required."
|
||||
else
|
||||
PRIORITY=2
|
||||
EMOJI="✅"
|
||||
STATUS_LINE="Nightly QA Passed perfectly."
|
||||
STATUS_LINE="Nightly QA Passed."
|
||||
fi
|
||||
|
||||
TITLE="$EMOJI $PROJECT Nightly QA"
|
||||
MESSAGE="$STATUS_LINE\n$URL\nPlease check Pipeline output for details."
|
||||
MESSAGE="$STATUS_LINE
|
||||
Prepare: $PREPARE | Static: $STATIC | A11y: $A11Y
|
||||
Analysis: $ANALYSIS | Perf: $PERF
|
||||
$URL"
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -46,4 +46,12 @@ directus/uploads/directus-health-file
|
||||
# Estimation Engine Data
|
||||
data/crawls/
|
||||
packages/estimation-engine/out/
|
||||
apps/web/out/estimations/
|
||||
apps/web/out/estimations/
|
||||
|
||||
# Memory MCP
|
||||
data/qdrant/
|
||||
packages/memory-mcp/models/
|
||||
|
||||
# Kabelfachmann MCP
|
||||
packages/kabelfachmann-mcp/data/
|
||||
packages/kabelfachmann-mcp/models/
|
||||
@@ -5,47 +5,4 @@ if [ -f "$SCRIPT_DIR/scripts/validate-sdk-imports.sh" ]; then
|
||||
"$SCRIPT_DIR/scripts/validate-sdk-imports.sh" || exit 1
|
||||
fi
|
||||
|
||||
# Check if we are pushing a tag
|
||||
while read local_ref local_sha remote_ref remote_sha
|
||||
do
|
||||
if [[ "$remote_ref" == refs/tags/* ]]; then
|
||||
TAG=${remote_ref#refs/tags/}
|
||||
echo "🏷️ Tag detected: $TAG, ensuring versions are synced..."
|
||||
|
||||
# Run sync script
|
||||
pnpm sync-versions "$TAG"
|
||||
|
||||
# Check for changes in relevant files
|
||||
SYNC_FILES="package.json packages/*/package.json apps/*/package.json .env.example"
|
||||
CHANGES=$(git status --porcelain $SYNC_FILES)
|
||||
|
||||
if [[ -n "$CHANGES" ]]; then
|
||||
echo "📝 Version sync made changes. Integrating into tag..."
|
||||
|
||||
# Stage and commit
|
||||
git add $SYNC_FILES
|
||||
git commit -m "chore: sync versions to $TAG" --no-verify
|
||||
|
||||
# Force update the local tag to point to the new commit
|
||||
git tag -f "$TAG" > /dev/null
|
||||
|
||||
echo "✅ Tag $TAG has been updated locally with synced versions."
|
||||
# Push branch AND tag directly inside the hook because Git native push has already recorded the old SHA
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
if [ -n "$CURRENT_BRANCH" ]; then
|
||||
git push origin "$CURRENT_BRANCH" --no-verify
|
||||
fi
|
||||
git push origin "$TAG" --force --no-verify
|
||||
|
||||
echo -e "\n\033[32m✨ VERSIONS SYNCED & PUSHED SUCCESSFULLY ✨\033[0m"
|
||||
echo -e "\033[33mThe correct commit has been tagged on origin.\033[0m"
|
||||
echo -e "\033[90m(Info: You will see a 'pre-push hook failed' and 'failed to push' error below. Please completely ignore it. We MUST abort the native git push, otherwise Git would push the pre-sync commit to the tag and break the version history.)\033[0m\n"
|
||||
|
||||
# We MUST exit 1, otherwise native Git will push the wrong commit to the tag
|
||||
exit 1
|
||||
else
|
||||
echo "✨ Versions already in sync for $TAG."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Builder
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
||||
FROM git.infra.mintel.me/mmintel/nextjs:latest AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Clean the workspace in case the base image is dirty
|
||||
@@ -37,7 +37,7 @@ COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 2: Runner
|
||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
1
apps/analytics-mailer/.gitignore
vendored
Normal file
1
apps/analytics-mailer/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dry-run-*.html
|
||||
30
apps/analytics-mailer/config.json
Normal file
30
apps/analytics-mailer/config.json
Normal file
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"websiteId": "59a7db94-0100-4c7e-98ef-99f45b17f9c3",
|
||||
"domain": "klz-cables.com",
|
||||
"clientEmail": "klaus.mintel@klz-cables.com, michael.bodemer@klz-cables.com, marc@mintel.me",
|
||||
"clientName": "KLZ Cables",
|
||||
"greeting": "Hallo Klaus, hallo Michael,"
|
||||
},
|
||||
{
|
||||
"websiteId": "b9035279-72f1-49bc-909d-b073d184c388",
|
||||
"domain": "mintel.me",
|
||||
"clientEmail": "marc@mintel.me",
|
||||
"clientName": "Mintel.me",
|
||||
"greeting": "Hallo Team Mintel,"
|
||||
},
|
||||
{
|
||||
"websiteId": "caa402bc-b2d2-4349-a254-84c0dd2a4460",
|
||||
"domain": "mb-grid-solutions.com",
|
||||
"clientEmail": "marc@mintel.me",
|
||||
"clientName": "MB Grid Solutions",
|
||||
"greeting": "Hallo Marc,"
|
||||
},
|
||||
{
|
||||
"websiteId": "d773ea10-a3b3-4ccf-9024-987e14c4d669",
|
||||
"domain": "e-tib.com",
|
||||
"clientEmail": "d.joseph@e-tib.com, marc@mintel.me",
|
||||
"clientName": "E-TIB GmbH",
|
||||
"greeting": "Hallo Danny,"
|
||||
}
|
||||
]
|
||||
27
apps/analytics-mailer/package.json
Normal file
27
apps/analytics-mailer/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@mintel/analytics-mailer",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --clean",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/mail": "workspace:*",
|
||||
"axios": "^1.7.9",
|
||||
"dotenv": "^16.4.7",
|
||||
"nodemailer": "^9.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.19.10",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"tsup": "^8.3.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
28
apps/analytics-mailer/src/index.test.ts
Normal file
28
apps/analytics-mailer/src/index.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fetchUmamiData } from "./index";
|
||||
|
||||
describe("fetchUmamiData", () => {
|
||||
it("fetches entry and exit pages correctly", async () => {
|
||||
const apiMock = {
|
||||
get: vi.fn().mockImplementation((url, config) => {
|
||||
if (url.includes("/stats")) return Promise.resolve({ data: { visitors: 10, visits: 10, pageviews: 10, bounces: 5, totaltime: 100 } });
|
||||
if (url.includes("/pageviews")) return Promise.resolve({ data: { pageviews: [] } });
|
||||
if (url.includes("/metrics")) {
|
||||
if (config.params.type === "path") return Promise.resolve({ data: [{ x: "/path", y: 5 }] });
|
||||
if (config.params.type === "referrer") return Promise.resolve({ data: [{ x: "google", y: 3 }] });
|
||||
if (config.params.type === "entry") return Promise.resolve({ data: [{ x: "/entry", y: 4 }] });
|
||||
if (config.params.type === "exit") return Promise.resolve({ data: [{ x: "/exit", y: 2 }] });
|
||||
}
|
||||
return Promise.resolve({ data: {} });
|
||||
})
|
||||
};
|
||||
|
||||
const result = await fetchUmamiData(apiMock, "test-id", 0, 1000, "Test");
|
||||
|
||||
// We expect the new entry and exit pages to be parsed and returned correctly
|
||||
expect(result.topEntryPages).toBeDefined();
|
||||
expect(result.topEntryPages[0]).toEqual({ url: "/entry", visitors: 4 });
|
||||
expect(result.topExitPages).toBeDefined();
|
||||
expect(result.topExitPages[0]).toEqual({ url: "/exit", visitors: 2 });
|
||||
});
|
||||
});
|
||||
202
apps/analytics-mailer/src/index.ts
Normal file
202
apps/analytics-mailer/src/index.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
import { render } from "@mintel/mail";
|
||||
import { AnalyticsTemplate } from "@mintel/mail";
|
||||
import { getThisMonth, getThisYear } from "./utils/time-calculator";
|
||||
import nodemailer from "nodemailer";
|
||||
import * as dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const UMAMI_BASE_URL = process.env.UMAMI_BASE_URL || "https://umami.infra.mintel.me";
|
||||
const UMAMI_USERNAME = process.env.UMAMI_USERNAME;
|
||||
const UMAMI_PASSWORD = process.env.UMAMI_PASSWORD;
|
||||
const UMAMI_API_KEY = process.env.UMAMI_API_KEY;
|
||||
|
||||
const SMTP_HOST = process.env.SMTP_HOST || "mail.infra.mintel.me";
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PORT || "587", 10);
|
||||
const SMTP_USER = process.env.SMTP_USER;
|
||||
const SMTP_PASS = process.env.SMTP_PASS;
|
||||
const SMTP_SENDER = process.env.SMTP_SENDER || "reports@mintel.me";
|
||||
|
||||
const GOTIFY_URL = process.env.GOTIFY_URL;
|
||||
const GOTIFY_TOKEN = process.env.GOTIFY_TOKEN;
|
||||
|
||||
interface ConfigClient {
|
||||
websiteId: string;
|
||||
domain: string;
|
||||
clientEmail: string;
|
||||
clientName: string;
|
||||
greeting?: string;
|
||||
}
|
||||
|
||||
async function getUmamiToken(): Promise<string> {
|
||||
if (UMAMI_API_KEY) {
|
||||
return UMAMI_API_KEY;
|
||||
}
|
||||
if (!UMAMI_USERNAME || !UMAMI_PASSWORD) {
|
||||
throw new Error("Missing UMAMI_USERNAME and UMAMI_PASSWORD or UMAMI_API_KEY");
|
||||
}
|
||||
const res = await axios.post(`${UMAMI_BASE_URL}/api/auth/login`, {
|
||||
username: UMAMI_USERNAME,
|
||||
password: UMAMI_PASSWORD,
|
||||
});
|
||||
return res.data.token;
|
||||
}
|
||||
|
||||
import https from "https";
|
||||
|
||||
export async function fetchUmamiData(api: any, websiteId: string, startAt: number, endAt: number, label: string) {
|
||||
let unit = "day";
|
||||
if (label === "Dieses Jahr") unit = "month";
|
||||
|
||||
const [statsRes, pagesRes, refRes, viewsRes, entryRes, exitRes] = await Promise.all([
|
||||
api.get(`/websites/${websiteId}/stats`, { params: { startAt, endAt } }),
|
||||
api.get(`/websites/${websiteId}/metrics`, { params: { type: "path", limit: 5, startAt, endAt } }),
|
||||
api.get(`/websites/${websiteId}/metrics`, { params: { type: "referrer", limit: 5, startAt, endAt } }),
|
||||
api.get(`/websites/${websiteId}/pageviews`, { params: { startAt, endAt, unit, timezone: "Europe/Berlin" } }).catch(() => ({ data: { pageviews: [] } })),
|
||||
api.get(`/websites/${websiteId}/metrics`, { params: { type: "entry", limit: 5, startAt, endAt } }),
|
||||
api.get(`/websites/${websiteId}/metrics`, { params: { type: "exit", limit: 5, startAt, endAt } }),
|
||||
]);
|
||||
|
||||
const stats = statsRes.data;
|
||||
const topPages = (pagesRes.data || []).map((p: any) => ({ url: p.x, views: p.y || 0 }));
|
||||
const topReferrers = (refRes.data || []).map((r: any) => ({ url: r.x, visitors: r.y || 0 }));
|
||||
const topEntryPages = (entryRes.data || []).map((p: any) => ({ url: p.x, visitors: p.y || 0 }));
|
||||
const topExitPages = (exitRes.data || []).map((p: any) => ({ url: p.x, visitors: p.y || 0 }));
|
||||
|
||||
const visitors = typeof stats.visitors === "object" ? stats.visitors?.value || 0 : stats.visitors || 0;
|
||||
const visits = typeof stats.visits === "object" ? stats.visits?.value || 0 : stats.visits || 0;
|
||||
const views = typeof stats.pageviews === "object" ? stats.pageviews?.value || 0 : stats.pageviews || 0;
|
||||
const bounces = typeof stats.bounces === "object" ? stats.bounces?.value || 0 : stats.bounces || 0;
|
||||
const totaltime = typeof stats.totaltime === "object" ? stats.totaltime?.value || 0 : stats.totaltime || 0;
|
||||
|
||||
const bounceRate = visits > 0 ? Math.round((bounces / visits) * 100) : 0;
|
||||
|
||||
let visitDuration = "0s";
|
||||
if (visits > 0) {
|
||||
const avgSeconds = Math.round(totaltime / visits);
|
||||
if (avgSeconds < 60) {
|
||||
visitDuration = `${avgSeconds}s`;
|
||||
} else {
|
||||
visitDuration = `${Math.floor(avgSeconds / 60)}m ${avgSeconds % 60}s`;
|
||||
}
|
||||
}
|
||||
|
||||
const chartData = (viewsRes.data?.pageviews || []).map((pv: any) => ({
|
||||
label: pv.x,
|
||||
value: pv.y || 0
|
||||
}));
|
||||
|
||||
return {
|
||||
label,
|
||||
visitors,
|
||||
visits,
|
||||
views,
|
||||
bounceRate,
|
||||
visitDuration,
|
||||
topPages,
|
||||
topReferrers,
|
||||
topEntryPages,
|
||||
topExitPages,
|
||||
chartData,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const isDryRun = process.argv.includes("--dry-run");
|
||||
|
||||
// Load config
|
||||
const configPath = path.resolve(process.cwd(), "config.json");
|
||||
const configRaw = await fs.readFile(configPath, "utf-8");
|
||||
const clients: ConfigClient[] = JSON.parse(configRaw);
|
||||
|
||||
// Setup Umami API
|
||||
const token = await getUmamiToken();
|
||||
const headers = UMAMI_API_KEY ? { "x-umami-api-key": token } : { Authorization: `Bearer ${token}` };
|
||||
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
||||
const api = axios.create({ baseURL: `${UMAMI_BASE_URL}/api`, headers, httpsAgent });
|
||||
|
||||
// Setup SMTP Transporter (Nodemailer)
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_PORT === 465,
|
||||
auth: SMTP_USER && SMTP_PASS ? {
|
||||
user: SMTP_USER,
|
||||
pass: SMTP_PASS,
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
const timeRanges = [getThisMonth(), getThisYear()];
|
||||
|
||||
console.log(`Generating reports...`);
|
||||
|
||||
for (const client of clients) {
|
||||
if (client.websiteId === "replace-with-umami-website-id") continue;
|
||||
|
||||
console.log(`Processing ${client.domain} (${client.clientName})...`);
|
||||
|
||||
try {
|
||||
const periods = await Promise.all(
|
||||
timeRanges.map((tr) => fetchUmamiData(api, client.websiteId, tr.startAt, tr.endAt, tr.label))
|
||||
);
|
||||
|
||||
const html = await render(AnalyticsTemplate({
|
||||
greeting: client.greeting || `Hallo ${client.clientName},`,
|
||||
domain: client.domain,
|
||||
periods,
|
||||
}));
|
||||
|
||||
const subject = `Performance-Report - ${client.domain}`;
|
||||
|
||||
const recipients = client.clientEmail.split(',').map(e => e.trim()).filter(e => e.length > 0);
|
||||
|
||||
if (isDryRun) {
|
||||
for (const recipient of recipients) {
|
||||
console.log(`[DRY-RUN] Would send individual email to: ${recipient} (for ${client.domain})`);
|
||||
}
|
||||
const debugPath = path.resolve(process.cwd(), `dry-run-${client.domain}.html`);
|
||||
await fs.writeFile(debugPath, html);
|
||||
console.log(`[DRY-RUN] Saved HTML preview to ${debugPath}`);
|
||||
} else {
|
||||
if (!SMTP_HOST) {
|
||||
throw new Error("SMTP_HOST is missing for live run.");
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await transporter.sendMail({
|
||||
from: SMTP_SENDER,
|
||||
to: recipient,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
console.log(`Successfully sent to ${recipient}`);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
const errorMsg = e.response?.data || e.message;
|
||||
console.error(`Failed to process client ${client.domain}:`, errorMsg);
|
||||
|
||||
if (GOTIFY_URL && GOTIFY_TOKEN) {
|
||||
try {
|
||||
await axios.post(`${GOTIFY_URL}/message?token=${GOTIFY_TOKEN}`, {
|
||||
title: `❌ Analytics Mailer Error (${client.domain})`,
|
||||
message: `Failed to generate or send report for ${client.domain}.\n\nError:\n${errorMsg}`,
|
||||
priority: 8
|
||||
});
|
||||
} catch (gotifyErr: any) {
|
||||
console.error("Failed to push to Gotify:", gotifyErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Done.");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
19
apps/analytics-mailer/src/utils/time-calculator.ts
Normal file
19
apps/analytics-mailer/src/utils/time-calculator.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export function getLast7Days() {
|
||||
const endAt = Date.now();
|
||||
const startAt = endAt - 7 * 24 * 60 * 60 * 1000;
|
||||
return { label: "Letzte 7 Tage", startAt, endAt };
|
||||
}
|
||||
|
||||
export function getThisMonth() {
|
||||
const now = new Date();
|
||||
const startAt = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
|
||||
const endAt = Date.now();
|
||||
return { label: "Dieser Monat", startAt, endAt };
|
||||
}
|
||||
|
||||
export function getThisYear() {
|
||||
const now = new Date();
|
||||
const startAt = new Date(now.getFullYear(), 0, 1).getTime();
|
||||
const endAt = Date.now();
|
||||
return { label: "Dieses Jahr", startAt, endAt };
|
||||
}
|
||||
10
apps/analytics-mailer/tsconfig.json
Normal file
10
apps/analytics-mailer/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sample-website",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
177
blender-mv.sh
Executable file
177
blender-mv.sh
Executable file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env bash
|
||||
# blender-mv: A smart file/directory mover that automatically updates Blender paths
|
||||
|
||||
set -e
|
||||
|
||||
print_usage() {
|
||||
echo "Usage: blender-mv [OPTIONS] SOURCE DEST"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --search-project <dir> Scan this specific project directory for .blend files"
|
||||
echo " to update (useful if you move an external asset folder,"
|
||||
echo " and old .blend files referencing it are elsewhere)."
|
||||
echo ""
|
||||
echo "Example: blender-mv ./old_folder ./new_folder"
|
||||
}
|
||||
|
||||
SEARCH_PROJECT=""
|
||||
|
||||
# Parse arguments
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case $1 in
|
||||
--search-project) SEARCH_PROJECT="$2"; shift 2 ;;
|
||||
-h|--help) print_usage; exit 0 ;;
|
||||
*)
|
||||
if [[ -z "$SOURCE" ]]; then
|
||||
SOURCE="$1"
|
||||
elif [[ -z "$DEST" ]]; then
|
||||
DEST="$1"
|
||||
else
|
||||
echo "Unknown parameter passed: $1"
|
||||
print_usage
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$SOURCE" || -z "$DEST" ]]; then
|
||||
echo "Error: Both SOURCE and DEST must be provided."
|
||||
print_usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -e "$SOURCE" ]]; then
|
||||
echo "Error: Source path '$SOURCE' does not exist."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to get absolute normalized path (handles missing destination gracefully)
|
||||
get_abs_path() {
|
||||
python3 -c 'import os,sys; print(os.path.normpath(os.path.abspath(sys.argv[1])))' "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
# Resolve source before moving
|
||||
ABS_SOURCE=$(get_abs_path "$SOURCE")
|
||||
|
||||
# Determine resulting target path
|
||||
if [[ -d "$DEST" ]]; then
|
||||
# If DEST is an existing dir, standard mv puts SOURCE inside it
|
||||
BASENAME_SOURCE=$(basename "$SOURCE")
|
||||
ABS_DEST=$(get_abs_path "$DEST/$BASENAME_SOURCE")
|
||||
else
|
||||
# Placed exactly at DEST (rename or target dir doesn't exist yet)
|
||||
ABS_DEST=$(get_abs_path "$DEST")
|
||||
fi
|
||||
|
||||
echo "Moving: $SOURCE -> $DEST"
|
||||
mv "$SOURCE" "$DEST"
|
||||
|
||||
echo "Replacing paths in Blender's recent-files lists..."
|
||||
# Find all recent-files.txt across Blender versions and replace the string
|
||||
find "$HOME/Library/Application Support/Blender" -type f -name "recent-files.txt" 2>/dev/null | while read -r RECENT_FILE; do
|
||||
# Using sed with alternative delimiter to avoid path interference
|
||||
sed -i '' "s|${ABS_SOURCE}|${ABS_DEST}|g" "$RECENT_FILE"
|
||||
done
|
||||
|
||||
# Prepare the embedded Python payload to update blend paths
|
||||
TMP_PY=$(mktemp /tmp/blender_update_paths.XXXXXX.py)
|
||||
cat << 'EOF' > "$TMP_PY"
|
||||
import bpy
|
||||
import sys
|
||||
|
||||
def replace_in_prop(prop, old_path, new_path):
|
||||
if prop and isinstance(prop, str) and old_path in prop:
|
||||
return prop.replace(old_path, new_path)
|
||||
return prop
|
||||
|
||||
def run():
|
||||
# Args are passed after `--`
|
||||
if '--' not in sys.argv:
|
||||
return
|
||||
idx = sys.argv.index('--')
|
||||
if len(sys.argv) <= idx + 2:
|
||||
return
|
||||
|
||||
old_path = sys.argv[idx + 1]
|
||||
new_path = sys.argv[idx + 2]
|
||||
|
||||
changed = False
|
||||
|
||||
# 1. Libraries (Linked objects/collections)
|
||||
for lib in bpy.data.libraries:
|
||||
new_fp = replace_in_prop(lib.filepath, old_path, new_path)
|
||||
if new_fp != lib.filepath:
|
||||
lib.filepath = new_fp
|
||||
changed = True
|
||||
|
||||
# 2. Images (Textures, Env Maps)
|
||||
for img in bpy.data.images:
|
||||
new_fp = replace_in_prop(img.filepath, old_path, new_path)
|
||||
if new_fp != img.filepath:
|
||||
img.filepath = new_fp
|
||||
changed = True
|
||||
|
||||
# 3. Cache files (Alembic/VDB)
|
||||
for cache in bpy.data.cache_files:
|
||||
new_fp = replace_in_prop(cache.filepath, old_path, new_path)
|
||||
if new_fp != cache.filepath:
|
||||
cache.filepath = new_fp
|
||||
changed = True
|
||||
|
||||
# 4. Fonts
|
||||
for font in bpy.data.fonts:
|
||||
new_fp = replace_in_prop(font.filepath, old_path, new_path)
|
||||
if new_fp != font.filepath:
|
||||
font.filepath = new_fp
|
||||
changed = True
|
||||
|
||||
# 5. Video strips
|
||||
for scene in bpy.data.scenes:
|
||||
if scene.sequence_editor:
|
||||
for strip in scene.sequence_editor.sequences_all:
|
||||
if strip.type in ('MOVIE', 'IMAGE'):
|
||||
new_fp = replace_in_prop(strip.filepath, old_path, new_path)
|
||||
if new_fp != strip.filepath:
|
||||
strip.filepath = new_fp
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
print(f"[*] Updated paths in {bpy.data.filepath}")
|
||||
bpy.ops.wm.save_mainfile()
|
||||
|
||||
try:
|
||||
run()
|
||||
except Exception as e:
|
||||
print(f"[!] Error updating blend file paths: {e}")
|
||||
EOF
|
||||
|
||||
# Determine what to search for blend files
|
||||
if [[ -n "$SEARCH_PROJECT" ]]; then
|
||||
SEARCH_ROOT=$(get_abs_path "$SEARCH_PROJECT")
|
||||
echo "Scanning project root '$SEARCH_ROOT' for .blend files..."
|
||||
elif [[ -d "$ABS_DEST" ]]; then
|
||||
SEARCH_ROOT="$ABS_DEST"
|
||||
echo "Scanning moved directory for .blend files..."
|
||||
elif [[ "$ABS_DEST" == *.blend ]]; then
|
||||
SEARCH_ROOT="$ABS_DEST"
|
||||
echo "Updating moved .blend file..."
|
||||
else
|
||||
echo "No project root defined and moved object is not a directory or .blend file."
|
||||
echo "Finished."
|
||||
rm -f "$TMP_PY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Execute headless blender on all found blend files
|
||||
if [[ -d "$SEARCH_ROOT" ]]; then
|
||||
find "$SEARCH_ROOT" -type f -name "*.blend" -print0 | while IFS= read -r -d '' BLEND_FILE; do
|
||||
blender -b "$BLEND_FILE" -P "$TMP_PY" -- "$ABS_SOURCE" "$ABS_DEST" >/dev/null 2>&1 || echo "Warning: Failed to process $BLEND_FILE"
|
||||
done
|
||||
elif [[ -f "$SEARCH_ROOT" && "$SEARCH_ROOT" == *.blend ]]; then
|
||||
blender -b "$SEARCH_ROOT" -P "$TMP_PY" -- "$ABS_SOURCE" "$ABS_DEST" >/dev/null 2>&1 || echo "Warning: Failed to process $SEARCH_ROOT"
|
||||
fi
|
||||
|
||||
rm -f "$TMP_PY"
|
||||
echo "Success: Move complete and paths updated."
|
||||
39
docker-compose.gatekeeper.yml
Normal file
39
docker-compose.gatekeeper.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
gatekeeper-proxy:
|
||||
image: alpine:latest
|
||||
command: sleep infinity
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- infra
|
||||
labels:
|
||||
- "caddy=http://gatekeeper.localhost"
|
||||
- "caddy.route=/*"
|
||||
- "caddy.route.0_redir=/ /gatekeeper/login 302"
|
||||
- "caddy.route.1_reverse_proxy=gatekeeper-app:3000"
|
||||
|
||||
gatekeeper-app:
|
||||
image: node:20-alpine
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
- gatekeeper_root_node_modules:/app/node_modules
|
||||
- gatekeeper_pkg_node_modules:/app/packages/gatekeeper/node_modules
|
||||
- gatekeeper_next_cache:/app/packages/gatekeeper/.next
|
||||
- gatekeeper_pnpm_store:/pnpm
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- NPM_TOKEN=${NPM_TOKEN:-}
|
||||
networks:
|
||||
- infra
|
||||
command: >
|
||||
sh -c "corepack enable && pnpm config set store-dir /pnpm && pnpm install --no-frozen-lockfile && pnpm --filter @mintel/gatekeeper run dev --hostname 0.0.0.0 --port 3000"
|
||||
|
||||
networks:
|
||||
infra:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
gatekeeper_root_node_modules:
|
||||
gatekeeper_pkg_node_modules:
|
||||
gatekeeper_next_cache:
|
||||
gatekeeper_pnpm_store:
|
||||
114
docker-compose.mcps.yml
Normal file
114
docker-compose.mcps.yml
Normal file
@@ -0,0 +1,114 @@
|
||||
services:
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
container_name: qdrant-mcp
|
||||
ports:
|
||||
- "6335:6333"
|
||||
- "6336:6334"
|
||||
volumes:
|
||||
- ./data/qdrant:/qdrant/storage
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
gitea-mcp:
|
||||
build:
|
||||
context: ./packages/gitea-mcp
|
||||
container_name: gitea-mcp
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3011:3001"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
memory-mcp:
|
||||
build:
|
||||
context: ./packages/memory-mcp
|
||||
container_name: memory-mcp
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3002:3002"
|
||||
depends_on:
|
||||
- qdrant
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
umami-mcp:
|
||||
build:
|
||||
context: ./packages/umami-mcp
|
||||
container_name: umami-mcp
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3003:3003"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
serpbear-mcp:
|
||||
build:
|
||||
context: ./packages/serpbear-mcp
|
||||
container_name: serpbear-mcp
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3004:3004"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
glitchtip-mcp:
|
||||
build:
|
||||
context: ./packages/glitchtip-mcp
|
||||
container_name: glitchtip-mcp
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3005:3005"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
klz-payload-mcp:
|
||||
build:
|
||||
context: ./packages/klz-payload-mcp
|
||||
container_name: klz-payload-mcp
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3006:3006"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
vikunja-mcp:
|
||||
build:
|
||||
context: ./packages/vikunja-mcp
|
||||
container_name: vikunja-mcp
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3007:3007"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
outline-mcp:
|
||||
build:
|
||||
context: ./packages/outline-mcp
|
||||
container_name: outline-mcp
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3008:3008"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
networks:
|
||||
mcp-network:
|
||||
driver: bridge
|
||||
69
eslint-errors-2.txt
Normal file
69
eslint-errors-2.txt
Normal file
@@ -0,0 +1,69 @@
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/gitea-mcp/src/index.ts[24m[0m
|
||||
[0m [2m11:0[22m [31merror[39m Parsing error: Identifier expected[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/glitchtip-mcp/src/index.ts[24m[0m
|
||||
[0m [2m124:19[22m [33mwarning[39m 'res' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/klz-payload-mcp/src/index.ts[24m[0m
|
||||
[0m [2m39:18[22m [33mwarning[39m 'e' is defined but never used. Allowed unused caught errors must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/memory-mcp/src/qdrant.test.ts[24m[0m
|
||||
[0m [2m7:52[22m [33mwarning[39m 'text' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/page-audit/src/report.ts[24m[0m
|
||||
[0m [2m7:47[22m [33mwarning[39m 'PageAuditData' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m7:62[22m [33mwarning[39m 'AuditIssue' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/AiFieldButton.tsx[24m[0m
|
||||
[0m [2m11:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/GenerateSlugButton.tsx[24m[0m
|
||||
[0m [2m20:21[22m [33mwarning[39m 'replaceState' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m21:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/GenerateThumbnailButton.tsx[24m[0m
|
||||
[0m [2m21:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/OptimizeButton.tsx[24m[0m
|
||||
[0m [2m5:10[22m [33mwarning[39m 'Button' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/mcpAdapter.ts[24m[0m
|
||||
[0m [2m44:15[22m [33mwarning[39m 'toolSchema' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/memoryDb.ts[24m[0m
|
||||
[0m [2m89:31[22m [33mwarning[39m 'query' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/payloadLocal.ts[24m[0m
|
||||
[0m [2m3:40[22m [33mwarning[39m 'User' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/types.ts[24m[0m
|
||||
[0m [2m1:15[22m [33mwarning[39m 'Plugin' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/ConceptPDF.tsx[24m[0m
|
||||
[0m [2m4:18[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m5:10[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/EstimationPDF.tsx[24m[0m
|
||||
[0m [2m4:18[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m5:10[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m54:11[22m [33mwarning[39m 'getPageNum' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/InfoPDF.tsx[24m[0m
|
||||
[0m [2m5:13[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m12:5[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/pdf/SharedUI.tsx[24m[0m
|
||||
[0m [2m528:5[22m [33mwarning[39m 'bankData' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/pdf/SimpleLayout.tsx[24m[0m
|
||||
[0m [2m4:52[22m [33mwarning[39m 'PDFText' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m5:26[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/seo-engine/src/report.ts[24m[0m
|
||||
[0m [2m5:3[22m [33mwarning[39m 'TopicCluster' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m6:3[22m [33mwarning[39m 'ContentGap' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m7:3[22m [33mwarning[39m 'CompetitorRanking' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[31m[1m✖ 28 problems (1 error, 27 warnings)[22m[39m[0m
|
||||
[0m[31m[1m[22m[39m[0m
|
||||
97
eslint-errors.txt
Normal file
97
eslint-errors.txt
Normal file
@@ -0,0 +1,97 @@
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/gitea-mcp/src/index.ts[24m[0m
|
||||
[0m [2m12:5[22m [33mwarning[39m 'Resource' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m14:10[22m [33mwarning[39m 'z' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m427:30[22m [33mwarning[39m 'e' is defined but never used. Allowed unused caught errors must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m745:50[22m [31merror[39m Unnecessary escape character: \/ [2mno-useless-escape[22m[0m
|
||||
[0m [2m745:60[22m [31merror[39m Unnecessary escape character: \/ [2mno-useless-escape[22m[0m
|
||||
[0m [2m799:54[22m [31merror[39m Unnecessary escape character: \/ [2mno-useless-escape[22m[0m
|
||||
[0m [2m799:64[22m [31merror[39m Unnecessary escape character: \/ [2mno-useless-escape[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/glitchtip-mcp/src/index.ts[24m[0m
|
||||
[0m [2m124:19[22m [33mwarning[39m 'res' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/klz-payload-mcp/src/index.ts[24m[0m
|
||||
[0m [2m39:18[22m [33mwarning[39m 'e' is defined but never used. Allowed unused caught errors must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/memory-mcp/src/qdrant.test.ts[24m[0m
|
||||
[0m [2m7:52[22m [33mwarning[39m 'text' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/page-audit/src/report.ts[24m[0m
|
||||
[0m [2m7:47[22m [33mwarning[39m 'PageAuditData' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m7:62[22m [33mwarning[39m 'AuditIssue' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/chatPlugin.ts[24m[0m
|
||||
[0m [2m1:15[22m [33mwarning[39m 'Config' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m10:17[22m [31merror[39m 'config' is never reassigned. Use 'const' instead [2mprefer-const[22m[0m
|
||||
[0m [2m48:37[22m [33mwarning[39m 'req' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/ChatWindow/index.tsx[24m[0m
|
||||
[0m [2m43:5[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m [2m44:63[22m [33mwarning[39m 'setMessages' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/AiFieldButton.tsx[24m[0m
|
||||
[0m [2m11:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/GenerateSlugButton.tsx[24m[0m
|
||||
[0m [2m20:21[22m [33mwarning[39m 'replaceState' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m21:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/FieldGenerators/GenerateThumbnailButton.tsx[24m[0m
|
||||
[0m [2m21:13[22m [33mwarning[39m 'value' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/components/OptimizeButton.tsx[24m[0m
|
||||
[0m [2m5:10[22m [33mwarning[39m 'Button' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/endpoints/chatEndpoint.ts[24m[0m
|
||||
[0m [2m96:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m [2m100:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/mcpAdapter.ts[24m[0m
|
||||
[0m [2m44:15[22m [33mwarning[39m 'toolSchema' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m53:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/memoryDb.ts[24m[0m
|
||||
[0m [2m50:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m [2m88:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m [2m89:31[22m [33mwarning[39m 'query' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/tools/payloadLocal.ts[24m[0m
|
||||
[0m [2m3:40[22m [33mwarning[39m 'User' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m25:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m [2m45:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m [2m61:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m [2m78:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m [2m95:13[22m [31merror[39m Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free [2m@typescript-eslint/ban-ts-comment[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/payload-ai/src/types.ts[24m[0m
|
||||
[0m [2m1:15[22m [33mwarning[39m 'Plugin' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/ConceptPDF.tsx[24m[0m
|
||||
[0m [2m4:18[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m5:10[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/EstimationPDF.tsx[24m[0m
|
||||
[0m [2m4:18[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m5:10[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m54:11[22m [33mwarning[39m 'getPageNum' is assigned a value but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/InfoPDF.tsx[24m[0m
|
||||
[0m [2m5:13[22m [33mwarning[39m 'PDFPage' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m12:5[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/pdf/SharedUI.tsx[24m[0m
|
||||
[0m [2m528:5[22m [33mwarning[39m 'bankData' is defined but never used. Allowed unused args must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/pdf-library/src/components/pdf/SimpleLayout.tsx[24m[0m
|
||||
[0m [2m4:52[22m [33mwarning[39m 'PDFText' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m5:26[22m [33mwarning[39m 'pdfStyles' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[4m/Users/marcmintel/Projects/at-mintel/packages/seo-engine/src/report.ts[24m[0m
|
||||
[0m [2m5:3[22m [33mwarning[39m 'TopicCluster' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m6:3[22m [33mwarning[39m 'ContentGap' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m [2m7:3[22m [33mwarning[39m 'CompetitorRanking' is defined but never used. Allowed unused vars must match /^_/u [2m@typescript-eslint/no-unused-vars[22m[0m
|
||||
[0m[0m
|
||||
[0m[31m[1m✖ 49 problems (16 errors, 33 warnings)[22m[39m[0m
|
||||
[0m[31m[1m[22m[39m[31m[1m 1 error and 0 warnings potentially fixable with the `--fix` option.[22m[39m[0m
|
||||
[0m[31m[1m[22m[39m[0m
|
||||
12
fix-private.mjs
Normal file
12
fix-private.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import fs from 'fs';
|
||||
import glob from 'glob';
|
||||
|
||||
const files = glob.sync('/Users/marcmintel/Projects/at-mintel/packages/*/package.json');
|
||||
files.forEach(f => {
|
||||
const content = fs.readFileSync(f, 'utf8');
|
||||
if (content.includes('"private": true,')) {
|
||||
console.log(`Fixing ${f}`);
|
||||
const newContent = content.replace(/\s*"private": true,?\n/g, '\n');
|
||||
fs.writeFileSync(f, newContent);
|
||||
}
|
||||
});
|
||||
15
package.json
15
package.json
@@ -5,11 +5,19 @@
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"dev": "pnpm -r dev",
|
||||
"dev:gatekeeper": "bash -c 'trap \"COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml down && COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml up --build --remove-orphans'",
|
||||
"dev:mcps:up": "docker-compose -f docker-compose.mcps.yml up -d --build --remove-orphans",
|
||||
"dev:mcps:down": "docker-compose -f docker-compose.mcps.yml down",
|
||||
"dev:mcps:watch": "pnpm -r --filter=\"./packages/*-mcp\" exec tsc -w",
|
||||
"dev:mcps": "npm run dev:mcps:up && npm run dev:mcps:watch",
|
||||
"start:mcps": "npm run dev:mcps:up",
|
||||
"start:mcps:force": "docker-compose -f docker-compose.mcps.yml up -d --build --force-recreate --remove-orphans",
|
||||
"lint": "pnpm -r --filter='./packages/**' --filter='./apps/**' lint",
|
||||
"test": "pnpm -r test",
|
||||
"changeset": "changeset",
|
||||
"version-packages": "changeset version",
|
||||
"sync-versions": "tsx scripts/sync-versions.ts --",
|
||||
"release:version": "bash scripts/release.sh",
|
||||
"release": "pnpm build && changeset publish",
|
||||
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
|
||||
"prepare": "husky"
|
||||
@@ -23,7 +31,7 @@
|
||||
"@next/eslint-plugin-next": "16.1.6",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^20.17.16",
|
||||
"@types/node": "^20.19.33",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
@@ -34,6 +42,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"pm2": "^6.0.14",
|
||||
"prettier": "^3.8.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.0.0",
|
||||
@@ -47,7 +56,7 @@
|
||||
"pino-pretty": "^13.1.3",
|
||||
"require-in-the-middle": "^8.0.1"
|
||||
},
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.17",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
@@ -66,4 +75,4 @@
|
||||
"@sentry/nextjs": "10.38.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/cli",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.17",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/cloner",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.17",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/concept-engine",
|
||||
"version": "1.9.4",
|
||||
"private": true,
|
||||
"version": "1.9.17",
|
||||
"description": "AI-powered web project concept generation and analysis",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/content-engine",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.17",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/eslint-config",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.17",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/estimation-engine",
|
||||
"version": "1.9.4",
|
||||
"private": true,
|
||||
"version": "1.9.17",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/gatekeeper",
|
||||
"version": "1.9.4",
|
||||
"private": true,
|
||||
"version": "1.9.17",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -12,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"framer-motion": "^11.18.2",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "16.1.6",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-slate-800 font-serif antialiased selection:bg-slate-900 selection:text-white;
|
||||
@apply bg-[#f5f5f7] text-black/80 font-serif antialiased selection:bg-black/10 selection:text-black;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -18,15 +18,15 @@
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-sans font-bold text-slate-900 tracking-tighter;
|
||||
@apply font-sans font-bold text-black tracking-tighter;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply mb-4 text-base leading-relaxed text-slate-700;
|
||||
@apply mb-4 text-base leading-relaxed text-black/50;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-slate-900 hover:text-slate-700 transition-colors no-underline;
|
||||
@apply text-black/50 hover:text-black transition-colors no-underline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,34 +36,58 @@
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-6 py-3 border border-slate-200 bg-white text-slate-600 font-sans font-bold text-sm uppercase tracking-widest rounded-full transition-all duration-500 ease-industrial hover:border-slate-400 hover:text-slate-900 hover:bg-slate-50 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-slate-100 active:translate-y-0 active:shadow-sm;
|
||||
@apply inline-flex items-center justify-center px-6 py-3 border border-black/10 bg-white text-black/60 font-sans font-bold text-sm uppercase tracking-widest rounded-full transition-all duration-500 ease-industrial hover:border-black/20 hover:text-black hover:bg-white hover:-translate-y-0.5 hover:shadow-xl hover:shadow-black/5 active:translate-y-0 active:shadow-sm;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply border-slate-900 text-slate-900 hover:bg-slate-900 hover:text-white;
|
||||
@apply border-black bg-black text-white hover:bg-black/85 hover:text-white;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
/* Custom scrollbar - light theme */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
background: #d1d1d6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
background: #b0b0b8;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
@@ -79,6 +103,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.8s ease-out 0.2s forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.2s ease-in-out 0s 2;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,17 @@ const newsreader = Newsreader({
|
||||
export const metadata: Metadata = {
|
||||
title: "Gatekeeper | Access Control",
|
||||
description: "Mintel Infrastructure Protection",
|
||||
openGraph: {
|
||||
title: "Gatekeeper | Access Control",
|
||||
description: "Mintel Infrastructure Protection",
|
||||
siteName: "Mintel Gatekeeper",
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Gatekeeper | Access Control",
|
||||
description: "Mintel Infrastructure Protection",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ArrowRight, ShieldCheck } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { GateScene } from "../../components/gate-scene";
|
||||
import { AnimatedLoginForm } from "../../components/animated-login-form";
|
||||
|
||||
interface LoginPageProps {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
@@ -17,8 +18,8 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
async function login(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const email = (formData.get("email") as string || "").trim();
|
||||
const password = (formData.get("password") as string || "").trim();
|
||||
const email = ((formData.get("email") as string) || "").trim();
|
||||
const password = ((formData.get("password") as string) || "").trim();
|
||||
|
||||
const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||
const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||
@@ -116,7 +117,9 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
}
|
||||
|
||||
if (userIdentity) {
|
||||
console.log(`[Login] Success: ${userIdentity} | Redirect: ${targetRedirect}`);
|
||||
console.log(
|
||||
`[Login] Success: ${userIdentity} | Redirect: ${targetRedirect}`,
|
||||
);
|
||||
const cookieStore = await cookies();
|
||||
// Store identity in the cookie (simplified for now, ideally signed)
|
||||
const sessionValue = JSON.stringify({
|
||||
@@ -127,7 +130,9 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
console.log(`[Login] Setting Cookie: ${authCookieName} | Domain: ${cookieDomain || "Default"}`);
|
||||
console.log(
|
||||
`[Login] Setting Cookie: ${authCookieName} | Domain: ${cookieDomain || "Default"}`,
|
||||
);
|
||||
|
||||
cookieStore.set(authCookieName, sessionValue, {
|
||||
httpOnly: true,
|
||||
@@ -145,101 +150,79 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative bg-white font-serif antialiased overflow-hidden">
|
||||
{/* Background Decor - Signature mintel.me style */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-[0.03] scale-[1.01]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, #000 1px, transparent 1px), linear-gradient(to bottom, #000 1px, transparent 1px)`,
|
||||
backgroundSize: "clamp(30px, 8vw, 40px) clamp(30px, 8vw, 40px)",
|
||||
}}
|
||||
/>
|
||||
<div className="min-h-screen flex items-center justify-center relative bg-[#f5f5f7] font-serif antialiased overflow-hidden selection:bg-black/10 selection:text-black">
|
||||
{/* 3D Digital Gate Background */}
|
||||
<GateScene />
|
||||
|
||||
<main className="relative z-10 w-full max-w-sm px-8 sm:px-6">
|
||||
<div className="space-y-12 sm:space-y-16 animate-fade-in">
|
||||
{/* Top Icon Box - Signature mintel.me Black Square */}
|
||||
<main className="relative z-10 w-full max-w-[380px] px-6 sm:px-4 pb-24 sm:pb-32 pointer-events-auto">
|
||||
<div className="space-y-10 animate-fade-in">
|
||||
{/* Top Icon Box */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 bg-black rounded-xl flex items-center justify-center shadow-xl shadow-slate-100 hover:scale-105 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] rotate-2 hover:rotate-0">
|
||||
<Image
|
||||
<a
|
||||
href="https://mintel.me"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-14 h-14 bg-white rounded-2xl flex items-center justify-center shadow-lg shadow-black/[0.06] hover:scale-105 hover:shadow-black/10 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] border border-black/[0.06] hover:border-black/10"
|
||||
>
|
||||
<img
|
||||
src="/gatekeeper/icon-white.svg"
|
||||
alt="Mintel"
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-8 h-8"
|
||||
unoptimized
|
||||
style={{ filter: "invert(1)" }}
|
||||
width={28}
|
||||
height={28}
|
||||
className="w-7 h-7 opacity-80"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="space-y-12 animate-slide-up">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-xs font-sans font-bold uppercase tracking-[0.4em] text-slate-900 border-b border-slate-50 pb-4 inline-block mx-auto min-w-[200px]">
|
||||
{projectName} <span className="text-slate-300">Gatekeeper</span>
|
||||
<div className="space-y-8 animate-slide-up">
|
||||
<div className="text-center space-y-3">
|
||||
<h1 className="text-[11px] font-sans font-bold uppercase tracking-[0.5em] text-black/80 pb-3 inline-block mx-auto min-w-[220px]">
|
||||
{projectName} <span className="text-black/30">Gatekeeper</span>
|
||||
</h1>
|
||||
<p className="text-[10px] text-slate-400 font-sans uppercase tracking-widest italic flex items-center justify-center gap-2">
|
||||
<span className="w-1 h-1 bg-slate-200 rounded-full" />
|
||||
Infrastructure Protection
|
||||
<span className="w-1 h-1 bg-slate-200 rounded-full" />
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="h-px w-8 bg-gradient-to-r from-transparent to-black/10" />
|
||||
<p className="text-[8px] text-black/30 font-sans uppercase tracking-[0.35em] font-semibold">
|
||||
Infrastructure Protection
|
||||
</p>
|
||||
<div className="h-px w-8 bg-gradient-to-l from-transparent to-black/10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 px-5 py-3 rounded-2xl text-[9px] font-sans font-bold uppercase tracking-widest flex items-center gap-3 border border-red-100 animate-shake">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<div className="bg-red-50 backdrop-blur-md text-red-600 px-5 py-4 rounded-2xl text-[9px] font-sans font-bold uppercase tracking-widest flex items-center gap-3 border border-red-200 animate-shake">
|
||||
<ShieldCheck className="w-4 h-4 text-red-500/70" />
|
||||
<span>Access Denied. Try Again.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={login} className="space-y-4">
|
||||
<input type="hidden" name="redirect" value={redirectUrl} />
|
||||
{/* The Animated Framer Motion Form */}
|
||||
<AnimatedLoginForm
|
||||
redirectUrl={redirectUrl}
|
||||
loginAction={login}
|
||||
projectName={projectName}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="EMAIL (OPTIONAL)"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-[10px] font-sans font-bold tracking-[0.2em] uppercase placeholder:text-slate-300 shadow-sm shadow-slate-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
placeholder="ACCESS CODE"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-sm font-sans font-bold tracking-[0.3em] uppercase placeholder:text-slate-300 placeholder:tracking-widest shadow-sm shadow-slate-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100 flex items-center justify-center"
|
||||
{/* Bottom Section */}
|
||||
<div className="pt-4 sm:pt-6 flex flex-col items-center gap-5">
|
||||
<div className="h-px w-16 bg-gradient-to-r from-transparent via-black/10 to-transparent" />
|
||||
<a
|
||||
href="https://mintel.me"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="opacity-30 transition-opacity hover:opacity-60"
|
||||
>
|
||||
Unlock Access
|
||||
<ArrowRight className="ml-3 w-3 h-3 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Bottom Section - Full Branding Parity */}
|
||||
<div className="pt-12 sm:pt-20 flex flex-col items-center gap-6 sm:gap-8">
|
||||
<div className="h-px w-8 bg-slate-100" />
|
||||
<div className="opacity-80 transition-opacity hover:opacity-100">
|
||||
<Image
|
||||
src="/gatekeeper/logo-black.svg"
|
||||
<img
|
||||
src="/gatekeeper/logo-white.svg"
|
||||
alt={projectName}
|
||||
width={140}
|
||||
height={40}
|
||||
className="h-7 sm:h-auto grayscale contrast-125 w-auto"
|
||||
unoptimized
|
||||
width={120}
|
||||
height={36}
|
||||
className="h-5 sm:h-6 w-auto"
|
||||
style={{ filter: "invert(1)" }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[8px] font-sans font-bold text-slate-300 uppercase tracking-[0.4em] sm:tracking-[0.5em] text-center">
|
||||
© 2026 MINTEL
|
||||
</a>
|
||||
<p className="text-[7px] font-sans font-semibold text-black/25 uppercase tracking-[0.5em] text-center">
|
||||
© {new Date().getFullYear()} MINTEL
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
126
packages/gatekeeper/src/app/opengraph-image.tsx
Normal file
126
packages/gatekeeper/src/app/opengraph-image.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
// Image metadata
|
||||
export const alt = "Gatekeeper Infrastructure Protection";
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
};
|
||||
|
||||
export const contentType = "image/png";
|
||||
|
||||
export default async function Image() {
|
||||
const projectName = process.env.PROJECT_NAME || "MINTEL";
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
background: "linear-gradient(to bottom, #020617, #0f172a)",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
fontFamily: "Inter, sans-serif",
|
||||
}}
|
||||
>
|
||||
{/* Subtle Background Pattern matching the industrial look */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 50% 50%, #334155 1px, transparent 1px)",
|
||||
backgroundSize: "40px 40px",
|
||||
opacity: 0.1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Central Card Element */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(15, 23, 42, 0.6)",
|
||||
border: "1px solid rgba(51, 65, 85, 0.4)",
|
||||
borderRadius: "32px",
|
||||
padding: "80px",
|
||||
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
|
||||
}}
|
||||
>
|
||||
{/* Top Icon Box */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100px",
|
||||
height: "100px",
|
||||
background: "#000",
|
||||
borderRadius: "24px",
|
||||
border: "2px solid #334155",
|
||||
boxShadow: "0 20px 40px rgba(0,0,0,0.8)",
|
||||
marginBottom: "40px",
|
||||
transform: "rotate(2deg)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Project Name & Typography */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: "64px",
|
||||
fontWeight: 900,
|
||||
letterSpacing: "0.2em",
|
||||
color: "white",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
{projectName}{" "}
|
||||
<span style={{ color: "#64748b", marginLeft: "10px" }}>
|
||||
GATEKEEPER
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: "24px",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.4em",
|
||||
color: "#94a3b8",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
Infrastructure Protection
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
...size,
|
||||
},
|
||||
);
|
||||
}
|
||||
283
packages/gatekeeper/src/components/animated-login-form.tsx
Normal file
283
packages/gatekeeper/src/components/animated-login-form.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRight, Lock, Shield, Fingerprint } from "lucide-react";
|
||||
|
||||
interface AnimatedLoginFormProps {
|
||||
redirectUrl: string;
|
||||
loginAction: (formData: FormData) => Promise<void>;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
export function AnimatedLoginForm({
|
||||
redirectUrl,
|
||||
loginAction,
|
||||
}: AnimatedLoginFormProps) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const beamRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Mouse tracking refs (no re-render)
|
||||
const mouse = useRef({ x: 0, y: 0 });
|
||||
const angle = useRef(0);
|
||||
const tilt = useRef({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Single rAF loop: iridescent border + perspective tilt
|
||||
useEffect(() => {
|
||||
let animId: number;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
mouse.current = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
|
||||
const animate = () => {
|
||||
if (!wrapperRef.current || !beamRef.current) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = wrapperRef.current.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const dx = mouse.current.x - cx;
|
||||
const dy = mouse.current.y - cy;
|
||||
|
||||
// Angle from form center to mouse → positions the bright highlight
|
||||
const targetAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
|
||||
// Lerp angle smoothly (shortest path)
|
||||
let diff = targetAngle - angle.current;
|
||||
while (diff > 180) diff -= 360;
|
||||
while (diff < -180) diff += 360;
|
||||
angle.current += diff * 0.06;
|
||||
|
||||
// Intensity: slightly stronger on focus
|
||||
const intensity = isFocused ? 1 : 0.7;
|
||||
|
||||
// Mouse-aligned iridescent conic gradient
|
||||
// The "hotspot" (brightest white) faces the mouse
|
||||
beamRef.current.style.background = `conic-gradient(from ${angle.current}deg at 50% 50%,
|
||||
rgba(255,255,255,${1.0 * intensity}) 0deg,
|
||||
rgba(200,210,255,${0.8 * intensity}) 20deg,
|
||||
rgba(255,200,230,${0.7 * intensity}) 45deg,
|
||||
rgba(150,160,180,${0.6 * intensity}) 80deg,
|
||||
rgba(40,40,50,${0.5 * intensity}) 160deg,
|
||||
rgba(20,20,30,${0.4 * intensity}) 200deg,
|
||||
rgba(140,150,170,${0.5 * intensity}) 280deg,
|
||||
rgba(210,225,255,${0.7 * intensity}) 320deg,
|
||||
rgba(255,255,255,${1.0 * intensity}) 360deg)`;
|
||||
|
||||
// Subtle perspective tilt — max ±4deg
|
||||
const maxTilt = 4;
|
||||
const normX = dx / (rect.width * 2);
|
||||
const normY = dy / (rect.height * 2);
|
||||
const targetTiltY = normX * maxTilt;
|
||||
const targetTiltX = -normY * maxTilt;
|
||||
tilt.current.x += (targetTiltX - tilt.current.x) * 0.08;
|
||||
tilt.current.y += (targetTiltY - tilt.current.y) * 0.08;
|
||||
|
||||
wrapperRef.current.style.transform = `perspective(800px) rotateX(${tilt.current.x}deg) rotateY(${tilt.current.y}deg)`;
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
cancelAnimationFrame(animId);
|
||||
};
|
||||
}, [isFocused]);
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await loginAction(formData);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.8, ease: [0.23, 1, 0.32, 1], delay: 0.3 }}
|
||||
className="relative"
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
{/* Outer wrapper for tilt (refs need non-motion div) */}
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
style={{ willChange: "transform", transformStyle: "preserve-3d" }}
|
||||
className="relative"
|
||||
>
|
||||
{/* ── Always-on iridescent beam border ── */}
|
||||
<div className="absolute -inset-[1.5px] rounded-[28px] z-0 overflow-hidden">
|
||||
{/* Sharp edge layer */}
|
||||
<div
|
||||
ref={beamRef}
|
||||
className="absolute inset-0 rounded-[28px]"
|
||||
style={{ filter: "blur(0.4px)" }}
|
||||
/>
|
||||
{/* Soft glow bloom */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-[28px]"
|
||||
style={{
|
||||
background: "inherit",
|
||||
filter: "blur(15px)",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Glassmorphism card — high-fidelity glossy light ── */}
|
||||
<div
|
||||
className="relative z-10 bg-white/70 backdrop-blur-3xl rounded-3xl p-7 sm:p-9"
|
||||
style={{
|
||||
boxShadow:
|
||||
/* Razor-sharp inner border highlight */ "inset 0 0 0 1px rgba(255,255,255,0.7), " +
|
||||
/* Top gloss edge */ "inset 0 1.5px 0.5px rgba(255,255,255,1), " +
|
||||
/* Secondary soft top gloss */ "inset 0 4px 10px rgba(255,255,255,0.4), " +
|
||||
/* Bottom inner shadow */ "inset 0 -1px 1px rgba(0,0,0,0.05), " +
|
||||
/* Outer drop shadows for depth */ "0 25px 50px -12px rgba(0,0,0,0.08), 0 4px 8px rgba(0,0,0,0.02)",
|
||||
}}
|
||||
>
|
||||
{/* Subtle surface "sheen" gradient */}
|
||||
<div className="absolute inset-0 rounded-3xl bg-gradient-to-br from-white/40 via-transparent to-black/[0.02] pointer-events-none" />
|
||||
|
||||
{/* Shield icon header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.5, duration: 0.6 }}
|
||||
className="flex justify-center mb-6"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-black/[0.04] to-black/[0.08] border border-black/[0.06] flex items-center justify-center backdrop-blur-sm">
|
||||
<Shield className="w-5 h-5 text-black/40" />
|
||||
</div>
|
||||
{mounted && (
|
||||
<div className="absolute -inset-2 rounded-2xl border border-black/[0.04] animate-ping opacity-30" />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<form
|
||||
action={handleSubmit}
|
||||
className="space-y-5 relative z-10"
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsFocused(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="redirect" value={redirectUrl} />
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Email Input */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.6, duration: 0.5 }}
|
||||
className="relative group"
|
||||
>
|
||||
<Fingerprint className="absolute left-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-black/20 group-focus-within:text-black/50 transition-colors duration-300 pointer-events-none" />
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Identity (optional)"
|
||||
onFocus={() => setIsFocused(true)}
|
||||
className="w-full bg-black/[0.02] border border-black/[0.06] rounded-2xl pl-11 pr-4 py-3.5 focus:outline-none focus:border-black/15 focus:bg-white/80 transition-all duration-300 text-[11px] font-sans font-medium tracking-[0.08em] placeholder:text-black/25 placeholder:normal-case placeholder:tracking-normal text-black/70"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-4 right-4 h-px bg-gradient-to-r from-transparent via-black/0 to-transparent group-focus-within:via-black/10 transition-all duration-500" />
|
||||
</motion.div>
|
||||
|
||||
{/* Password Input */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.7, duration: 0.5 }}
|
||||
className="relative group"
|
||||
>
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-black/20 group-focus-within:text-black/50 transition-colors duration-300 pointer-events-none" />
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
placeholder="Access code"
|
||||
onFocus={() => setIsFocused(true)}
|
||||
className="w-full bg-black/[0.02] border border-black/[0.06] rounded-2xl pl-11 pr-4 py-3.5 focus:outline-none focus:border-black/15 focus:bg-white/80 transition-all duration-300 text-[13px] font-sans font-medium tracking-[0.15em] placeholder:text-black/25 placeholder:tracking-normal placeholder:text-[11px] placeholder:font-normal text-black/80"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-4 right-4 h-px bg-gradient-to-r from-transparent via-black/0 to-transparent group-focus-within:via-black/10 transition-all duration-500" />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8, duration: 0.5 }}
|
||||
>
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={`relative w-full py-4 rounded-2xl text-[10px] font-bold tracking-[0.3em] uppercase flex items-center justify-center overflow-hidden transition-all duration-300 ${
|
||||
isSubmitting
|
||||
? "bg-black/5 text-black/25 cursor-not-allowed"
|
||||
: "bg-black text-white hover:bg-black/85 shadow-lg shadow-black/10"
|
||||
}`}
|
||||
>
|
||||
{!isSubmitting && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full hover:translate-x-full transition-transform duration-700" />
|
||||
)}
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center gap-3 relative z-10">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: 1,
|
||||
ease: "linear",
|
||||
}}
|
||||
className="w-4 h-4 border-2 border-white/20 border-t-white/70 rounded-full"
|
||||
/>
|
||||
Authenticating...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-3 relative z-10">
|
||||
Unlock Access
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</form>
|
||||
|
||||
{/* Security badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.0, duration: 0.5 }}
|
||||
className="mt-5 flex items-center justify-center gap-2 text-[8px] font-sans font-semibold text-black/25 uppercase tracking-[0.3em]"
|
||||
>
|
||||
<div className="w-1 h-1 rounded-full bg-black/20 animate-pulse" />
|
||||
Encrypted Connection
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
127
packages/gatekeeper/src/components/gate-scene.tsx
Normal file
127
packages/gatekeeper/src/components/gate-scene.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function GateScene() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const mouse = useRef({ x: -1000, y: -1000 });
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const FONT_SIZE = 13;
|
||||
const COL_GAP = 18;
|
||||
const ROW_GAP = 20;
|
||||
|
||||
interface Cell {
|
||||
char: string;
|
||||
alpha: number;
|
||||
targetAlpha: number;
|
||||
speed: number;
|
||||
nextChange: number;
|
||||
}
|
||||
|
||||
let cells: Cell[][] = [];
|
||||
let cols = 0;
|
||||
let rows = 0;
|
||||
let animId: number;
|
||||
|
||||
const init = () => {
|
||||
cols = Math.ceil(canvas.width / COL_GAP);
|
||||
rows = Math.ceil(canvas.height / ROW_GAP);
|
||||
cells = Array.from({ length: cols }, () =>
|
||||
Array.from({ length: rows }, () => ({
|
||||
char: Math.random() > 0.5 ? "1" : "0",
|
||||
alpha: Math.random() * 0.08,
|
||||
targetAlpha: Math.random() * 0.15,
|
||||
speed: 0.008 + Math.random() * 0.02,
|
||||
nextChange: Math.floor(Math.random() * 80),
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
mouse.current = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
init();
|
||||
};
|
||||
|
||||
let frame = 0;
|
||||
|
||||
const draw = () => {
|
||||
frame++;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = `${FONT_SIZE}px 'Courier New', monospace`;
|
||||
|
||||
for (let c = 0; c < cols; c++) {
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const cell = cells[c][r];
|
||||
const x = c * COL_GAP;
|
||||
const y = r * ROW_GAP + FONT_SIZE;
|
||||
|
||||
// Mouse proximity influence
|
||||
const dx = mouse.current.x - x;
|
||||
const dy = mouse.current.y - y;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
const proximity = Math.max(0, 1 - Math.sqrt(distSq) / 250);
|
||||
|
||||
// Nudge alpha toward target
|
||||
cell.alpha += (cell.targetAlpha - cell.alpha) * cell.speed;
|
||||
|
||||
// More aggressive random behavior
|
||||
if (frame >= cell.nextChange) {
|
||||
cell.targetAlpha = Math.random() * 0.25; // Higher max alpha
|
||||
cell.speed = 0.01 + Math.random() * 0.03; // Faster transitions
|
||||
cell.nextChange = frame + 20 + Math.floor(Math.random() * 80); // More frequent changes
|
||||
|
||||
// Higher flip probability near mouse
|
||||
const flipProb = 0.3 + proximity * 0.5;
|
||||
if (Math.random() < flipProb) {
|
||||
cell.char = cell.char === "0" ? "1" : "0";
|
||||
}
|
||||
}
|
||||
|
||||
const a = Math.min(0.4, cell.alpha + proximity * 0.35);
|
||||
if (a < 0.01) continue;
|
||||
|
||||
// Dark chars on light background
|
||||
ctx.fillStyle = `rgba(0, 0, 0, ${a})`;
|
||||
ctx.fillText(cell.char, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
animId = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId);
|
||||
window.removeEventListener("resize", resize);
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none z-0 bg-[#f5f5f7]">
|
||||
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 55% 65% at 50% 50%, rgba(245,245,247,0.7) 0%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
packages/gitea-mcp/Dockerfile
Normal file
19
packages/gitea-mcp/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN npm install -g pnpm@10.18.3 && pnpm install
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Use node to run the compiled start.js
|
||||
ENTRYPOINT ["node", "dist/start.js"]
|
||||
22
packages/gitea-mcp/package.json
Normal file
22
packages/gitea-mcp/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@mintel/gitea-mcp",
|
||||
"version": "1.9.17",
|
||||
"description": "Native Gitea MCP server for 100% Antigravity compatibility",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/start.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||
"axios": "^1.7.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.19.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.10",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
1347
packages/gitea-mcp/src/index.ts
Normal file
1347
packages/gitea-mcp/src/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
16
packages/gitea-mcp/src/start.ts
Normal file
16
packages/gitea-mcp/src/start.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
// Try to load .env.local first (contains credentials usually)
|
||||
config({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||
// Fallback to .env (contains defaults)
|
||||
config({ quiet: true, path: resolve(__dirname, '../../../.env') });
|
||||
|
||||
// Now boot the compiled MCP index
|
||||
import('./index.js').catch(err => {
|
||||
console.error('Failed to start MCP Server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
16
packages/gitea-mcp/tsconfig.json
Normal file
16
packages/gitea-mcp/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
15
packages/glitchtip-mcp/Dockerfile
Normal file
15
packages/glitchtip-mcp/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:20-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:20-bookworm-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
ENTRYPOINT ["node", "dist/start.js"]
|
||||
24
packages/glitchtip-mcp/package.json
Normal file
24
packages/glitchtip-mcp/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@mintel/glitchtip-mcp",
|
||||
"version": "1.9.17",
|
||||
"description": "GlitchTip Error Tracking MCP server for Mintel infrastructure",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/start.js",
|
||||
"dev": "tsx watch src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||
"axios": "^1.7.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.19.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.10",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
219
packages/glitchtip-mcp/src/index.ts
Normal file
219
packages/glitchtip-mcp/src/index.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import express, { Request, Response } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import axios from "axios";
|
||||
import https from "https";
|
||||
|
||||
const GLITCHTIP_BASE_URL = process.env.GLITCHTIP_BASE_URL || "https://glitchtip.infra.mintel.me";
|
||||
const GLITCHTIP_API_KEY = process.env.GLITCHTIP_API_KEY;
|
||||
|
||||
if (!GLITCHTIP_API_KEY) {
|
||||
console.error("Warning: GLITCHTIP_API_KEY is not set. API calls will fail.");
|
||||
}
|
||||
|
||||
const httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false, // For internal infra
|
||||
});
|
||||
|
||||
const glitchtipClient = axios.create({
|
||||
baseURL: `${GLITCHTIP_BASE_URL}/api/0`,
|
||||
headers: { Authorization: `Bearer ${GLITCHTIP_API_KEY}` },
|
||||
httpsAgent,
|
||||
timeout: 10000 // Add timeout to prevent hangs
|
||||
});
|
||||
|
||||
// Logging middleware for GlitchTip client
|
||||
glitchtipClient.interceptors.request.use((config) => {
|
||||
console.error(`GlitchTip Request: ${config.method?.toUpperCase()} ${config.url}`);
|
||||
return config;
|
||||
}, (error) => {
|
||||
console.error(`GlitchTip Request Error: ${error.message}`);
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
glitchtipClient.interceptors.response.use((response) => {
|
||||
return response;
|
||||
}, (error) => {
|
||||
const msg = error.response?.data?.detail || error.response?.data?.message || error.message;
|
||||
console.error(`GlitchTip API Error (${error.config?.url}): ${msg}`);
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
const LIST_PROJECTS_TOOL: Tool = {
|
||||
name: "glitchtip_list_projects",
|
||||
description: "List all projects and organizations in GlitchTip",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
};
|
||||
|
||||
const LIST_ISSUES_TOOL: Tool = {
|
||||
name: "glitchtip_list_issues",
|
||||
description: "List issues (errors) for a specific project",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
organization_slug: { type: "string", description: "The organization slug" },
|
||||
project_slug: { type: "string", description: "The project slug" },
|
||||
query: { type: "string", description: "Optional query filter (e.g., 'is:unresolved')" },
|
||||
limit: { type: "number", description: "Maximum number of issues to return (default: 20)" },
|
||||
},
|
||||
required: ["organization_slug", "project_slug"],
|
||||
},
|
||||
};
|
||||
|
||||
const GET_ISSUE_DETAILS_TOOL: Tool = {
|
||||
name: "glitchtip_get_issue_details",
|
||||
description: "Get detailed information about a specific issue, including stack trace",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
issue_id: { type: "string", description: "The ID of the issue" },
|
||||
},
|
||||
required: ["issue_id"],
|
||||
},
|
||||
};
|
||||
|
||||
const UPDATE_ISSUE_TOOL: Tool = {
|
||||
name: "glitchtip_update_issue",
|
||||
description: "Update the status of an issue (e.g., resolve it)",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
issue_id: { type: "string", description: "The ID of the issue" },
|
||||
status: { type: "string", enum: ["resolved", "unresolved", "ignored"], description: "The new status" },
|
||||
},
|
||||
required: ["issue_id", "status"],
|
||||
},
|
||||
};
|
||||
|
||||
const server = new Server(
|
||||
{ name: "glitchtip-mcp", version: "1.0.0" },
|
||||
{ capabilities: { tools: {} } }
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
LIST_PROJECTS_TOOL,
|
||||
LIST_ISSUES_TOOL,
|
||||
GET_ISSUE_DETAILS_TOOL,
|
||||
UPDATE_ISSUE_TOOL,
|
||||
],
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
try {
|
||||
if (request.params.name === "glitchtip_list_projects") {
|
||||
const res = await glitchtipClient.get('/projects/');
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
|
||||
if (request.params.name === "glitchtip_list_issues") {
|
||||
const { organization_slug, project_slug, query, limit = 20 } = request.params.arguments as any;
|
||||
const res = await glitchtipClient.get(`/projects/${organization_slug}/${project_slug}/issues/`, {
|
||||
params: { query, limit }
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
|
||||
if (request.params.name === "glitchtip_get_issue_details") {
|
||||
const { issue_id } = request.params.arguments as any;
|
||||
const res = await glitchtipClient.get(`/issues/${issue_id}/`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
|
||||
if (request.params.name === "glitchtip_update_issue") {
|
||||
const { issue_id, status } = request.params.arguments as any;
|
||||
const res = await glitchtipClient.put(`/issues/${issue_id}/`, { status });
|
||||
return { content: [{ type: "text", text: `Issue ${issue_id} status updated to ${status}.` }] };
|
||||
}
|
||||
|
||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||
} catch (e: any) {
|
||||
const msg = e.response?.data?.detail || e.response?.data?.message || e.message;
|
||||
return { isError: true, content: [{ type: "text", text: `GlitchTip Service Error: ${msg}` }] };
|
||||
}
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const isStdio = process.argv.includes('--stdio');
|
||||
|
||||
if (isStdio) {
|
||||
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error('GlitchTip MCP server is running on stdio');
|
||||
} else {
|
||||
const app = express();
|
||||
const transports = new Map<string, SSEServerTransport>();
|
||||
|
||||
// Manual CORS middleware
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version');
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((req, _res, next) => {
|
||||
console.error(`${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/sse', async (req, res) => {
|
||||
const transport = new SSEServerTransport('/message', res as any);
|
||||
const sessionId = transport.sessionId;
|
||||
console.error(`New SSE connection: ${sessionId}`);
|
||||
transports.set(sessionId, transport);
|
||||
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
res.write(": heartbeat\n\n");
|
||||
}, 15000);
|
||||
|
||||
req.on('close', () => {
|
||||
console.error(`SSE connection closed: ${sessionId}`);
|
||||
clearInterval(heartbeatInterval);
|
||||
transports.delete(sessionId);
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
req.on('close', resolve);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/message', express.json(), async (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const transport = transports.get(sessionId);
|
||||
|
||||
console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`);
|
||||
|
||||
if (!transport) {
|
||||
console.error(`No transport found for session: ${sessionId}`);
|
||||
res.status(400).send('No active SSE connection for this session');
|
||||
return;
|
||||
}
|
||||
await transport.handlePostMessage(req, res);
|
||||
});
|
||||
|
||||
const PORT = Number(process.env.GLITCHTIP_MCP_PORT) || 3005;
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.error(`GlitchTip MCP server running on http://0.0.0.0:${PORT}/sse`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
run().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
13
packages/glitchtip-mcp/src/start.ts
Normal file
13
packages/glitchtip-mcp/src/start.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
config({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||
config({ quiet: true, path: resolve(__dirname, '../../../.env') });
|
||||
|
||||
import('./index.js').catch(err => {
|
||||
console.error('Failed to start GlitchTip MCP Server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
16
packages/glitchtip-mcp/tsconfig.json
Normal file
16
packages/glitchtip-mcp/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/husky-config",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.17",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Start from the pre-built Nextjs Base image
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
||||
FROM git.infra.mintel.me/mmintel/nextjs:latest AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -20,7 +20,7 @@ ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||
RUN pnpm --filter ${APP_NAME:-app} build
|
||||
|
||||
# Production runner image
|
||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Copy standalone output and static files
|
||||
|
||||
@@ -13,7 +13,7 @@ COPY packages/eslint-config/package.json ./packages/eslint-config/package.json
|
||||
COPY packages/next-config/package.json ./packages/next-config/package.json
|
||||
COPY packages/tsconfig/package.json ./packages/tsconfig/package.json
|
||||
COPY packages/infra/package.json ./packages/infra/package.json
|
||||
COPY packages/cms-infra/package.json ./packages/cms-infra/package.json
|
||||
|
||||
COPY packages/mail/package.json ./packages/mail/package.json
|
||||
COPY packages/cli/package.json ./packages/cli/package.json
|
||||
COPY packages/observability/package.json ./packages/observability/package.json
|
||||
|
||||
2
packages/infra/docker/config.yml
Normal file
2
packages/infra/docker/config.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
service:
|
||||
allowiconchanges: false
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-app}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||
|
||||
gatekeeper:
|
||||
image: registry.infra.mintel.me/mintel/gatekeeper:${IMAGE_TAG:-latest}
|
||||
image: git.infra.mintel.me/mmintel/gatekeeper:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
|
||||
|
||||
directus:
|
||||
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
||||
image: git.infra.mintel.me/mmintel/directus:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
|
||||
42
packages/infra/docker/vikunja-compose.yml
Normal file
42
packages/infra/docker/vikunja-compose.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_USER: vikunja
|
||||
POSTGRES_PASSWORD: mintel_vikunja_DB_2026!
|
||||
POSTGRES_DB: vikunja
|
||||
volumes:
|
||||
- ./data/db:/var/lib/postgresql/data
|
||||
restart: always
|
||||
|
||||
vikunja:
|
||||
image: vikunja/vikunja:latest
|
||||
environment:
|
||||
VIKUNJA_DATABASE_HOST: db
|
||||
VIKUNJA_DATABASE_PASSWORD: mintel_vikunja_DB_2026!
|
||||
VIKUNJA_DATABASE_TYPE: postgres
|
||||
VIKUNJA_DATABASE_USER: vikunja
|
||||
VIKUNJA_DATABASE_DATABASE: vikunja
|
||||
VIKUNJA_SERVICE_PUBLICURL: https://tasks.infra.mintel.me/
|
||||
VIKUNJA_SERVICE_ENABLEREGISTRATION: true
|
||||
VIKUNJA_SERVICE_ALLOWICONCHANGES: false
|
||||
VIKUNJA_SERVICE_JWTSECRET: d2FzaHViZXJzZWN1cmVqd3RzZWNyZXQyMDI2IQ==
|
||||
volumes:
|
||||
- ./data/files:/app/vikunja/files
|
||||
- ./config.yml:/etc/vikunja/config.yml:ro
|
||||
depends_on:
|
||||
- db
|
||||
restart: always
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.vikunja.rule=Host(`tasks.infra.mintel.me`)"
|
||||
- "traefik.http.routers.vikunja.entrypoints=websecure"
|
||||
- "traefik.http.routers.vikunja.tls.certresolver=le"
|
||||
- "traefik.http.services.vikunja.loadbalancer.server.port=3456"
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
|
||||
networks:
|
||||
infra:
|
||||
external: true
|
||||
@@ -177,12 +177,31 @@ jobs:
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 🔐 Registry Login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.infra.mintel.me
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASS }}
|
||||
- name: 🔐 Discover Valid Registry Token
|
||||
id: discover_token
|
||||
run: |
|
||||
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
|
||||
TOKENS="${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}"
|
||||
USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel"
|
||||
|
||||
for TOKEN in $TOKENS; do
|
||||
if [ -n "$TOKEN" ]; then
|
||||
for U in $USERS; do
|
||||
if [ -n "$U" ]; then
|
||||
echo "Attempting docker login for a token with user $U..."
|
||||
if echo "$TOKEN" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then
|
||||
echo "✅ Successfully authenticated with a token."
|
||||
echo "::add-mask::$TOKEN"
|
||||
echo "token=$TOKEN" >> $GITHUB_OUTPUT
|
||||
echo "user=$U" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
echo "❌ All available tokens failed to authenticate!"
|
||||
exit 1
|
||||
|
||||
- name: 🏗️ Docker Build & Push
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -197,8 +216,8 @@ jobs:
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:${{ needs.prepare.outputs.image_tag }}
|
||||
NPM_TOKEN=${{ steps.discover_token.outputs.token }}
|
||||
tags: git.infra.mintel.me/mmintel/${{ github.event.repository.name }}:${{ needs.prepare.outputs.image_tag }}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 4: Deploy
|
||||
@@ -262,7 +281,7 @@ jobs:
|
||||
set -e
|
||||
cd "/home/deploy/sites/${{ github.event.repository.name }}"
|
||||
chmod 600 "$ENV_FILE"
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
echo "${{ steps.discover_token.outputs.token }}" | docker login git.infra.mintel.me -u "${{ steps.discover_token.outputs.user }}" --password-stdin
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
|
||||
docker system prune -f --filter "until=24h"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/infra",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.17",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -2,12 +2,24 @@
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
REGISTRY_DATA="/mnt/HC_Volume_104575103/registry-data/docker/registry/v2"
|
||||
REGISTRY_DATA="/mnt/HC_Volume_104796416/registry-data/docker/registry/v2"
|
||||
KEEP_TAGS=3
|
||||
|
||||
echo "🏥 Starting Aggressive Mintel Infrastructure Optimization..."
|
||||
|
||||
# 1. Prune Registry Tags (Filesystem level)
|
||||
# 1. Gitea Maintenance
|
||||
echo "🍵 Running Gitea Maintenance..."
|
||||
GITEA_CONTAINER=$(docker ps --format "{{.Names}}" | grep gitea | head -1 || true)
|
||||
if [ -n "$GITEA_CONTAINER" ]; then
|
||||
# Run common Gitea cleanup tasks
|
||||
docker exec -u git "$GITEA_CONTAINER" gitea admin cron run cleanup_old_repository_archives || true
|
||||
docker exec -u git "$GITEA_CONTAINER" gitea admin cron run cleanup_upload_directory || true
|
||||
docker exec -u git "$GITEA_CONTAINER" gitea admin cron run cleanup_packages || true
|
||||
docker exec -u git "$GITEA_CONTAINER" gitea admin cron run garbage_collect_attachment || true
|
||||
docker exec -u git "$GITEA_CONTAINER" gitea admin cron run garbage_collect_lfs || true
|
||||
fi
|
||||
|
||||
# 2. Prune Registry Tags (Filesystem level)
|
||||
if [ -d "$REGISTRY_DATA" ]; then
|
||||
echo "🔍 Processing Registry tags..."
|
||||
for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
|
||||
@@ -44,7 +56,17 @@ fi
|
||||
# 3. Global Docker Pruning
|
||||
echo "🧹 Pruning Docker resources..."
|
||||
docker system prune -af --filter "until=24h"
|
||||
|
||||
echo "🧹 Pruning BuildKit builder containers and cache..."
|
||||
# Remove orphaned buildkit containers to release volume locks
|
||||
docker rm -f $(docker ps -aq --filter name=buildx_buildkit_builder) 2>/dev/null || true
|
||||
docker builder prune -af || true
|
||||
docker buildx prune -af || true
|
||||
|
||||
echo "🧹 Pruning Docker volumes..."
|
||||
docker volume prune -f
|
||||
# Force remove dangling buildx state volumes that might bypass standard prune
|
||||
docker volume ls -q | grep buildx | xargs -r docker volume rm 2>/dev/null || true
|
||||
|
||||
echo "✅ Optimization complete!"
|
||||
df -h /mnt/HC_Volume_104575103
|
||||
df -h /mnt/HC_Volume_104796416
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
REGISTRY_DATA="/mnt/HC_Volume_104575103/registry-data/docker/registry/v2"
|
||||
# Find registry data dynamically (volume ID might change)
|
||||
REGISTRY_DATA=$(find /mnt -maxdepth 5 -type d -path '*/registry-data/docker/registry/v2' | head -n 1)
|
||||
|
||||
if [ -z "$REGISTRY_DATA" ]; then
|
||||
echo "❌ Registry data directory not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
KEEP_TAGS=3
|
||||
|
||||
echo "🏥 Starting Aggressive Registry & Docker Maintenance..."
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/journaling",
|
||||
"version": "1.9.4",
|
||||
"private": true,
|
||||
"version": "1.9.17",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
|
||||
15
packages/klz-payload-mcp/Dockerfile
Normal file
15
packages/klz-payload-mcp/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:20-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:20-bookworm-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
ENTRYPOINT ["node", "dist/start.js"]
|
||||
24
packages/klz-payload-mcp/package.json
Normal file
24
packages/klz-payload-mcp/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@mintel/klz-payload-mcp",
|
||||
"version": "1.9.17",
|
||||
"description": "KLZ PayloadCMS MCP server for technical product data and leads",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/start.js",
|
||||
"dev": "tsx watch src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||
"axios": "^1.7.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.19.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.10",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
653
packages/klz-payload-mcp/src/index.ts
Normal file
653
packages/klz-payload-mcp/src/index.ts
Normal file
@@ -0,0 +1,653 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import express, { Request, Response } from 'express';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import axios from "axios";
|
||||
import crypto from "crypto";
|
||||
import https from "https";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://klz-cables.com";
|
||||
const PAYLOAD_API_KEY = process.env.PAYLOAD_API_KEY;
|
||||
const PAYLOAD_EMAIL = process.env.PAYLOAD_EMAIL || "agent@mintel.me";
|
||||
const PAYLOAD_PASSWORD = process.env.PAYLOAD_PASSWORD || "agentpassword123";
|
||||
|
||||
const httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false, // For internal infra
|
||||
});
|
||||
|
||||
let jwtToken: string | null = null;
|
||||
|
||||
const payloadClient = axios.create({
|
||||
baseURL: `${PAYLOAD_URL}/api`,
|
||||
headers: PAYLOAD_API_KEY ? { Authorization: `users API-Key ${PAYLOAD_API_KEY}` } : {},
|
||||
httpsAgent
|
||||
});
|
||||
|
||||
payloadClient.interceptors.request.use(async (config) => {
|
||||
if (!PAYLOAD_API_KEY && !jwtToken && PAYLOAD_EMAIL && PAYLOAD_PASSWORD) {
|
||||
try {
|
||||
const loginRes = await axios.post(`${PAYLOAD_URL}/api/users/login`, {
|
||||
email: PAYLOAD_EMAIL,
|
||||
password: PAYLOAD_PASSWORD
|
||||
}, { httpsAgent });
|
||||
if (loginRes.data && loginRes.data.token) {
|
||||
jwtToken = loginRes.data.token;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to authenticate with Payload CMS using email/password.");
|
||||
}
|
||||
}
|
||||
|
||||
if (jwtToken && !PAYLOAD_API_KEY) {
|
||||
config.headers.Authorization = `JWT ${jwtToken}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
payloadClient.interceptors.response.use(res => res, async (error) => {
|
||||
const originalRequest = error.config;
|
||||
// If token expired, clear it and retry
|
||||
if (error.response?.status === 401 && !originalRequest._retry && !PAYLOAD_API_KEY) {
|
||||
originalRequest._retry = true;
|
||||
jwtToken = null; // Forces re-authentication on next interceptor run
|
||||
return payloadClient(originalRequest);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
|
||||
const SEARCH_PRODUCTS_TOOL: Tool = {
|
||||
name: "payload_search_products",
|
||||
description: "Search for technical product specifications (cables, cross-sections) in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query or part number" },
|
||||
limit: { type: "number", description: "Maximum number of results" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const GET_PRODUCT_TOOL: Tool = {
|
||||
name: "payload_get_product",
|
||||
description: "Get a specific product by its slug or ID",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
slug: { type: "string", description: "Product slug" },
|
||||
id: { type: "string", description: "Product ID (if slug is not used)" }
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CREATE_PRODUCT_TOOL: Tool = {
|
||||
name: "payload_create_product",
|
||||
description: "Create a new product in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Product title" },
|
||||
slug: { type: "string", description: "Product slug" },
|
||||
data: { type: "object", description: "Additional product data (JSON)", additionalProperties: true }
|
||||
},
|
||||
required: ["title"]
|
||||
},
|
||||
};
|
||||
|
||||
const UPDATE_PRODUCT_TOOL: Tool = {
|
||||
name: "payload_update_product",
|
||||
description: "Update an existing product in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Product ID to update" },
|
||||
data: { type: "object", description: "Product data to update (JSON)", additionalProperties: true }
|
||||
},
|
||||
required: ["id", "data"]
|
||||
},
|
||||
};
|
||||
|
||||
const DELETE_PRODUCT_TOOL: Tool = {
|
||||
name: "payload_delete_product",
|
||||
description: "Delete a product from KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Product ID to delete" }
|
||||
},
|
||||
required: ["id"]
|
||||
},
|
||||
};
|
||||
|
||||
const LIST_LEADS_TOOL: Tool = {
|
||||
name: "payload_list_leads",
|
||||
description: "List recent lead inquiries and contact requests",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
limit: { type: "number", description: "Maximum number of leads" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const GET_LEAD_TOOL: Tool = {
|
||||
name: "payload_get_lead",
|
||||
description: "Get a specific lead by ID",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Lead ID" }
|
||||
},
|
||||
required: ["id"]
|
||||
},
|
||||
};
|
||||
|
||||
const CREATE_LEAD_TOOL: Tool = {
|
||||
name: "payload_create_lead",
|
||||
description: "Create a new lead in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
email: { type: "string", description: "Lead email address" },
|
||||
data: { type: "object", description: "Additional lead data (JSON)", additionalProperties: true }
|
||||
},
|
||||
required: ["email"]
|
||||
},
|
||||
};
|
||||
|
||||
const UPDATE_LEAD_TOOL: Tool = {
|
||||
name: "payload_update_lead",
|
||||
description: "Update an existing lead in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Lead ID to update" },
|
||||
data: { type: "object", description: "Lead data to update (JSON)", additionalProperties: true }
|
||||
},
|
||||
required: ["id", "data"]
|
||||
},
|
||||
};
|
||||
|
||||
const DELETE_LEAD_TOOL: Tool = {
|
||||
name: "payload_delete_lead",
|
||||
description: "Delete a lead from KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Lead ID to delete" }
|
||||
},
|
||||
required: ["id"]
|
||||
},
|
||||
};
|
||||
|
||||
const LIST_PAGES_TOOL: Tool = {
|
||||
name: "payload_list_pages",
|
||||
description: "List pages from KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
limit: { type: "number", description: "Maximum number of pages" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const GET_PAGE_TOOL: Tool = {
|
||||
name: "payload_get_page",
|
||||
description: "Get a specific page by its slug or ID",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
slug: { type: "string", description: "Page slug" },
|
||||
id: { type: "string", description: "Page ID (if slug is not used)" }
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const LIST_POSTS_TOOL: Tool = {
|
||||
name: "payload_list_posts",
|
||||
description: "List posts/articles from KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
limit: { type: "number", description: "Maximum number of posts" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const GET_POST_TOOL: Tool = {
|
||||
name: "payload_get_post",
|
||||
description: "Get a specific post by its slug or ID",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
slug: { type: "string", description: "Post slug" },
|
||||
id: { type: "string", description: "Post ID (if slug is not used)" }
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CREATE_PAGE_TOOL: Tool = {
|
||||
name: "payload_create_page",
|
||||
description: "Create a new page in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Page title" },
|
||||
slug: { type: "string", description: "Page slug" },
|
||||
data: { type: "object", description: "Additional page data (JSON)", additionalProperties: true }
|
||||
},
|
||||
required: ["title"]
|
||||
},
|
||||
};
|
||||
|
||||
const UPDATE_PAGE_TOOL: Tool = {
|
||||
name: "payload_update_page",
|
||||
description: "Update an existing page in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Page ID to update" },
|
||||
data: { type: "object", description: "Page data to update (JSON)", additionalProperties: true }
|
||||
},
|
||||
required: ["id", "data"]
|
||||
},
|
||||
};
|
||||
|
||||
const DELETE_PAGE_TOOL: Tool = {
|
||||
name: "payload_delete_page",
|
||||
description: "Delete a page from KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Page ID to delete" }
|
||||
},
|
||||
required: ["id"]
|
||||
},
|
||||
};
|
||||
|
||||
const CREATE_POST_TOOL: Tool = {
|
||||
name: "payload_create_post",
|
||||
description: "Create a new post in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Post title" },
|
||||
slug: { type: "string", description: "Post slug" },
|
||||
data: { type: "object", description: "Additional post data (JSON)", additionalProperties: true }
|
||||
},
|
||||
required: ["title"]
|
||||
},
|
||||
};
|
||||
|
||||
const UPDATE_POST_TOOL: Tool = {
|
||||
name: "payload_update_post",
|
||||
description: "Update an existing post in KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Post ID to update" },
|
||||
data: { type: "object", description: "Post data to update (JSON)", additionalProperties: true }
|
||||
},
|
||||
required: ["id", "data"]
|
||||
},
|
||||
};
|
||||
|
||||
const DELETE_POST_TOOL: Tool = {
|
||||
name: "payload_delete_post",
|
||||
description: "Delete a post from KLZ Payload CMS",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Post ID to delete" }
|
||||
},
|
||||
required: ["id"]
|
||||
},
|
||||
};
|
||||
|
||||
const server = new Server(
|
||||
{ name: "klz-payload-mcp", version: "1.0.0" },
|
||||
{ capabilities: { tools: {} } }
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
SEARCH_PRODUCTS_TOOL,
|
||||
GET_PRODUCT_TOOL,
|
||||
CREATE_PRODUCT_TOOL,
|
||||
UPDATE_PRODUCT_TOOL,
|
||||
DELETE_PRODUCT_TOOL,
|
||||
LIST_LEADS_TOOL,
|
||||
GET_LEAD_TOOL,
|
||||
CREATE_LEAD_TOOL,
|
||||
UPDATE_LEAD_TOOL,
|
||||
DELETE_LEAD_TOOL,
|
||||
LIST_PAGES_TOOL,
|
||||
GET_PAGE_TOOL,
|
||||
CREATE_PAGE_TOOL,
|
||||
UPDATE_PAGE_TOOL,
|
||||
DELETE_PAGE_TOOL,
|
||||
LIST_POSTS_TOOL,
|
||||
GET_POST_TOOL,
|
||||
CREATE_POST_TOOL,
|
||||
UPDATE_POST_TOOL,
|
||||
DELETE_POST_TOOL
|
||||
],
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (request.params.name === "payload_search_products") {
|
||||
const { query, limit = 10 } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.get('/products', {
|
||||
params: {
|
||||
where: query ? {
|
||||
or: [
|
||||
{ title: { contains: query } },
|
||||
{ slug: { contains: query } },
|
||||
{ description: { contains: query } }
|
||||
]
|
||||
} : {},
|
||||
limit
|
||||
}
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data.docs, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.response?.data?.errors?.[0]?.message || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_get_product") {
|
||||
const { slug, id } = request.params.arguments as any;
|
||||
try {
|
||||
if (id) {
|
||||
const res = await payloadClient.get(`/products/${id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} else if (slug) {
|
||||
const res = await payloadClient.get('/products', { params: { where: { slug: { equals: slug } }, limit: 1 } });
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data.docs[0] || {}, null, 2) }] };
|
||||
}
|
||||
return { isError: true, content: [{ type: "text", text: "Error: must provide slug or id" }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.response?.data?.errors?.[0]?.message || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_create_product") {
|
||||
const { title, slug, data = {} } = request.params.arguments as any;
|
||||
try {
|
||||
const payload = { title, slug, _status: 'draft', ...data };
|
||||
const res = await payloadClient.post('/products', payload);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_update_product") {
|
||||
const { id, data } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.patch(`/products/${id}`, data);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_delete_product") {
|
||||
const { id } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.delete(`/products/${id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_list_leads") {
|
||||
const { limit = 10 } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.get('/leads', {
|
||||
params: { limit, sort: '-createdAt' }
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data.docs, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.response?.data?.errors?.[0]?.message || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_get_lead") {
|
||||
const { id } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.get(`/leads/${id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.response?.data?.errors?.[0]?.message || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_create_lead") {
|
||||
const { email, data = {} } = request.params.arguments as any;
|
||||
try {
|
||||
const payload = { email, ...data };
|
||||
const res = await payloadClient.post('/leads', payload);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_update_lead") {
|
||||
const { id, data } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.patch(`/leads/${id}`, data);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_delete_lead") {
|
||||
const { id } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.delete(`/leads/${id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (request.params.name === "payload_list_pages") {
|
||||
const { limit = 10 } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.get('/pages', { params: { limit } });
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data.docs, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.response?.data?.errors?.[0]?.message || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_get_page") {
|
||||
const { slug, id } = request.params.arguments as any;
|
||||
try {
|
||||
if (id) {
|
||||
const res = await payloadClient.get(`/pages/${id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} else if (slug) {
|
||||
const res = await payloadClient.get('/pages', { params: { where: { slug: { equals: slug } }, limit: 1 } });
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data.docs[0] || {}, null, 2) }] };
|
||||
}
|
||||
return { isError: true, content: [{ type: "text", text: "Error: must provide slug or id" }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.response?.data?.errors?.[0]?.message || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_create_page") {
|
||||
const { title, slug, data = {} } = request.params.arguments as any;
|
||||
try {
|
||||
const payload = { title, slug, _status: 'draft', ...data };
|
||||
const res = await payloadClient.post('/pages', payload);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_update_page") {
|
||||
const { id, data } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.patch(`/pages/${id}`, data);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_delete_page") {
|
||||
const { id } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.delete(`/pages/${id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_list_posts") {
|
||||
const { limit = 10 } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.get('/posts', { params: { limit, sort: '-createdAt' } });
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data.docs, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.response?.data?.errors?.[0]?.message || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_get_post") {
|
||||
const { slug, id } = request.params.arguments as any;
|
||||
try {
|
||||
if (id) {
|
||||
const res = await payloadClient.get(`/posts/${id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} else if (slug) {
|
||||
const res = await payloadClient.get('/posts', { params: { where: { slug: { equals: slug } }, limit: 1 } });
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data.docs[0] || {}, null, 2) }] };
|
||||
}
|
||||
return { isError: true, content: [{ type: "text", text: "Error: must provide slug or id" }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${e.response?.data?.errors?.[0]?.message || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_create_post") {
|
||||
const { title, slug, data = {} } = request.params.arguments as any;
|
||||
try {
|
||||
const payload = { title, slug, _status: 'draft', ...data };
|
||||
const res = await payloadClient.post('/posts', payload);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_update_post") {
|
||||
const { id, data } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.patch(`/posts/${id}`, data);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
if (request.params.name === "payload_delete_post") {
|
||||
const { id } = request.params.arguments as any;
|
||||
try {
|
||||
const res = await payloadClient.delete(`/posts/${id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
} catch (e: any) {
|
||||
return { isError: true, content: [{ type: "text", text: `Error: ${JSON.stringify(e.response?.data) || e.message}` }] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const isStdio = process.argv.includes('--stdio');
|
||||
|
||||
if (isStdio) {
|
||||
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error('KLZ Payload MCP server is running on stdio');
|
||||
} else {
|
||||
const app = express();
|
||||
const transports = new Map<string, SSEServerTransport>();
|
||||
|
||||
// Manual CORS middleware
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version');
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((req, _res, next) => {
|
||||
console.error(`${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/sse', async (req, res) => {
|
||||
const transport = new SSEServerTransport('/message', res as any);
|
||||
const sessionId = transport.sessionId;
|
||||
console.error(`New SSE connection: ${sessionId}`);
|
||||
transports.set(sessionId, transport);
|
||||
|
||||
req.on('close', () => {
|
||||
console.error(`SSE connection closed: ${sessionId}`);
|
||||
transports.delete(sessionId);
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
req.on('close', resolve);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/message', express.json(), async (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const transport = transports.get(sessionId);
|
||||
|
||||
console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`);
|
||||
|
||||
if (!transport) {
|
||||
console.error(`No transport found for session: ${sessionId}`);
|
||||
res.status(400).send('No active SSE connection for this session');
|
||||
return;
|
||||
}
|
||||
await transport.handlePostMessage(req, res);
|
||||
});
|
||||
|
||||
const PORT = Number(process.env.KLZ_PAYLOAD_MCP_PORT) || 3006;
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.error(`KLZ Payload MCP server running on http://0.0.0.0:${PORT}/sse`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
13
packages/klz-payload-mcp/src/start.ts
Normal file
13
packages/klz-payload-mcp/src/start.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
config({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||
config({ quiet: true, path: resolve(__dirname, '../../../.env') });
|
||||
|
||||
import('./index.js').catch(err => {
|
||||
console.error('Failed to start KLZ Payload MCP Server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
16
packages/klz-payload-mcp/tsconfig.json
Normal file
16
packages/klz-payload-mcp/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/mail",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.17",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
||||
@@ -25,3 +25,4 @@ export * from "./templates/ConfirmationMessage";
|
||||
export * from "./templates/FollowUpTemplate";
|
||||
export * from "./templates/ProjectEstimateTemplate";
|
||||
export * from "./templates/SiteAuditTemplate";
|
||||
export * from "./templates/AnalyticsTemplate";
|
||||
|
||||
197
packages/mail/src/layouts/GatekeeperLayout.tsx
Normal file
197
packages/mail/src/layouts/GatekeeperLayout.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Img,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
export interface GatekeeperLayoutProps {
|
||||
preview: string;
|
||||
projectName?: string;
|
||||
subTitle?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const GatekeeperLayout = ({ preview, projectName = "MINTEL", subTitle = "INFRASTRUCTURE", children }: GatekeeperLayoutProps) => {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{preview}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
|
||||
{/* Top Icon Box */}
|
||||
<Section style={iconBoxSection}>
|
||||
<div style={iconBox}>
|
||||
<Img
|
||||
src="https://mintel.me/favicon-32x32.png"
|
||||
width={28}
|
||||
height={28}
|
||||
alt="Mintel"
|
||||
style={iconImg}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Title Area */}
|
||||
<Section style={titleSection}>
|
||||
<Text style={gatekeeperTitle}>
|
||||
{projectName} <span style={{ color: "rgba(0,0,0,0.3)" }}>GATEKEEPER</span>
|
||||
</Text>
|
||||
|
||||
<table align="center" width="100%" border={0} cellPadding={0} cellSpacing={0} role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="30%" align="right"><div style={lineGradientRight} /></td>
|
||||
<td width="40%" align="center">
|
||||
<Text style={gatekeeperSub}>
|
||||
{subTitle}
|
||||
</Text>
|
||||
</td>
|
||||
<td width="30%" align="left"><div style={lineGradientLeft} /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<Section style={contentSection}>
|
||||
{children}
|
||||
</Section>
|
||||
|
||||
{/* Footer Area */}
|
||||
<Section style={footerSection}>
|
||||
<div style={lineGradientCenter} />
|
||||
<div style={{ marginTop: "24px" }}>
|
||||
<Img
|
||||
src="https://mintel.me/favicon-32x32.png"
|
||||
width={24}
|
||||
height={24}
|
||||
alt={projectName}
|
||||
style={{ opacity: 0.3, margin: "0 auto", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
<Text style={footerText}>
|
||||
© {new Date().getFullYear()} MINTEL
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
const main = {
|
||||
backgroundColor: "#f5f5f7",
|
||||
color: "#111111",
|
||||
fontFamily:
|
||||
'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif',
|
||||
WebkitFontSmoothing: "antialiased" as const,
|
||||
};
|
||||
|
||||
const container = {
|
||||
margin: "0 auto",
|
||||
padding: "40px 20px 80px",
|
||||
maxWidth: "420px",
|
||||
};
|
||||
|
||||
const iconBoxSection = {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "40px",
|
||||
};
|
||||
|
||||
const iconBox = {
|
||||
width: "56px",
|
||||
height: "56px",
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: "16px",
|
||||
margin: "0 auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
boxShadow: "0 10px 15px -3px rgba(0,0,0,0.06), 0 4px 6px -2px rgba(0,0,0,0.03)",
|
||||
};
|
||||
|
||||
const iconImg = {
|
||||
margin: "14px auto",
|
||||
display: "block",
|
||||
opacity: 0.8,
|
||||
filter: "grayscale(100%) brightness(0)",
|
||||
};
|
||||
|
||||
const titleSection = {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "32px",
|
||||
};
|
||||
|
||||
const gatekeeperTitle = {
|
||||
fontSize: "11px",
|
||||
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
|
||||
fontWeight: "bold",
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.5em",
|
||||
color: "rgba(0,0,0,0.8)",
|
||||
margin: "0 0 12px 0",
|
||||
};
|
||||
|
||||
const gatekeeperSub = {
|
||||
fontSize: "8px",
|
||||
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.35em",
|
||||
fontWeight: 600,
|
||||
color: "rgba(0,0,0,0.3)",
|
||||
margin: "0",
|
||||
};
|
||||
|
||||
const lineGradientRight = {
|
||||
height: "1px",
|
||||
width: "32px",
|
||||
backgroundColor: "rgba(0,0,0,0.1)",
|
||||
marginLeft: "auto",
|
||||
};
|
||||
|
||||
const lineGradientLeft = {
|
||||
height: "1px",
|
||||
width: "32px",
|
||||
backgroundColor: "rgba(0,0,0,0.1)",
|
||||
marginRight: "auto",
|
||||
};
|
||||
|
||||
const contentSection = {
|
||||
backgroundColor: "#ffffff",
|
||||
padding: "32px",
|
||||
borderRadius: "24px",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
boxShadow: "0 4px 6px -1px rgba(0,0,0,0.03)",
|
||||
marginBottom: "32px",
|
||||
};
|
||||
|
||||
const footerSection = {
|
||||
textAlign: "center" as const,
|
||||
paddingTop: "24px",
|
||||
};
|
||||
|
||||
const lineGradientCenter = {
|
||||
height: "1px",
|
||||
width: "64px",
|
||||
backgroundColor: "rgba(0,0,0,0.1)",
|
||||
margin: "0 auto",
|
||||
};
|
||||
|
||||
const footerText = {
|
||||
fontSize: "7px",
|
||||
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
|
||||
fontWeight: 600,
|
||||
color: "rgba(0,0,0,0.25)",
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.5em",
|
||||
marginTop: "20px",
|
||||
};
|
||||
41
packages/mail/src/templates/AnalyticsTemplate.test.tsx
Normal file
41
packages/mail/src/templates/AnalyticsTemplate.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { render } from "@react-email/components";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { AnalyticsTemplate, PeriodData } from "./AnalyticsTemplate";
|
||||
import * as React from "react";
|
||||
|
||||
describe("AnalyticsTemplate", () => {
|
||||
it("renders entry and exit pages correctly", async () => {
|
||||
const period: PeriodData = {
|
||||
label: "Test Period",
|
||||
visitors: 100,
|
||||
visits: 120,
|
||||
views: 500,
|
||||
bounceRate: 50,
|
||||
visitDuration: "2m",
|
||||
topPages: [{ url: "/home", views: 50 }],
|
||||
topReferrers: [{ url: "google.com", visitors: 30 }],
|
||||
// These fields will cause a TS error because they don't exist yet,
|
||||
// but we force it to see it fail.
|
||||
topEntryPages: [{ url: "/entry", visitors: 40 }],
|
||||
topExitPages: [{ url: "/exit", visitors: 20 }],
|
||||
chartData: [],
|
||||
} as any; // Cast as any to bypass TS compilation before we update the type
|
||||
|
||||
const html = await render(
|
||||
<AnalyticsTemplate
|
||||
greeting="Hallo Kunde,"
|
||||
domain="example.com"
|
||||
periods={[period]}
|
||||
/>
|
||||
);
|
||||
|
||||
// Assert that the new sections for entry and exit pages exist.
|
||||
expect(html).toContain("Häufigste Einstiege");
|
||||
expect(html).toContain("Häufigste Ausstiege");
|
||||
expect(html).toContain("/entry");
|
||||
expect(html).toContain("/exit");
|
||||
|
||||
// Assert that the badges have updated line heights
|
||||
expect(html).toContain('line-height:1.5');
|
||||
});
|
||||
});
|
||||
378
packages/mail/src/templates/AnalyticsTemplate.tsx
Normal file
378
packages/mail/src/templates/AnalyticsTemplate.tsx
Normal file
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/meme-generator",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.17",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
18
packages/memory-mcp/Dockerfile
Normal file
18
packages/memory-mcp/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:20-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace
|
||||
RUN for dir in $(find /app/node_modules -type d -name "sharp" | grep "node_modules/sharp$"); do \
|
||||
echo "module.exports = {};" > "$dir/lib/index.js" || true; \
|
||||
done
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:20-bookworm-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
ENTRYPOINT ["node", "dist/start.js"]
|
||||
29
packages/memory-mcp/package.json
Normal file
29
packages/memory-mcp/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@mintel/memory-mcp",
|
||||
"version": "1.9.17",
|
||||
"description": "Local Qdrant-based Memory MCP server",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/start.js",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||
"@qdrant/js-client-rest": "^1.12.0",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"onnxruntime-node": "^1.14.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.19.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.10",
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vitest": "^2.1.3"
|
||||
}
|
||||
}
|
||||
153
packages/memory-mcp/src/index.ts
Normal file
153
packages/memory-mcp/src/index.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import express from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { z } from 'zod';
|
||||
import { QdrantMemoryService } from './qdrant.js';
|
||||
|
||||
async function main() {
|
||||
const server = new McpServer({
|
||||
name: '@mintel/memory-mcp',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const qdrantService = new QdrantMemoryService(process.env.QDRANT_URL || 'http://localhost:6333');
|
||||
|
||||
server.tool(
|
||||
'store_memory',
|
||||
'Store a new piece of knowledge/memory into the vector database. Use this to remember architectural decisions, preferences, aliases, etc.',
|
||||
{
|
||||
label: z.string().describe('A short, descriptive label or title for the memory (e.g., "Architektur-Entscheidungen")'),
|
||||
content: z.string().describe('The actual content to remember (e.g., "In diesem Projekt nutzen wir lieber Composition over Inheritance.")'),
|
||||
},
|
||||
async (args) => {
|
||||
const success = await qdrantService.storeMemory(args.label, args.content);
|
||||
if (success) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Successfully stored memory: [${args.label}]` }],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Failed to store memory: [${args.label}]` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'retrieve_memory',
|
||||
'Retrieve relevant memories from the vector database based on a semantic search query.',
|
||||
{
|
||||
query: z.string().describe('The search query to find relevant memories.'),
|
||||
limit: z.number().optional().describe('Maximum number of results to return (default: 5)'),
|
||||
},
|
||||
async (args) => {
|
||||
const results = await qdrantService.retrieveMemory(args.query, args.limit || 5);
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'No relevant memories found.' }],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedResults = results
|
||||
.map(r => `- [${r.label}] (Score: ${r.score.toFixed(3)}): ${r.content}`)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Found ${results.length} memories:\n\n${formattedResults}` }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const isStdio = process.argv.includes('--stdio');
|
||||
|
||||
if (isStdio) {
|
||||
// Connect Stdio FIRST to avoid handshake timeouts while loading model
|
||||
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error('Memory MCP server is running on stdio');
|
||||
|
||||
// Initialize dependency after connection
|
||||
try {
|
||||
await qdrantService.initialize();
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize local dependencies:', e);
|
||||
}
|
||||
} else {
|
||||
const app = express();
|
||||
const transports = new Map<string, SSEServerTransport>();
|
||||
|
||||
// Manual CORS middleware
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version');
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((req, _res, next) => {
|
||||
console.error(`${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/sse', async (req, res) => {
|
||||
const transport = new SSEServerTransport('/message', res as any);
|
||||
const sessionId = transport.sessionId;
|
||||
console.error(`New SSE connection: ${sessionId}`);
|
||||
transports.set(sessionId, transport);
|
||||
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
res.write(": heartbeat\n\n");
|
||||
}, 15000);
|
||||
|
||||
req.on('close', () => {
|
||||
console.error(`SSE connection closed: ${sessionId}`);
|
||||
clearInterval(heartbeatInterval);
|
||||
transports.delete(sessionId);
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
req.on('close', resolve);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/message', express.json(), async (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const transport = transports.get(sessionId);
|
||||
|
||||
console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`);
|
||||
|
||||
if (!transport) {
|
||||
console.error(`No transport found for session: ${sessionId}`);
|
||||
res.status(400).send('No active SSE connection for this session');
|
||||
return;
|
||||
}
|
||||
await transport.handlePostMessage(req, res);
|
||||
});
|
||||
|
||||
const PORT = Number(process.env.MEMORY_MCP_PORT) || 3002;
|
||||
app.listen(PORT, '0.0.0.0', async () => {
|
||||
console.error(`Memory MCP server running on http://0.0.0.0:${PORT}/sse`);
|
||||
// Initialize dependencies in SSE mode on startup
|
||||
try {
|
||||
await qdrantService.initialize();
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize local dependencies:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
89
packages/memory-mcp/src/qdrant.test.ts
Normal file
89
packages/memory-mcp/src/qdrant.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { QdrantMemoryService } from './qdrant.js';
|
||||
|
||||
vi.mock('@xenova/transformers', () => {
|
||||
return {
|
||||
env: { allowRemoteModels: false, localModelPath: './models' },
|
||||
pipeline: vi.fn().mockResolvedValue(async (text: string) => {
|
||||
// Mock embedding generation: returns an array of 384 numbers
|
||||
return { data: new Float32Array(384).fill(0.1) };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mockCreateCollection = vi.fn();
|
||||
const mockGetCollections = vi.fn().mockResolvedValue({ collections: [] });
|
||||
const mockUpsert = vi.fn();
|
||||
const mockSearch = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'test-id',
|
||||
version: 1,
|
||||
score: 0.9,
|
||||
payload: { label: 'Test Label', content: 'Test Content' }
|
||||
}
|
||||
]);
|
||||
|
||||
vi.mock('@qdrant/js-client-rest', () => {
|
||||
return {
|
||||
QdrantClient: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
getCollections: mockGetCollections,
|
||||
createCollection: mockCreateCollection,
|
||||
upsert: mockUpsert,
|
||||
search: mockSearch
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('QdrantMemoryService', () => {
|
||||
let service: QdrantMemoryService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new QdrantMemoryService('http://localhost:6333');
|
||||
});
|
||||
|
||||
it('should initialize and create collection if missing', async () => {
|
||||
mockGetCollections.mockResolvedValueOnce({ collections: [] });
|
||||
await service.initialize();
|
||||
|
||||
expect(mockGetCollections).toHaveBeenCalled();
|
||||
expect(mockCreateCollection).toHaveBeenCalledWith('mcp_memory', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should not create collection if it already exists', async () => {
|
||||
mockGetCollections.mockResolvedValueOnce({ collections: [{ name: 'mcp_memory' }] });
|
||||
await service.initialize();
|
||||
|
||||
expect(mockCreateCollection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should store memory', async () => {
|
||||
await service.initialize();
|
||||
const result = await service.storeMemory('Design', 'Composition over Inheritance');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockUpsert).toHaveBeenCalledWith('mcp_memory', expect.objectContaining({
|
||||
wait: true,
|
||||
points: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
label: 'Design',
|
||||
content: 'Composition over Inheritance'
|
||||
})
|
||||
})
|
||||
])
|
||||
}));
|
||||
});
|
||||
|
||||
it('should retrieve memory', async () => {
|
||||
await service.initialize();
|
||||
const results = await service.retrieveMemory('Design');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].label).toBe('Test Label');
|
||||
expect(results[0].content).toBe('Test Content');
|
||||
expect(results[0].score).toBe(0.9);
|
||||
});
|
||||
});
|
||||
110
packages/memory-mcp/src/qdrant.ts
Normal file
110
packages/memory-mcp/src/qdrant.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { pipeline, env } from '@xenova/transformers';
|
||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
||||
|
||||
// Be sure to set local caching options for transformers
|
||||
env.allowRemoteModels = true;
|
||||
env.localModelPath = './models';
|
||||
|
||||
export class QdrantMemoryService {
|
||||
private client: QdrantClient;
|
||||
private collectionName = 'mcp_memory';
|
||||
private embedder: any = null;
|
||||
|
||||
constructor(url: string = 'http://localhost:6333') {
|
||||
this.client = new QdrantClient({ url });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the embedding model and the Qdrant collection
|
||||
*/
|
||||
async initialize() {
|
||||
// 1. Load the embedding model (using a lightweight model suitable for semantic search)
|
||||
console.error('Loading embedding model...');
|
||||
this.embedder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
|
||||
|
||||
// 2. Ensure collection exists
|
||||
console.error(`Checking for collection: ${this.collectionName}`);
|
||||
try {
|
||||
const collections = await this.client.getCollections();
|
||||
const exists = collections.collections.some(c => c.name === this.collectionName);
|
||||
|
||||
if (!exists) {
|
||||
console.error(`Creating collection: ${this.collectionName}`);
|
||||
await this.client.createCollection(this.collectionName, {
|
||||
vectors: {
|
||||
size: 384, // size for all-MiniLM-L6-v2
|
||||
distance: 'Cosine'
|
||||
}
|
||||
});
|
||||
console.error('Collection created successfully.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize Qdrant collection:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a vector embedding for the given text
|
||||
*/
|
||||
private async getEmbedding(text: string): Promise<number[]> {
|
||||
if (!this.embedder) {
|
||||
throw new Error('Embedder not initialized. Call initialize() first.');
|
||||
}
|
||||
const output = await this.embedder(text, { pooling: 'mean', normalize: true });
|
||||
return Array.from(output.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a memory entry into Qdrant
|
||||
*/
|
||||
async storeMemory(label: string, content: string): Promise<boolean> {
|
||||
try {
|
||||
const fullText = `${label}: ${content}`;
|
||||
const vector = await this.getEmbedding(fullText);
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
await this.client.upsert(this.collectionName, {
|
||||
wait: true,
|
||||
points: [
|
||||
{
|
||||
id,
|
||||
vector,
|
||||
payload: {
|
||||
label,
|
||||
content,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to store memory:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves memory entries relevant to the query
|
||||
*/
|
||||
async retrieveMemory(query: string, limit: number = 5): Promise<Array<{ label: string, content: string, score: number }>> {
|
||||
try {
|
||||
const vector = await this.getEmbedding(query);
|
||||
const searchResults = await this.client.search(this.collectionName, {
|
||||
vector,
|
||||
limit,
|
||||
with_payload: true
|
||||
});
|
||||
|
||||
return searchResults.map(result => ({
|
||||
label: String(result.payload?.label || ''),
|
||||
content: String(result.payload?.content || ''),
|
||||
score: result.score
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to retrieve memory:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
16
packages/memory-mcp/src/start.ts
Normal file
16
packages/memory-mcp/src/start.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
// Try to load .env.local first (contains credentials usually)
|
||||
config({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||
// Fallback to .env (contains defaults)
|
||||
config({ quiet: true, path: resolve(__dirname, '../../../.env') });
|
||||
|
||||
// Now boot the compiled MCP index
|
||||
import('./index.js').catch(err => {
|
||||
console.error('Failed to start MCP Server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
16
packages/memory-mcp/tsconfig.json
Normal file
16
packages/memory-mcp/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
@@ -53,6 +53,31 @@ const withMintelConfig = (config) => {
|
||||
|
||||
let nextConfig = { ...baseNextConfig, ...config };
|
||||
|
||||
// Explicitly merge rewrites
|
||||
nextConfig.rewrites = async () => {
|
||||
const defaultRewrites = await baseNextConfig.rewrites();
|
||||
let customRewrites = {};
|
||||
if (typeof config.rewrites === 'function') {
|
||||
customRewrites = await config.rewrites();
|
||||
} else if (Array.isArray(config.rewrites)) {
|
||||
customRewrites = config.rewrites;
|
||||
}
|
||||
|
||||
if (Array.isArray(customRewrites)) {
|
||||
return {
|
||||
beforeFiles: [...customRewrites, ...defaultRewrites],
|
||||
afterFiles: [],
|
||||
fallback: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
beforeFiles: [...(customRewrites.beforeFiles || []), ...defaultRewrites],
|
||||
afterFiles: customRewrites.afterFiles || [],
|
||||
fallback: customRewrites.fallback || [],
|
||||
};
|
||||
};
|
||||
|
||||
if (hasI18nConfig) {
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
nextConfig = withNextIntl(nextConfig);
|
||||
@@ -71,3 +96,4 @@ const withMintelConfig = (config) => {
|
||||
};
|
||||
|
||||
export default withMintelConfig;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-config",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.17",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-feedback",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.17",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-observability",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.17",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-utils",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.17",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/observability",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.17",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
|
||||
15
packages/outline-mcp/Dockerfile
Normal file
15
packages/outline-mcp/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:20-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install -g pnpm@10.18.3 && pnpm install --ignore-workspace
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:20-bookworm-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
ENTRYPOINT ["node", "dist/start.js"]
|
||||
26
packages/outline-mcp/package.json
Normal file
26
packages/outline-mcp/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@mintel/outline-mcp",
|
||||
"version": "1.0.0",
|
||||
"description": "Outline MCP server for Mintel infrastructure",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/start.js",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.5.0",
|
||||
"axios": "^1.7.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.19.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.10",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.5.3",
|
||||
"vitest": "^2.1.3"
|
||||
}
|
||||
}
|
||||
91
packages/outline-mcp/src/index.test.ts
Normal file
91
packages/outline-mcp/src/index.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
|
||||
// Mock environment
|
||||
process.env.OUTLINE_API_TOKEN = 'test-token';
|
||||
process.env.OUTLINE_BASE_URL = 'https://outline.test';
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios', () => {
|
||||
const mockPost = vi.fn();
|
||||
return {
|
||||
default: {
|
||||
create: vi.fn(() => ({
|
||||
post: mockPost
|
||||
}))
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
describe('Outline MCP Server Tools', () => {
|
||||
let mockPost: any;
|
||||
let requestHandler: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Extract the mock
|
||||
const axiosInstance = axios.create();
|
||||
mockPost = axiosInstance.post;
|
||||
|
||||
// Mock Server setRequestHandler
|
||||
let capturedHandler: any;
|
||||
vi.spyOn(Server.prototype, 'setRequestHandler').mockImplementation((schema: any, handler: any) => {
|
||||
// The first call is usually ListToolsRequestSchema, second is CallToolRequestSchema
|
||||
// We just store the last one which is CallToolRequestSchema
|
||||
capturedHandler = handler;
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
// Re-import to trigger registration
|
||||
vi.resetModules();
|
||||
await import('./index.js');
|
||||
|
||||
requestHandler = capturedHandler;
|
||||
});
|
||||
|
||||
it('should search documents', async () => {
|
||||
mockPost.mockResolvedValueOnce({ data: { data: [{ id: '1', title: 'Test' }] } });
|
||||
|
||||
const result = await requestHandler({
|
||||
params: {
|
||||
name: 'outline_search_documents',
|
||||
arguments: { query: 'test' }
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/api/documents.search', { query: 'test', collectionId: undefined });
|
||||
expect(result.content[0].text).toContain('Test');
|
||||
});
|
||||
|
||||
it('should get a document', async () => {
|
||||
mockPost.mockResolvedValueOnce({ data: { data: { id: 'doc1', title: 'Doc 1' } } });
|
||||
|
||||
const result = await requestHandler({
|
||||
params: {
|
||||
name: 'outline_get_document',
|
||||
arguments: { id: 'doc1' }
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/api/documents.info', { id: 'doc1' });
|
||||
expect(result.content[0].text).toContain('Doc 1');
|
||||
});
|
||||
|
||||
it('should create a document', async () => {
|
||||
mockPost.mockResolvedValueOnce({ data: { data: { id: 'new-doc' } } });
|
||||
|
||||
const result = await requestHandler({
|
||||
params: {
|
||||
name: 'outline_create_document',
|
||||
arguments: { collectionId: 'col1', title: 'New Doc', text: 'Hello', publish: true }
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/api/documents.create', {
|
||||
collectionId: 'col1', title: 'New Doc', text: 'Hello', publish: true
|
||||
});
|
||||
expect(result.content[0].text).toContain('new-doc');
|
||||
});
|
||||
});
|
||||
184
packages/outline-mcp/src/index.ts
Normal file
184
packages/outline-mcp/src/index.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import axios from "axios";
|
||||
|
||||
// Environment variables
|
||||
const OUTLINE_API_TOKEN = process.env.OUTLINE_API_TOKEN;
|
||||
const OUTLINE_BASE_URL = process.env.OUTLINE_BASE_URL;
|
||||
|
||||
if (!OUTLINE_API_TOKEN || !OUTLINE_BASE_URL) {
|
||||
console.error("Missing required environment variables: OUTLINE_API_TOKEN or OUTLINE_BASE_URL");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: OUTLINE_BASE_URL,
|
||||
headers: {
|
||||
Authorization: `Bearer ${OUTLINE_API_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Define tools
|
||||
const tools: Tool[] = [
|
||||
{
|
||||
name: "outline_search_documents",
|
||||
description: "Search for documents in Outline.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query" },
|
||||
collectionId: { type: "string", description: "Optional collection ID to scope search" },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "outline_get_document",
|
||||
description: "Get a document by its ID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "The ID of the document" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "outline_create_document",
|
||||
description: "Create a new document.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
collectionId: { type: "string", description: "The ID of the collection" },
|
||||
title: { type: "string", description: "Document title" },
|
||||
text: { type: "string", description: "Document content in Markdown" },
|
||||
publish: { type: "boolean", description: "Whether to publish the document" },
|
||||
},
|
||||
required: ["collectionId", "title", "text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "outline_update_document",
|
||||
description: "Update an existing document.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "The ID of the document" },
|
||||
title: { type: "string", description: "New title" },
|
||||
text: { type: "string", description: "New content in Markdown" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "outline_create_collection",
|
||||
description: "Create a new collection.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Collection name" },
|
||||
description: { type: "string", description: "Collection description" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: "at-mintel-outline-mcp",
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools };
|
||||
});
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
try {
|
||||
switch (request.params.name) {
|
||||
case "outline_search_documents": {
|
||||
const { query, collectionId } = request.params.arguments as any;
|
||||
const res = await axiosInstance.post("/api/documents.search", {
|
||||
query,
|
||||
collectionId,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
||||
};
|
||||
}
|
||||
case "outline_get_document": {
|
||||
const { id } = request.params.arguments as any;
|
||||
const res = await axiosInstance.post("/api/documents.info", { id });
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
||||
};
|
||||
}
|
||||
case "outline_create_document": {
|
||||
const { collectionId, title, text, publish } = request.params.arguments as any;
|
||||
const res = await axiosInstance.post("/api/documents.create", {
|
||||
collectionId,
|
||||
title,
|
||||
text,
|
||||
publish: publish ?? true,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
||||
};
|
||||
}
|
||||
case "outline_update_document": {
|
||||
const { id, title, text } = request.params.arguments as any;
|
||||
const res = await axiosInstance.post("/api/documents.update", {
|
||||
id,
|
||||
title,
|
||||
text,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
||||
};
|
||||
}
|
||||
case "outline_create_collection": {
|
||||
const { name, description } = request.params.arguments as any;
|
||||
const res = await axiosInstance.post("/api/collections.create", {
|
||||
name,
|
||||
description,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data ? JSON.stringify(error.response.data) : error.message;
|
||||
return {
|
||||
content: [{ type: "text", text: `API Error: ${errorMsg}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("Outline MCP Server running on stdio");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
13
packages/outline-mcp/src/start.ts
Normal file
13
packages/outline-mcp/src/start.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
config({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
|
||||
config({ quiet: true, path: resolve(__dirname, '../../../.env') });
|
||||
|
||||
import('./index.js').catch(err => {
|
||||
console.error('Failed to start Outline MCP Server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
16
packages/outline-mcp/tsconfig.json
Normal file
16
packages/outline-mcp/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/page-audit",
|
||||
"version": "1.9.4",
|
||||
"private": true,
|
||||
"version": "1.9.17",
|
||||
"description": "AI-powered website IST-analysis using DataForSEO and Gemini",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
2
packages/payload-ai/.npmrc
Normal file
2
packages/payload-ai/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
|
||||
7
packages/payload-ai/CHANGELOG.md
Normal file
7
packages/payload-ai/CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# @mintel/payload-ai
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Initial release of the @mintel/payload-ai package
|
||||
52
packages/payload-ai/package.json
Normal file
52
packages/payload-ai/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@mintel/payload-ai",
|
||||
"version": "1.9.18",
|
||||
"description": "Reusable Payload CMS AI Extensions",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./components/FieldGenerators/*": "./dist/components/FieldGenerators/*.js",
|
||||
"./components/*": "./dist/components/*.js",
|
||||
"./actions/*": "./dist/actions/*.js",
|
||||
"./globals/*": "./dist/globals/*.js",
|
||||
"./endpoints/*": "./dist/endpoints/*.js",
|
||||
"./utils/*": "./dist/utils/*.js",
|
||||
"./tools/*": "./dist/tools/*.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@payloadcms/next": ">=3.0.0",
|
||||
"@payloadcms/ui": ">=3.0.0",
|
||||
"payload": ">=3.0.0",
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^3.0.39",
|
||||
"@ai-sdk/react": "^3.0.110",
|
||||
"@mintel/content-engine": "workspace:*",
|
||||
"@mintel/thumbnail-generator": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@qdrant/js-client-rest": "^1.17.0",
|
||||
"ai": "^6.0.108",
|
||||
"replicate": "^1.4.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/next": "3.77.0",
|
||||
"@payloadcms/ui": "3.77.0",
|
||||
"@types/node": "^20.17.17",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"next": "^15.1.0",
|
||||
"payload": "3.77.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
98
packages/payload-ai/src/chatPlugin.ts
Normal file
98
packages/payload-ai/src/chatPlugin.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Plugin } from "payload";
|
||||
import { AIChatPermissionsCollection } from "./collections/AIChatPermissions.js";
|
||||
import type { PayloadChatPluginConfig } from "./types.js";
|
||||
import { optimizePostEndpoint } from "./endpoints/optimizeEndpoint.js";
|
||||
import {
|
||||
generateSlugEndpoint,
|
||||
generateThumbnailEndpoint,
|
||||
generateSingleFieldEndpoint,
|
||||
} from "./endpoints/generateEndpoints.js";
|
||||
|
||||
export const payloadChatPlugin =
|
||||
(pluginOptions: PayloadChatPluginConfig): Plugin =>
|
||||
(incomingConfig) => {
|
||||
const config = { ...incomingConfig };
|
||||
|
||||
// If disabled, return config untouched
|
||||
if (pluginOptions.enabled === false) {
|
||||
return config;
|
||||
}
|
||||
|
||||
// 1. Inject the Permissions Collection into the Schema
|
||||
const existingCollections = config.collections || [];
|
||||
|
||||
const mcpServers = pluginOptions.mcpServers || [];
|
||||
|
||||
// Dynamically populate the select options for Collections and MCP Servers
|
||||
const permissionCollection = { ...AIChatPermissionsCollection };
|
||||
const collectionField = permissionCollection.fields.find(
|
||||
(f) => "name" in f && f.name === "allowedCollections",
|
||||
) as any;
|
||||
if (collectionField) {
|
||||
collectionField.options = existingCollections.map((c) => ({
|
||||
label: c.labels?.singular || c.slug,
|
||||
value: c.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
const mcpField = permissionCollection.fields.find(
|
||||
(f) => "name" in f && f.name === "allowedMcpServers",
|
||||
) as any;
|
||||
if (mcpField) {
|
||||
mcpField.options = mcpServers.map((s) => ({
|
||||
label: s.name,
|
||||
value: s.name,
|
||||
}));
|
||||
}
|
||||
|
||||
config.collections = [...existingCollections, permissionCollection];
|
||||
|
||||
// 2. Register Custom API Endpoint for the AI Chat
|
||||
config.endpoints = [
|
||||
...(config.endpoints || []),
|
||||
{
|
||||
path: "/api/mcp-chat",
|
||||
method: "post",
|
||||
handler: async (_req) => {
|
||||
// Fallback simple handler while developing endpoint logic
|
||||
return Response.json({ message: "Chat endpoint active" });
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/api/mintel-ai/optimize",
|
||||
method: "post",
|
||||
handler: optimizePostEndpoint,
|
||||
},
|
||||
{
|
||||
path: "/api/mintel-ai/generate-slug",
|
||||
method: "post",
|
||||
handler: generateSlugEndpoint,
|
||||
},
|
||||
{
|
||||
path: "/api/mintel-ai/generate-thumbnail",
|
||||
method: "post",
|
||||
handler: generateThumbnailEndpoint,
|
||||
},
|
||||
{
|
||||
path: "/api/mintel-ai/generate-single-field",
|
||||
method: "post",
|
||||
handler: generateSingleFieldEndpoint,
|
||||
},
|
||||
];
|
||||
|
||||
// 3. Inject Chat React Component into Admin UI
|
||||
if (pluginOptions.renderChatBubble !== false) {
|
||||
config.admin = {
|
||||
...(config.admin || {}),
|
||||
components: {
|
||||
...(config.admin?.components || {}),
|
||||
providers: [
|
||||
...(config.admin?.components?.providers || []),
|
||||
"@mintel/payload-ai/components/ChatWindow/index#ChatWindowProvider",
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
69
packages/payload-ai/src/collections/AIChatPermissions.ts
Normal file
69
packages/payload-ai/src/collections/AIChatPermissions.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
/**
|
||||
* A central collection to manage which AI Tools/MCPs a User or Role is allowed to use.
|
||||
*/
|
||||
export const AIChatPermissionsCollection: CollectionConfig = {
|
||||
slug: 'ai-chat-permissions',
|
||||
labels: {
|
||||
singular: 'AI Chat Permission',
|
||||
plural: 'AI Chat Permissions',
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'description',
|
||||
group: 'AI & Tools',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'E.g. "Editors default AI permissions"',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'targetUser',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
hasMany: false,
|
||||
admin: {
|
||||
description: 'Apply these permissions to a specific user (optional).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'targetRole',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Admin', value: 'admin' },
|
||||
{ label: 'Editor', value: 'editor' },
|
||||
], // Ideally this is dynamically populated in a real scenario, but we hardcode standard roles for now
|
||||
admin: {
|
||||
description: 'Apply these permissions to all users with this role.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'allowedCollections',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: [], // Will be populated dynamically in the plugin init based on actual collections
|
||||
admin: {
|
||||
description: 'Which Payload collections is the AI allowed to read/write on behalf of this user?',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'allowedMcpServers',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: [], // Will be populated dynamically based on plugin config
|
||||
admin: {
|
||||
description: 'Which external MCP Servers is the AI allowed to execute tools from?',
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
163
packages/payload-ai/src/components/AiMediaButtons.tsx
Normal file
163
packages/payload-ai/src/components/AiMediaButtons.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useDocumentInfo, toast } from "@payloadcms/ui";
|
||||
|
||||
type Action = "upscale" | "recover";
|
||||
|
||||
interface ActionState {
|
||||
loading: boolean;
|
||||
resultId?: string | number;
|
||||
}
|
||||
|
||||
export const AiMediaButtons: React.FC = () => {
|
||||
const { id } = useDocumentInfo();
|
||||
|
||||
const [upscale, setUpscale] = useState<ActionState>({ loading: false });
|
||||
const [recover, setRecover] = useState<ActionState>({ loading: false });
|
||||
|
||||
if (!id) return null; // Only show on existing documents
|
||||
|
||||
const runAction = async (action: Action) => {
|
||||
const setter = action === "upscale" ? setUpscale : setRecover;
|
||||
setter({ loading: true });
|
||||
|
||||
const label = action === "upscale" ? "AI Upscale" : "AI Recover";
|
||||
|
||||
toast.info(
|
||||
`${label} started – this can take 30–90 seconds, please wait…`,
|
||||
);
|
||||
|
||||
try {
|
||||
// The API path is hardcoded here and assuming that's where the host app registers the endpoint.
|
||||
const response = await fetch(`/api/media/${id}/ai-process`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || `${label} failed`);
|
||||
}
|
||||
|
||||
setter({ loading: false, resultId: result.mediaId });
|
||||
|
||||
toast.success(
|
||||
`✅ ${label} erfolgreich! Neues Bild (ID: ${result.mediaId}) wurde gespeichert.`,
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error(`[AiMediaButtons] ${action} error:`, err);
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : `${label} fehlgeschlagen`,
|
||||
);
|
||||
setter({ loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
background: "var(--theme-elevation-150)",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
color: "var(--theme-text)",
|
||||
padding: "8px 14px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "13px",
|
||||
fontWeight: 500,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
transition: "opacity 0.15s ease",
|
||||
};
|
||||
|
||||
const disabledStyle: React.CSSProperties = {
|
||||
opacity: 0.55,
|
||||
cursor: "not-allowed",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "10px",
|
||||
marginBottom: "1.5rem",
|
||||
marginTop: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{/* AI Upscale */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={upscale.loading || recover.loading}
|
||||
onClick={() => runAction("upscale")}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
...(upscale.loading || recover.loading ? disabledStyle : { cursor: "pointer" }),
|
||||
}}
|
||||
>
|
||||
{upscale.loading ? "⏳ AI Upscale läuft…" : "✨ AI Upscale"}
|
||||
</button>
|
||||
{upscale.resultId && (
|
||||
<a
|
||||
href={`/admin/collections/media/${upscale.resultId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--theme-elevation-500)",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
→ Neues Bild öffnen (ID: {upscale.resultId})
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Recover */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={upscale.loading || recover.loading}
|
||||
onClick={() => runAction("recover")}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
...(upscale.loading || recover.loading ? disabledStyle : { cursor: "pointer" }),
|
||||
}}
|
||||
>
|
||||
{recover.loading ? "⏳ AI Recover läuft…" : "🔄 AI Recover"}
|
||||
</button>
|
||||
{recover.resultId && (
|
||||
<a
|
||||
href={`/admin/collections/media/${recover.resultId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--theme-elevation-500)",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
→ Neues Bild öffnen (ID: {recover.resultId})
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
width: "100%",
|
||||
fontSize: "0.8rem",
|
||||
color: "var(--theme-elevation-500)",
|
||||
margin: 0,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
<strong>AI Upscale</strong> verbessert die Auflösung via{" "}
|
||||
<code>google/upscaler</code>. <strong>AI Recover</strong> restauriert
|
||||
alte/beschädigte Fotos via{" "}
|
||||
<code>microsoft/bringing-old-photos-back-to-life</code>. Das
|
||||
Ergebnis wird als neues Medium gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
156
packages/payload-ai/src/components/ChatWindow/index.tsx
Normal file
156
packages/payload-ai/src/components/ChatWindow/index.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
|
||||
export const ChatWindowProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<ChatWindow />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatWindow: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [pageContext, setPageContext] = useState<any>({ url: "" });
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const path = window.location.pathname;
|
||||
let collectionSlug = null;
|
||||
let id = null;
|
||||
// Payload admin URLs are usually /admin/collections/:slug/:id
|
||||
const match = path.match(/\/collections\/([^/]+)(?:\/([^/]+))?/);
|
||||
if (match) {
|
||||
collectionSlug = match[1];
|
||||
if (match[2] && match[2] !== "create") {
|
||||
id = match[2];
|
||||
}
|
||||
}
|
||||
|
||||
setPageContext({
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
collectionSlug,
|
||||
id,
|
||||
});
|
||||
}
|
||||
}, [isOpen]); // Refresh context when chat is opened
|
||||
|
||||
const { messages, input, handleInputChange, handleSubmit } = useChat({
|
||||
api: "/api/mcp-chat",
|
||||
initialMessages: [],
|
||||
body: {
|
||||
pageContext,
|
||||
},
|
||||
} as any) as any;
|
||||
|
||||
// Basic implementation to toggle chat window and submit messages
|
||||
return (
|
||||
<div className="payload-mcp-chat-container">
|
||||
<button
|
||||
className="payload-mcp-chat-toggle"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "20px",
|
||||
right: "20px",
|
||||
zIndex: 9999,
|
||||
padding: "12px 24px",
|
||||
backgroundColor: "#000",
|
||||
color: "#fff",
|
||||
borderRadius: "8px",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{isOpen ? "Close AI Chat" : "Ask AI"}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="payload-mcp-chat-window"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "80px",
|
||||
right: "20px",
|
||||
width: "450px",
|
||||
height: "650px",
|
||||
backgroundColor: "#fff",
|
||||
border: "1px solid #eaeaea",
|
||||
borderRadius: "12px",
|
||||
zIndex: 9999,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: "0 10px 40px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="chat-header"
|
||||
style={{
|
||||
padding: "16px",
|
||||
borderBottom: "1px solid #eaeaea",
|
||||
backgroundColor: "#f9f9f9",
|
||||
borderTopLeftRadius: "12px",
|
||||
borderTopRightRadius: "12px",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: 0, fontSize: "16px" }}>Payload MCP Chat</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="chat-messages"
|
||||
style={{ flex: 1, padding: "16px", overflowY: "auto" }}
|
||||
>
|
||||
{messages.map((m: any) => (
|
||||
<div
|
||||
key={m.id}
|
||||
style={{
|
||||
marginBottom: "12px",
|
||||
textAlign: m.role === "user" ? "right" : "left",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: m.role === "user" ? "#000" : "#f0f0f0",
|
||||
color: m.role === "user" ? "#fff" : "#000",
|
||||
maxWidth: "80%",
|
||||
}}
|
||||
>
|
||||
{m.role === "user" ? "G: " : "AI: "}
|
||||
{m.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{ padding: "16px", borderTop: "1px solid #eaeaea" }}
|
||||
>
|
||||
<input
|
||||
value={input}
|
||||
placeholder="Ask me anything or use /commands..."
|
||||
onChange={handleInputChange}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #eaeaea",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useField, useDocumentInfo, useForm } from "@payloadcms/ui";
|
||||
export function AiFieldButton({ path, field }: { path: string; field: any }) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [instructions, setInstructions] = useState("");
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
|
||||
// Payload hooks
|
||||
const { value, setValue } = useField<string>({ path });
|
||||
const { title } = useDocumentInfo();
|
||||
const { fields } = useForm();
|
||||
|
||||
const extractText = (lexicalRoot: any): string => {
|
||||
if (!lexicalRoot) return "";
|
||||
let text = "";
|
||||
const iterate = (node: any) => {
|
||||
if (node.text) text += node.text + " ";
|
||||
if (node.children) node.children.forEach(iterate);
|
||||
};
|
||||
iterate(lexicalRoot);
|
||||
return text;
|
||||
};
|
||||
|
||||
const handleGenerate = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const lexicalValue = fields?.content?.value as any;
|
||||
const legacyValue = fields?.legacyMdx?.value as string;
|
||||
let draftContent = legacyValue || "";
|
||||
if (!draftContent && lexicalValue?.root) {
|
||||
draftContent = extractText(lexicalValue.root);
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
// Field name is passed as a label usually, fallback to path
|
||||
const fieldName = typeof field?.label === "string" ? field.label : path;
|
||||
const fieldDescription =
|
||||
typeof field?.admin?.description === "string"
|
||||
? field.admin.description
|
||||
: "";
|
||||
|
||||
const resData = await fetch("/api/api/mintel-ai/generate-single-field", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
documentTitle: (title as string) || "",
|
||||
documentContent: draftContent,
|
||||
fieldName,
|
||||
fieldDescription,
|
||||
instructions,
|
||||
}),
|
||||
});
|
||||
const res = await resData.json();
|
||||
|
||||
if (res.success && res.text) {
|
||||
setValue(res.text);
|
||||
} else {
|
||||
alert("Fehler: " + res.error);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
alert("Fehler bei der Generierung.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
setShowInstructions(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "8px",
|
||||
marginBottom: "8px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
style={{
|
||||
background: "var(--theme-elevation-150)",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
color: "var(--theme-text)",
|
||||
padding: "4px 12px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
cursor: isGenerating ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
opacity: isGenerating ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isGenerating ? "✨ AI arbeitet..." : "✨ AI Ausfüllen"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowInstructions(!showInstructions);
|
||||
}}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "var(--theme-elevation-500)",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
{showInstructions ? "Prompt verbergen" : "Mit Prompt..."}
|
||||
</button>
|
||||
</div>
|
||||
{showInstructions && (
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Eigene Anweisung an AI (z.B. 'als catchy slogan')"
|
||||
disabled={isGenerating}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px 8px",
|
||||
fontSize: "12px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
background: "var(--theme-elevation-50)",
|
||||
color: "var(--theme-text)",
|
||||
}}
|
||||
rows={2}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useForm, useField } from "@payloadcms/ui";
|
||||
export function GenerateSlugButton({ path }: { path: string }) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [instructions, setInstructions] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGenerating) return;
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue =
|
||||
"Slug-Generierung läuft noch. Wenn Sie neu laden, bricht der Vorgang ab!";
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [isGenerating]);
|
||||
|
||||
const { fields, replaceState } = useForm();
|
||||
const { value, initialValue, setValue } = useField({ path });
|
||||
|
||||
const extractText = (lexicalRoot: any): string => {
|
||||
if (!lexicalRoot) return "";
|
||||
let text = "";
|
||||
const iterate = (node: any) => {
|
||||
if (node.text) text += node.text + " ";
|
||||
if (node.children) node.children.forEach(iterate);
|
||||
};
|
||||
iterate(lexicalRoot);
|
||||
return text;
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const title = (fields?.title?.value as string) || "";
|
||||
const lexicalValue = fields?.content?.value as any;
|
||||
const legacyValue = fields?.legacyMdx?.value as string;
|
||||
|
||||
let draftContent = legacyValue || "";
|
||||
if (!draftContent && lexicalValue?.root) {
|
||||
draftContent = extractText(lexicalValue.root);
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const resData = await fetch("/api/api/mintel-ai/generate-slug", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
draftContent,
|
||||
oldSlug: initialValue as string,
|
||||
instructions,
|
||||
}),
|
||||
});
|
||||
const res = await resData.json();
|
||||
|
||||
if (res.success && res.slug) {
|
||||
setValue(res.slug);
|
||||
} else {
|
||||
alert("Fehler: " + res.error);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
alert("Unerwarteter Fehler.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center mb-4">
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Optionale AI Anweisung für den Slug..."
|
||||
disabled={isGenerating}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "40px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
background: "var(--theme-elevation-50)",
|
||||
color: "var(--theme-text)",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
className="btn btn--icon-style-none btn--size-medium ml-auto"
|
||||
style={{
|
||||
background: "var(--theme-elevation-150)",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
color: "var(--theme-text)",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.05)",
|
||||
transition: "all 0.2s ease",
|
||||
opacity: isGenerating ? 0.6 : 1,
|
||||
cursor: isGenerating ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
<span className="btn__content">
|
||||
{isGenerating ? "✨ Generiere (ca 10s)..." : "✨ AI Slug Generieren"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useForm, useField } from "@payloadcms/ui";
|
||||
export function GenerateThumbnailButton({ path }: { path: string }) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [instructions, setInstructions] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGenerating) return;
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue =
|
||||
"Bild-Generierung läuft noch (dies dauert bis zu 2 Minuten). Wenn Sie neu laden, bricht der Vorgang ab!";
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [isGenerating]);
|
||||
|
||||
const { fields } = useForm();
|
||||
const { value, setValue } = useField({ path });
|
||||
|
||||
const extractText = (lexicalRoot: any): string => {
|
||||
if (!lexicalRoot) return "";
|
||||
let text = "";
|
||||
const iterate = (node: any) => {
|
||||
if (node.text) text += node.text + " ";
|
||||
if (node.children) node.children.forEach(iterate);
|
||||
};
|
||||
iterate(lexicalRoot);
|
||||
return text;
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const title = (fields?.title?.value as string) || "";
|
||||
const lexicalValue = fields?.content?.value as any;
|
||||
const legacyValue = fields?.legacyMdx?.value as string;
|
||||
|
||||
let draftContent = legacyValue || "";
|
||||
if (!draftContent && lexicalValue?.root) {
|
||||
draftContent = extractText(lexicalValue.root);
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const resData = await fetch("/api/api/mintel-ai/generate-thumbnail", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
draftContent,
|
||||
title,
|
||||
instructions,
|
||||
}),
|
||||
});
|
||||
const res = await resData.json();
|
||||
|
||||
if (res.success && res.mediaId) {
|
||||
setValue(res.mediaId);
|
||||
} else {
|
||||
alert("Fehler: " + res.error);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
alert("Unerwarteter Fehler.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center mt-2 mb-4">
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Optionale Thumbnail-Detailanweisung (Farben, Stimmung, etc.)..."
|
||||
disabled={isGenerating}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "40px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
background: "var(--theme-elevation-50)",
|
||||
color: "var(--theme-text)",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
className="btn btn--icon-style-none btn--size-medium"
|
||||
style={{
|
||||
background: "var(--theme-elevation-150)",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
color: "var(--theme-text)",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.05)",
|
||||
transition: "all 0.2s ease",
|
||||
opacity: isGenerating ? 0.6 : 1,
|
||||
cursor: isGenerating ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
<span className="btn__content">
|
||||
{isGenerating
|
||||
? "✨ AI arbeitet (dauert ca. 1-2 Min)..."
|
||||
: "✨ AI Thumbnail Generieren"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user