chore: optimize cms startup, refactor scripts and implement real-time dev mode

This commit is contained in:
2026-02-16 18:10:52 +01:00
parent 67750c886e
commit 8f32c80801
33 changed files with 1185 additions and 618 deletions

View File

@@ -21,6 +21,8 @@ services:
- "traefik.enable=true"
- "traefik.http.routers.sample-website.rule=Host(`${TRAEFIK_HOST:-sample-website.localhost}`)"
- "traefik.http.services.sample-website.loadbalancer.server.port=3000"
- "caddy=http://${TRAEFIK_HOST:-acquisition.localhost}"
- "caddy.reverse_proxy={{upstreams 3000}}"
directus:
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
@@ -58,6 +60,8 @@ services:
- "traefik.enable=true"
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
- "caddy=http://${DIRECTUS_HOST:-cms.at.localhost}"
- "caddy.reverse_proxy={{upstreams 8055}}"
at-mintel-directus-db:
image: postgres:15-alpine

View File

@@ -10,15 +10,16 @@
"changeset": "changeset",
"version-packages": "changeset version",
"sync-versions": "tsx scripts/sync-versions.ts --",
"cms:push:infra": "./scripts/sync-directus.sh push infra",
"cms:pull:infra": "./scripts/sync-directus.sh pull infra",
"cms:dev": "pnpm --filter @mintel/cms-infra dev",
"cms:up": "pnpm --filter @mintel/cms-infra up",
"cms:down": "pnpm --filter @mintel/cms-infra down",
"cms:logs": "pnpm --filter @mintel/cms-infra logs",
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
"cms:schema:apply": "./scripts/cms-apply.sh local",
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
"cms:up": "cd packages/cms-infra && npm run up -- --force-recreate",
"cms:down": "cd packages/cms-infra && npm run down",
"cms:logs": "cd packages/cms-infra && npm run logs",
"dev:infra": "docker-compose up -d directus directus-db",
"cms:sync:push": "./scripts/sync-directus.sh push infra",
"cms:sync:pull": "./scripts/sync-directus.sh pull infra",
"build:extensions": "./scripts/sync-extensions.sh",
"release": "pnpm build && changeset publish",
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
"prepare": "husky"
@@ -63,4 +64,4 @@
"@sentry/nextjs": "10.38.0"
}
}
}
}

View File

@@ -20,9 +20,11 @@
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"dependencies": {
"@mintel/directus-extension-toolkit": "workspace:*"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0"
}
}
}

View File

