chore: optimize cms startup, refactor scripts and implement real-time dev mode
This commit is contained in:
@@ -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
|
||||
|
||||
15
package.json
15
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -24,4 +24,4 @@
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,9 @@ const entryPoints = [
|
||||
|
||||
try {
|
||||
mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
|
||||
} catch (e) { }
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
console.log(`Building entry point...`);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,4 +24,4 @@
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
/* global module */
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "node:path";
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* global process */
|
||||
import path from "node:path";
|
||||
|
||||
const buildLintCommand = (filenames) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -24,4 +24,4 @@
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
148
pnpm-lock.yaml
generated
@@ -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
27
scripts/cms-up.sh
Executable 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
66
scripts/patch-cms.sh
Executable 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."
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user