@@ -72,6 +72,15 @@
<v-button v-if="selectedLead?.audit_pdf_path" secondary icon v-tooltip.bottom="'PDF öffnen'" @click="openPdf">
<v-icon name="open_in_new" />
</v-button>
<v-button
v-if="selectedLead && !isCustomer(selectedLead.company)"
secondary
@click="linkAsCustomer"
>
<v-icon name="handshake" left />
Kunde verlinken
</v-button>
<v-button
v-if="selectedLead?.audit_pdf_path"
@@ -182,9 +191,12 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useApi } from '@directus/extensions-sdk';
import { useRoute, useRouter } from 'vue-router';
import { MintelManagerLayout, MintelSelect, MintelStatCard } from '@mintel/directus-extension-toolkit';
const api = useApi();
const route = useRoute();
const router = useRouter();
const leads = ref<any[]>([]);
const selectedLeadId = ref<string | null>(null);
const loadingAudit = ref(false);
@@ -204,6 +216,7 @@ const newLead = ref({
const companies = ref<any[]>([]);
const people = ref<any[]>([]);
const customers = ref<any[]>([]);
const companyOptions = computed(() =>
companies.value.map(c => ({
@@ -242,7 +255,7 @@ const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadI
async function fetchData() {
try {
const [leadsResp, peopleResp, companiesResp] = await Promise.all([
const [leadsResp, peopleResp, companiesResp, customersResp] = await Promise.all([
api.get('/items/leads', {
params: {
sort: '-date_created',
@@ -250,11 +263,13 @@ async function fetchData() {
}
}),
api.get('/items/people', { params: { sort: 'last_name' } }),
api.get('/items/companies', { params: { sort: 'name' } })
api.get('/items/companies', { params: { sort: 'name' } }),
api.get('/items/customers', { params: { fields: ['company'] } })
]);
leads.value = leadsResp.data.data;
people.value = peopleResp.data.data;
companies.value = companiesResp.data.data;
customers.value = customersResp.data.data;
if (!selectedLeadId.value && leads.value.length > 0) {
selectedLeadId.value = leads.value[0].id;
@@ -264,6 +279,33 @@ async function fetchData() {
}
}
function isCustomer(companyId: string | any) {
if (!companyId) return false;
const id = typeof companyId === 'object' ? companyId.id : companyId;
return customers.value.some(c => (typeof c.company === 'object' ? c.company.id : c.company) === id);
}
async function linkAsCustomer() {
if (!selectedLead.value) return;
const companyId = selectedLead.value.company
? (typeof selectedLead.value.company === 'object' ? selectedLead.value.company.id : selectedLead.value.company)
: null;
const personId = selectedLead.value.contact_person
? (typeof selectedLead.value.contact_person === 'object' ? selectedLead.value.contact_person.id : selectedLead.value.contact_person)
: null;
router.push({
name: 'module-customer-manager',
query: {
create: 'true',
company: companyId,
contact_person: personId
}
});
}
async function fetchLeads() {
await fetchData();
}
@@ -391,7 +433,12 @@ function getStatusColor(status: string) {
}
}
onMounted(fetchData);
onMounted(async () => {
await fetchData();
if (route.query.create === 'true') {
openCreateDrawer();
}
});
</script>
<style scoped>

View File

@@ -11,7 +11,9 @@ const outfile = resolve(__dirname, 'dist/index.js');
try {
mkdirSync(dirname(outfile), { recursive: true });
} catch (e) { }
} catch {
// ignore
}
console.log(`Building from ${entryPoint} to ${outfile}...`);
@@ -22,12 +24,16 @@ build({
target: 'node18',
outfile: outfile,
jsx: 'automatic',
format: 'esm',
// footer: {
// js: "module.exports = module.exports.default || module.exports;",
// },
loader: {
'.tsx': 'tsx',
'.ts': 'ts',
'.js': 'js',
},
external: ["@react-pdf/renderer", "react", "react-dom", "jsdom", "jsdom/*", "jquery", "jquery/*", "canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"],
external: ["canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"],
plugins: [{
name: 'mock-canvas',
setup(build) {

View File

@@ -24,4 +24,4 @@
"react": "^19.2.4",
"react-dom": "^19.2.4"
}
}
}

View File

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

View File

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

View File

@@ -12,7 +12,9 @@ const entryPoints = [
try {
mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
} catch (e) { }
} catch {
// ignore
}
console.log(`Building entry point...`);

View File

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

View File

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

View File

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

Binary file not shown.

View File

@@ -39,9 +39,9 @@ services:
labels:
- "traefik.enable=true"
- "traefik.http.routers.at-mintel-infra-cms.rule=Host(`cms.localhost`)"
- "traefik.http.services.at-mintel-infra-cms.loadbalancer.server.port=8055"
- "traefik.http.services.at-mintel-infra-cms.loadbalancer.healthcheck.path=/server/health"
- "traefik.docker.network=infra"
- "caddy=cms.localhost"
- "caddy.reverse_proxy={{upstreams 8055}}"
networks:
default:

View File

@@ -4,7 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"up": "npm run build:extensions && docker compose up -d",
"dev": "npm run up -- --link",
"up": "../../scripts/cms-up.sh",
"down": "docker compose down",
"logs": "docker compose logs -f",
"build:extensions": "../../scripts/sync-extensions.sh",
@@ -14,4 +15,4 @@
"sync:push": "../../scripts/sync-directus.sh push infra",
"sync:pull": "../../scripts/sync-directus.sh pull infra"
}
}
}

View File

@@ -20,9 +20,11 @@
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"dependencies": {
"@mintel/directus-extension-toolkit": "workspace:*"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0"
}
}
}

View File

@@ -104,9 +104,11 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useApi } from '@directus/extensions-sdk';
import { useRoute } from 'vue-router';
import { MintelManagerLayout } from '@mintel/directus-extension-toolkit';
const api = useApi();
const route = useRoute();
const companies = ref([]);
const selectedCompany = ref(null);
const feedback = ref(null);
@@ -201,7 +203,12 @@ async function deleteCompany() {
}
}
onMounted(fetchData);
onMounted(async () => {
await fetchData();
if (route.query.create === 'true') {
openCreateDrawer();
}
});
</script>
<style scoped>

View File

@@ -20,9 +20,11 @@
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"dependencies": {
"@mintel/directus-extension-toolkit": "workspace:*"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0"
}
}
}

View File

@@ -193,15 +193,69 @@
</div>
</div>
</v-drawer>
<!-- Drawer: Quick Add Company -->
<v-drawer
v-model="quickAddCompanyActive"
title="Firma schnell anlegen"
icon="business"
@cancel="quickAddCompanyActive = false"
>
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Firmenname</span>
<v-input v-model="quickCompanyForm.name" placeholder="z.B. Schmidt GmbH" autofocus />
</div>
<div class="field">
<span class="label">Domain / Website</span>
<v-input v-model="quickCompanyForm.domain" placeholder="example.com" />
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="savingQuick" @click="saveQuickCompany">Firma speichern</v-button>
</div>
</div>
</v-drawer>
<!-- Drawer: Quick Add Person -->
<v-drawer
v-model="quickAddPersonActive"
title="Person schnell anlegen"
icon="person"
@cancel="quickAddPersonActive = false"
>
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Vorname</span>
<v-input v-model="quickPersonForm.first_name" placeholder="Vorname" autofocus />
</div>
<div class="field">
<span class="label">Nachname</span>
<v-input v-model="quickPersonForm.last_name" placeholder="Nachname" />
</div>
<div class="field">
<span class="label">E-Mail</span>
<v-input v-model="quickPersonForm.email" placeholder="email@example.com" type="email" />
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="savingQuick" @click="saveQuickPerson">Person speichern</v-button>
</div>
</div>
</v-drawer>
</MintelManagerLayout>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, computed } from 'vue';
import { ref, onMounted, nextTick, computed, watch } from 'vue';
import { useApi } from '@directus/extensions-sdk';
import { useRoute } from 'vue-router';
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
const api = useApi();
const route = useRoute();
const items = ref<any[]>([]);
const selectedItem = ref<any>(null);
@@ -222,6 +276,12 @@ const drawerUserActive = ref(false);
const isEditingUser = ref(false);
const userForm = ref({ id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' });
const quickAddCompanyActive = ref(false);
const quickAddPersonActive = ref(false);
const savingQuick = ref(false);
const quickCompanyForm = ref({ name: '', domain: '' });
const quickPersonForm = ref({ first_name: '', last_name: '', email: '' });
const tableHeaders = [
{ text: 'Name', value: 'name', sortable: true },
{ text: 'E-Mail', value: 'email', sortable: true },
@@ -284,7 +344,10 @@ function openEditDrawer() {
}
async function saveItem() {
if (!form.value.company) return;
if (!form.value.company) {
notice.value = { type: 'danger', message: 'Bitte wählen Sie eine Firma aus.' };
return;
}
saving.value = true;
try {
if (isEditing.value) {
@@ -301,7 +364,10 @@ async function saveItem() {
if (updated) selectItem(updated);
}
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
notice.value = {
type: 'danger',
message: e.response?.data?.errors?.[0]?.message || e.message || 'Speichern fehlgeschlagen'
};
} finally {
saving.value = false;
}
@@ -365,9 +431,45 @@ async function inviteUser(user: any) {
}
function openQuickAdd(type: 'company' | 'person') {
// Quick add logic can involve opening another drawer or navigating
// For now, we'll just show a notice
notice.value = { type: 'info', message: `${type === 'company' ? 'Firma' : 'Person'} im jeweiligen Manager anlegen.` };
if (type === 'company') {
quickCompanyForm.value = { name: '', domain: '' };
quickAddCompanyActive.value = true;
} else {
quickPersonForm.value = { first_name: '', last_name: '', email: '' };
quickAddPersonActive.value = true;
}
}
async function saveQuickCompany() {
if (!quickCompanyForm.value.name) return;
savingQuick.value = true;
try {
const res = await api.post('/items/companies', quickCompanyForm.value);
await fetchData();
form.value.company = res.data.data.id;
quickAddCompanyActive.value = false;
notice.value = { type: 'success', message: 'Firma angelegt und ausgewählt.' };
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} finally {
savingQuick.value = false;
}
}
async function saveQuickPerson() {
if (!quickPersonForm.value.first_name || !quickPersonForm.value.last_name) return;
savingQuick.value = true;
try {
const res = await api.post('/items/people', quickPersonForm.value);
await fetchData();
form.value.contact_person = res.data.data.id;
quickAddPersonActive.value = false;
notice.value = { type: 'success', message: 'Person angelegt und ausgewählt.' };
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} finally {
savingQuick.value = false;
}
}
function formatDate(dateStr: string) {
@@ -377,7 +479,30 @@ function formatDate(dateStr: string) {
});
}
onMounted(fetchData);
function handleDeepLink() {
if (route.query.create === 'true') {
// Only open if not already open to avoid resetting form if user is already typing
if (!drawerActive.value) {
openCreateDrawer();
}
if (route.query.company) {
form.value.company = route.query.company as any;
}
if (route.query.contact_person) {
form.value.contact_person = route.query.contact_person as any;
}
}
}
watch(() => route.query.create, (newVal) => {
if (newVal === 'true') handleDeepLink();
});
onMounted(async () => {
await fetchData();
handleDeepLink();
});
</script>
<style scoped>

View File

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

View File

@@ -24,4 +24,4 @@
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}
}

View File

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

View File

@@ -1,4 +1,3 @@
import path from "node:path";
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [

View File

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

View File

@@ -35,9 +35,9 @@ DB_USER="directus"
DB_NAME="directus"
echo "🔍 Detecting local database..."
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
LOCAL_DB_CONTAINER=$(docker compose ps -q at-mintel-directus-db)
if [ -z "$LOCAL_DB_CONTAINER" ]; then
echo "❌ Local directus-db container not found. Running?"
echo "❌ Local at-mintel-directus-db container not found. Running?"
exit 1
fi

View File

@@ -20,9 +20,11 @@
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"dependencies": {
"@mintel/directus-extension-toolkit": "workspace:*"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0"
}
}
}

View File

@@ -126,9 +126,11 @@
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue';
import { useApi } from '@directus/extensions-sdk';
import { useRoute } from 'vue-router';
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
const api = useApi();
const route = useRoute();
const people = ref([]);
const companies = ref([]);
const selectedPerson = ref(null);
@@ -256,7 +258,12 @@ function openQuickAdd(type: string) {
feedback.value = { type: 'info', message: `Firma im Company Manager anlegen.` };
}
onMounted(fetchData);
onMounted(async () => {
await fetchData();
if (route.query.create === 'true') {
openCreateDrawer();
}
});
</script>
<style scoped>

View File

@@ -24,4 +24,4 @@
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}
}

View File

@@ -7,7 +7,7 @@
</header>
<div class="stats-grid">
<div class="stat-card" @click="navigateTo('/company-manager')">
<div class="stat-card" @click="navigateTo('company-manager')">
<div class="stat-icon"><v-icon name="business" large /></div>
<div class="stat-content">
<span class="stat-label">Firmen</span>
@@ -16,7 +16,7 @@
<v-icon name="chevron_right" class="arrow" />
</div>
<div class="stat-card" @click="navigateTo('/people-manager')">
<div class="stat-card" @click="navigateTo('people-manager')">
<div class="stat-icon"><v-icon name="person" large /></div>
<div class="stat-content">
<span class="stat-label">Personen</span>
@@ -25,7 +25,7 @@
<v-icon name="chevron_right" class="arrow" />
</div>
<div class="stat-card" @click="navigateTo('/acquisition-manager')">
<div class="stat-card" @click="navigateTo('acquisition-manager')">
<div class="stat-icon"><v-icon name="auto_awesome" large /></div>
<div class="stat-content">
<span class="stat-label">Leads</span>
@@ -38,14 +38,18 @@
<div class="recent-activity">
<h2 class="section-title">Schnellzugriff</h2>
<div class="action-grid">
<v-button secondary block @click="navigateTo('/people-manager?create=true')">
<v-button secondary block @click="navigateTo('people-manager', { create: 'true' })">
<v-icon name="person_add" left />
Neue Person anlegen
</v-button>
<v-button secondary block @click="navigateTo('/acquisition-manager?create=true')">
<v-button secondary block @click="navigateTo('acquisition-manager', { create: 'true' })">
<v-icon name="add_link" left />
Neuen Lead registrieren
</v-button>
<v-button secondary block @click="navigateTo('customer-manager', { create: 'true' })">
<v-icon name="handshake" left />
Kunden verlinken
</v-button>
</div>
</div>
</div>
@@ -83,8 +87,8 @@ async function fetchStats() {
}
}
function navigateTo(path: string) {
router.push(path);
function navigateTo(id: string, query?: any) {
router.push({ name: `module-${id}`, query });
}
onMounted(fetchStats);

148
pnpm-lock.yaml generated
View File

@@ -164,7 +164,7 @@ importers:
devDependencies:
'@directus/extensions-sdk':
specifier: 11.0.2
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
'@mintel/mail':
specifier: workspace:*
version: link:../mail
@@ -179,13 +179,14 @@ importers:
version: 5.9.3
packages/acquisition-manager:
dependencies:
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
devDependencies:
'@directus/extensions-sdk':
specifier: 11.0.2
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
vue:
specifier: ^3.4.0
version: 3.5.28(typescript@5.9.3)
@@ -249,25 +250,27 @@ importers:
packages/cms-infra: {}
packages/company-manager:
dependencies:
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
devDependencies:
'@directus/extensions-sdk':
specifier: 11.0.2
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
vue:
specifier: ^3.4.0
version: 3.5.28(typescript@5.9.3)
packages/customer-manager:
dependencies:
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
devDependencies:
'@directus/extensions-sdk':
specifier: 11.0.2
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
vue:
specifier: ^3.4.0
version: 3.5.28(typescript@5.9.3)
@@ -440,7 +443,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.0.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
packages/next-config:
dependencies:
@@ -602,7 +605,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^2.0.0
version: 2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
version: 2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
packages/pdf-library:
dependencies:
@@ -639,13 +642,14 @@ importers:
version: 5.9.3
packages/people-manager:
dependencies:
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
devDependencies:
'@directus/extensions-sdk':
specifier: 11.0.2
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
vue:
specifier: ^3.4.0
version: 3.5.28(typescript@5.9.3)
@@ -8822,6 +8826,57 @@ snapshots:
'@directus/constants@11.0.3': {}
'@directus/extensions-sdk@11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)':
dependencies:
'@directus/composables': 10.1.12(vue@3.4.21(typescript@5.9.3))
'@directus/constants': 11.0.3
'@directus/extensions': 1.0.2(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(knex@3.1.0)(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(pino@10.3.1)(vue@3.4.21(typescript@5.9.3))
'@directus/themes': 0.3.6(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))
'@directus/types': 11.0.8(knex@3.1.0)(vue@3.4.21(typescript@5.9.3))
'@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
'@rollup/plugin-commonjs': 25.0.7(rollup@3.29.4)
'@rollup/plugin-json': 6.1.0(rollup@3.29.4)
'@rollup/plugin-node-resolve': 15.2.3(rollup@3.29.4)
'@rollup/plugin-replace': 5.0.5(rollup@3.29.4)
'@rollup/plugin-terser': 0.4.4(rollup@3.29.4)
'@rollup/plugin-virtual': 3.0.2(rollup@3.29.4)
'@vitejs/plugin-vue': 4.6.2(vite@4.5.2(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0))(vue@3.4.21(typescript@5.9.3))
chalk: 5.3.0
commander: 10.0.1
esbuild: 0.17.19
execa: 7.2.0
fs-extra: 11.2.0
inquirer: 9.2.16
ora: 6.3.1
rollup: 3.29.4
rollup-plugin-esbuild: 5.0.0(esbuild@0.17.19)(rollup@3.29.4)
rollup-plugin-styles: 4.0.0(rollup@3.29.4)
vite: 4.5.2(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
vue: 3.4.21(typescript@5.9.3)
transitivePeerDependencies:
- '@types/node'
- '@unhead/vue'
- better-sqlite3
- debug
- knex
- less
- lightningcss
- mysql
- mysql2
- pg
- pg-native
- pinia
- pino
- sass
- sqlite3
- stylus
- sugarss
- supports-color
- tedious
- terser
- typescript
- vue-router
'@directus/extensions-sdk@11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)':
dependencies:
'@directus/composables': 10.1.12(vue@3.4.21(typescript@5.9.3))
@@ -8873,6 +8928,32 @@ snapshots:
- typescript
- vue-router
'@directus/extensions@1.0.2(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(knex@3.1.0)(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(pino@10.3.1)(vue@3.4.21(typescript@5.9.3))':
dependencies:
'@directus/constants': 11.0.3
'@directus/themes': 0.3.6(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))
'@directus/types': 11.0.8(knex@3.1.0)(vue@3.4.21(typescript@5.9.3))
'@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
'@types/express': 4.17.21
fs-extra: 11.2.0
lodash-es: 4.17.21
zod: 3.22.4
optionalDependencies:
knex: 3.1.0
pino: 10.3.1
vue: 3.4.21(typescript@5.9.3)
transitivePeerDependencies:
- '@unhead/vue'
- better-sqlite3
- mysql
- mysql2
- pg
- pg-native
- pinia
- sqlite3
- supports-color
- tedious
'@directus/extensions@1.0.2(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(vue@3.4.21(typescript@5.9.3))':
dependencies:
'@directus/constants': 11.0.3
@@ -8916,6 +8997,17 @@ snapshots:
'@directus/system-data@1.0.2': {}
'@directus/themes@0.3.6(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))':
dependencies:
'@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
'@sinclair/typebox': 0.32.15
'@unhead/vue': 1.11.20(vue@3.4.21(typescript@5.9.3))
decamelize: 6.0.0
flat: 6.0.1
lodash-es: 4.17.21
pinia: 2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3))
vue: 3.4.21(typescript@5.9.3)
'@directus/themes@0.3.6(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))':
dependencies:
'@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
@@ -11007,6 +11099,14 @@ snapshots:
'@unhead/schema': 1.11.20
packrup: 0.1.2
'@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3))':
dependencies:
'@unhead/schema': 1.11.20
'@unhead/shared': 1.11.20
hookable: 5.5.3
unhead: 1.11.20
vue: 3.4.21(typescript@5.9.3)
'@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3))':
dependencies:
'@unhead/schema': 1.11.20
@@ -14439,6 +14539,16 @@ snapshots:
pify@4.0.1: {}
pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.4.21(typescript@5.9.3)
vue-demi: 0.14.10(vue@3.4.21(typescript@5.9.3))
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@vue/composition-api'
pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 6.6.4
@@ -15919,7 +16029,7 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
vitest@2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
vitest@2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
dependencies:
'@vitest/expect': 2.1.9
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0))
@@ -15957,7 +16067,7 @@ snapshots:
- supports-color
- terser
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
@@ -16040,6 +16150,10 @@ snapshots:
- tsx
- yaml
vue-demi@0.14.10(vue@3.4.21(typescript@5.9.3)):
dependencies:
vue: 3.4.21(typescript@5.9.3)
vue-demi@0.14.10(vue@3.5.28(typescript@5.9.3)):
dependencies:
vue: 3.5.28(typescript@5.9.3)

27
scripts/cms-up.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
echo "🚀 Starting CMS infrastructure..."
# 1. Build extensions (pass all arguments to handle flags like --link)
"$SCRIPT_DIR/sync-extensions.sh" "$@"
# Filter out --link before passing to docker compose
DOCKER_ARGS=()
for arg in "$@"; do
if [ "$arg" != "--link" ]; then
DOCKER_ARGS+=("$arg")
fi
done
# 2. Docker compose up with arguments
cd "$REPO_ROOT/packages/cms-infra"
docker compose up -d "${DOCKER_ARGS[@]}"
# 3. Apply core patch
"$SCRIPT_DIR/patch-cms.sh"
echo "✨ CMS is up and patched!"

66
scripts/patch-cms.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/bin/bash
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Define potential container names
CONTAINERS=("cms-infra-infra-cms-1" "at-mintel-directus-1")
echo "🔧 Checking for Directus containers to patch..."
for CONTAINER in "${CONTAINERS[@]}"; do
# Check if container exists and is running
if [ "$(docker ps -q -f name=^/${CONTAINER}$)" ]; then
echo "🔧 Applying core patch to Directus container: $CONTAINER..."
docker exec "$CONTAINER" node -e '
const fs = require("node:fs");
// Try multiple potential paths for the node_modules location
const searchPaths = [
"/directus/node_modules/.pnpm/@directus+extensions@file+packages+extensions_deep-diff@1.0.2_express@4.21.2_graphql@16_244b87fbecd929c2d2240e7b3abc1fe4/node_modules/@directus/extensions/dist/node.js",
"/directus/node_modules/@directus/extensions/dist/node.js"
];
let targetPath = null;
for (const p of searchPaths) {
if (fs.existsSync(p)) {
targetPath = p;
break;
}
}
if (targetPath) {
let content = fs.readFileSync(targetPath, "utf8");
// Patch the filter: allow string entrypoints for modules
const filterPatch = "extension.host === \"app\" && (extension.entrypoint.app || extension.entrypoint)";
if (!content.includes(filterPatch)) {
content = content.replace(
/extension\.host === \"app\" && !!extension\.entrypoint\.app/g,
filterPatch
);
}
// Patch all imports: handle string entrypoints
if (!content.includes("(extension.entrypoint.app || extension.entrypoint)")) {
content = content.replace(
/extension\.entrypoint\.app/g,
"(extension.entrypoint.app || extension.entrypoint)"
);
}
fs.writeFileSync(targetPath, content);
console.log(`✅ Core patched successfully at ${targetPath}.`);
} else {
console.error("⚠️ Could not find @directus/extensions node.js to patch!");
}
'
echo "🔄 Restarting Directus container: $CONTAINER..."
docker restart "$CONTAINER"
else
echo " Container $CONTAINER is not running or not found. Skipping patch."
fi
done
echo "✨ Patching process finished."

View File

@@ -22,6 +22,14 @@ EXTENSION_PACKAGES=(
"unified-dashboard"
)
# Parse flags
LINK_MODE=false
for arg in "$@"; do
if [ "$arg" == "--link" ]; then
LINK_MODE=true
fi
done
echo "🚀 Starting isolated extension sync..."
# Ensure target directories exist
@@ -59,13 +67,22 @@ for PKG in "${EXTENSION_PACKAGES[@]}"; do
rm -rf "${FINAL_TARGET:?}"/*
# Copy build artifacts
if [ -f "$PKG_PATH/dist/index.js" ]; then
cp "$PKG_PATH/dist/index.js" "$FINAL_TARGET/index.js"
elif [ -f "$PKG_PATH/index.js" ]; then
cp "$PKG_PATH/index.js" "$FINAL_TARGET/index.js"
if [ "$LINK_MODE" = true ]; then
if [ -f "$PKG_PATH/dist/index.js" ]; then
ln -sf "$PKG_PATH/dist/index.js" "$FINAL_TARGET/index.js"
elif [ -f "$PKG_PATH/index.js" ]; then
ln -sf "$PKG_PATH/index.js" "$FINAL_TARGET/index.js"
fi
else
if [ -f "$PKG_PATH/dist/index.js" ]; then
cp "$PKG_PATH/dist/index.js" "$FINAL_TARGET/index.js"
elif [ -f "$PKG_PATH/index.js" ]; then
cp "$PKG_PATH/index.js" "$FINAL_TARGET/index.js"
fi
fi
if [ -f "$PKG_PATH/package.json" ]; then
# We ALWAYS copy and patch package.json to avoid messing with source
cp "$PKG_PATH/package.json" "$FINAL_TARGET/"
# We force the registration path to index.js and ensure host/source are set
node -e "
@@ -88,7 +105,11 @@ for PKG in "${EXTENSION_PACKAGES[@]}"; do
fi
if [ -d "$PKG_PATH/dist" ]; then
cp -r "$PKG_PATH/dist" "$FINAL_TARGET/"
if [ "$LINK_MODE" = true ]; then
ln -sf "$PKG_PATH/dist" "$FINAL_TARGET/dist"
else
cp -r "$PKG_PATH/dist" "$FINAL_TARGET/"
fi
fi
done
@@ -118,38 +139,7 @@ for TARGET_BASE in "${TARGET_DIRS[@]}"; do
done
done
echo "🔧 Applying core patch to Directus 11.15.2 bundler..."
docker exec cms-infra-infra-cms-1 node -e '
const fs = require("node:fs");
const path = "/directus/node_modules/.pnpm/@directus+extensions@file+packages+extensions_deep-diff@1.0.2_express@4.21.2_graphql@16_244b87fbecd929c2d2240e7b3abc1fe4/node_modules/@directus/extensions/dist/node.js";
if (fs.existsSync(path)) {
let content = fs.readFileSync(path, "utf8");
// Patch the filter: allow string entrypoints for modules
const filterPatch = "extension.host === \"app\" && (extension.entrypoint.app || extension.entrypoint)";
if (!content.includes(filterPatch)) {
content = content.replace(
/extension\.host === \"app\" && !!extension\.entrypoint\.app/g,
filterPatch
);
}
# Container patching is now handled by scripts/patch-cms.sh
# which should be run AFTER the containers are up.
// Patch all imports: handle string entrypoints (replace all occurrences of .app where it might fail)
if (!content.includes("(extension.entrypoint.app || extension.entrypoint)")) {
content = content.replace(
/extension\.entrypoint\.app/g,
"(extension.entrypoint.app || extension.entrypoint)"
);
}
fs.writeFileSync(path, content);
console.log("✅ Core patched successfully.");
} else {
console.error("⚠️ Could not find node.js to patch!");
}
'
echo "🔄 Restarting Directus container..."
docker restart cms-infra-infra-cms-1 2>/dev/null || true
echo "✨ Sync complete! Extensions are in packages/cms-infra/extensions and core is patched."
echo "✨ Sync complete! Extensions are in packages/cms-infra/extensions."