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.enable=true"
|
||||||
- "traefik.http.routers.sample-website.rule=Host(`${TRAEFIK_HOST:-sample-website.localhost}`)"
|
- "traefik.http.routers.sample-website.rule=Host(`${TRAEFIK_HOST:-sample-website.localhost}`)"
|
||||||
- "traefik.http.services.sample-website.loadbalancer.server.port=3000"
|
- "traefik.http.services.sample-website.loadbalancer.server.port=3000"
|
||||||
|
- "caddy=http://${TRAEFIK_HOST:-acquisition.localhost}"
|
||||||
|
- "caddy.reverse_proxy={{upstreams 3000}}"
|
||||||
|
|
||||||
directus:
|
directus:
|
||||||
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
||||||
@@ -58,6 +60,8 @@ services:
|
|||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
|
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
|
||||||
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
|
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
|
||||||
|
- "caddy=http://${DIRECTUS_HOST:-cms.at.localhost}"
|
||||||
|
- "caddy.reverse_proxy={{upstreams 8055}}"
|
||||||
|
|
||||||
at-mintel-directus-db:
|
at-mintel-directus-db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -10,15 +10,16 @@
|
|||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"version-packages": "changeset version",
|
"version-packages": "changeset version",
|
||||||
"sync-versions": "tsx scripts/sync-versions.ts --",
|
"sync-versions": "tsx scripts/sync-versions.ts --",
|
||||||
"cms:push:infra": "./scripts/sync-directus.sh push infra",
|
"cms:dev": "pnpm --filter @mintel/cms-infra dev",
|
||||||
"cms:pull:infra": "./scripts/sync-directus.sh pull infra",
|
"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:snapshot": "./scripts/cms-snapshot.sh",
|
||||||
"cms:schema:apply": "./scripts/cms-apply.sh local",
|
"cms:schema:apply": "./scripts/cms-apply.sh local",
|
||||||
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
|
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
|
||||||
"cms:up": "cd packages/cms-infra && npm run up -- --force-recreate",
|
"cms:sync:push": "./scripts/sync-directus.sh push infra",
|
||||||
"cms:down": "cd packages/cms-infra && npm run down",
|
"cms:sync:pull": "./scripts/sync-directus.sh pull infra",
|
||||||
"cms:logs": "cd packages/cms-infra && npm run logs",
|
"build:extensions": "./scripts/sync-extensions.sh",
|
||||||
"dev:infra": "docker-compose up -d directus directus-db",
|
|
||||||
"release": "pnpm build && changeset publish",
|
"release": "pnpm build && changeset publish",
|
||||||
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
|
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
|
|||||||
@@ -20,9 +20,11 @@
|
|||||||
"build": "directus-extension build",
|
"build": "directus-extension build",
|
||||||
"dev": "directus-extension build -w"
|
"dev": "directus-extension build -w"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mintel/directus-extension-toolkit": "workspace:*"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
|
||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,6 +73,15 @@
|
|||||||
<v-icon name="open_in_new" />
|
<v-icon name="open_in_new" />
|
||||||
</v-button>
|
</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-button
|
||||||
v-if="selectedLead?.audit_pdf_path"
|
v-if="selectedLead?.audit_pdf_path"
|
||||||
primary
|
primary
|
||||||
@@ -182,9 +191,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
import { useApi } from '@directus/extensions-sdk';
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { MintelManagerLayout, MintelSelect, MintelStatCard } from '@mintel/directus-extension-toolkit';
|
import { MintelManagerLayout, MintelSelect, MintelStatCard } from '@mintel/directus-extension-toolkit';
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const leads = ref<any[]>([]);
|
const leads = ref<any[]>([]);
|
||||||
const selectedLeadId = ref<string | null>(null);
|
const selectedLeadId = ref<string | null>(null);
|
||||||
const loadingAudit = ref(false);
|
const loadingAudit = ref(false);
|
||||||
@@ -204,6 +216,7 @@ const newLead = ref({
|
|||||||
|
|
||||||
const companies = ref<any[]>([]);
|
const companies = ref<any[]>([]);
|
||||||
const people = ref<any[]>([]);
|
const people = ref<any[]>([]);
|
||||||
|
const customers = ref<any[]>([]);
|
||||||
|
|
||||||
const companyOptions = computed(() =>
|
const companyOptions = computed(() =>
|
||||||
companies.value.map(c => ({
|
companies.value.map(c => ({
|
||||||
@@ -242,7 +255,7 @@ const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadI
|
|||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
const [leadsResp, peopleResp, companiesResp] = await Promise.all([
|
const [leadsResp, peopleResp, companiesResp, customersResp] = await Promise.all([
|
||||||
api.get('/items/leads', {
|
api.get('/items/leads', {
|
||||||
params: {
|
params: {
|
||||||
sort: '-date_created',
|
sort: '-date_created',
|
||||||
@@ -250,11 +263,13 @@ async function fetchData() {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
api.get('/items/people', { params: { sort: 'last_name' } }),
|
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;
|
leads.value = leadsResp.data.data;
|
||||||
people.value = peopleResp.data.data;
|
people.value = peopleResp.data.data;
|
||||||
companies.value = companiesResp.data.data;
|
companies.value = companiesResp.data.data;
|
||||||
|
customers.value = customersResp.data.data;
|
||||||
|
|
||||||
if (!selectedLeadId.value && leads.value.length > 0) {
|
if (!selectedLeadId.value && leads.value.length > 0) {
|
||||||
selectedLeadId.value = leads.value[0].id;
|
selectedLeadId.value = leads.value[0].id;
|
||||||
@@ -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() {
|
async function fetchLeads() {
|
||||||
await fetchData();
|
await fetchData();
|
||||||
}
|
}
|
||||||
@@ -391,7 +433,12 @@ function getStatusColor(status: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchData);
|
onMounted(async () => {
|
||||||
|
await fetchData();
|
||||||
|
if (route.query.create === 'true') {
|
||||||
|
openCreateDrawer();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ const outfile = resolve(__dirname, 'dist/index.js');
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
mkdirSync(dirname(outfile), { recursive: true });
|
mkdirSync(dirname(outfile), { recursive: true });
|
||||||
} catch (e) { }
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Building from ${entryPoint} to ${outfile}...`);
|
console.log(`Building from ${entryPoint} to ${outfile}...`);
|
||||||
|
|
||||||
@@ -22,12 +24,16 @@ build({
|
|||||||
target: 'node18',
|
target: 'node18',
|
||||||
outfile: outfile,
|
outfile: outfile,
|
||||||
jsx: 'automatic',
|
jsx: 'automatic',
|
||||||
|
format: 'esm',
|
||||||
|
// footer: {
|
||||||
|
// js: "module.exports = module.exports.default || module.exports;",
|
||||||
|
// },
|
||||||
loader: {
|
loader: {
|
||||||
'.tsx': 'tsx',
|
'.tsx': 'tsx',
|
||||||
'.ts': 'ts',
|
'.ts': 'ts',
|
||||||
'.js': 'js',
|
'.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: [{
|
plugins: [{
|
||||||
name: 'mock-canvas',
|
name: 'mock-canvas',
|
||||||
setup(build) {
|
setup(build) {
|
||||||
|
|||||||
@@ -1,176 +1,236 @@
|
|||||||
import { defineEndpoint } from "@directus/extensions-sdk";
|
import { defineEndpoint } from "@directus/extensions-sdk";
|
||||||
import { AcquisitionService, PdfEngine } from "@mintel/pdf/server";
|
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 { createElement } from "react";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
|
|
||||||
export default defineEndpoint((router, { services, env }) => {
|
export default defineEndpoint((router, { services, env }) => {
|
||||||
|
const { ItemsService, MailService } = services;
|
||||||
|
|
||||||
|
router.get("/ping", (req, res) => res.send("pong"));
|
||||||
|
|
||||||
|
router.post("/audit/:id", async (req: any, res: any) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const leadsService = new ItemsService("leads", {
|
||||||
|
schema: req.schema,
|
||||||
|
accountability: req.accountability,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lead = await leadsService.readOne(id);
|
||||||
|
if (!lead) return res.status(404).send({ error: "Lead not found" });
|
||||||
|
|
||||||
|
await leadsService.updateOne(id, { status: "auditing" });
|
||||||
|
|
||||||
|
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
|
||||||
|
const result = await acqService.runFullSequence(
|
||||||
|
lead.website_url,
|
||||||
|
lead.briefing,
|
||||||
|
lead.comments,
|
||||||
|
);
|
||||||
|
|
||||||
|
await leadsService.updateOne(id, {
|
||||||
|
status: "audit_ready",
|
||||||
|
ai_state: result.state,
|
||||||
|
audit_context: JSON.stringify(result.usage),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send({ success: true, result });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Audit failed:", error);
|
||||||
|
await leadsService.updateOne(id, {
|
||||||
|
status: "new",
|
||||||
|
comments: `Audit failed: ${error.message}`,
|
||||||
|
});
|
||||||
|
res.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/audit-email/:id", async (req: any, res: any) => {
|
||||||
|
const { id } = req.params;
|
||||||
const { ItemsService, MailService } = services;
|
const { ItemsService, MailService } = services;
|
||||||
|
const leadsService = new ItemsService("leads", {
|
||||||
router.get("/ping", (req, res) => res.send("pong"));
|
schema: req.schema,
|
||||||
|
accountability: req.accountability,
|
||||||
router.post("/audit/:id", async (req: any, res: any) => {
|
});
|
||||||
const { id } = req.params;
|
const _peopleService = new ItemsService("people", {
|
||||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
schema: req.schema,
|
||||||
|
accountability: req.accountability,
|
||||||
try {
|
});
|
||||||
const lead = await leadsService.readOne(id);
|
const companiesService = new ItemsService("companies", {
|
||||||
if (!lead) return res.status(404).send({ error: "Lead not found" });
|
schema: req.schema,
|
||||||
|
accountability: req.accountability,
|
||||||
await leadsService.updateOne(id, { status: "auditing" });
|
});
|
||||||
|
const mailService = new MailService({
|
||||||
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
|
schema: req.schema,
|
||||||
const result = await acqService.runFullSequence(lead.website_url, lead.briefing, lead.comments);
|
accountability: req.accountability,
|
||||||
|
|
||||||
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) => {
|
try {
|
||||||
const { id } = req.params;
|
const lead = await leadsService.readOne(id, {
|
||||||
const { ItemsService, MailService } = services;
|
fields: ["*", "company.*", "contact_person.*"],
|
||||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
});
|
||||||
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
|
if (!lead || !lead.ai_state)
|
||||||
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
|
return res.status(400).send({ error: "Lead or Audit not ready" });
|
||||||
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
|
|
||||||
|
|
||||||
try {
|
let recipientEmail = lead.contact_email;
|
||||||
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
|
let companyName = lead.company?.name || lead.company_name;
|
||||||
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or Audit not ready" });
|
|
||||||
|
|
||||||
let recipientEmail = lead.contact_email;
|
if (lead.contact_person) {
|
||||||
let companyName = lead.company?.name || lead.company_name;
|
recipientEmail = lead.contact_person.email || recipientEmail;
|
||||||
|
|
||||||
if (lead.contact_person) {
|
if (lead.contact_person.company) {
|
||||||
recipientEmail = lead.contact_person.email || recipientEmail;
|
const personCompany = await companiesService.readOne(
|
||||||
|
lead.contact_person.company,
|
||||||
if (lead.contact_person.company) {
|
);
|
||||||
const personCompany = await companiesService.readOne(lead.contact_person.company);
|
companyName = personCompany?.name || companyName;
|
||||||
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 (!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) => {
|
try {
|
||||||
const { id } = req.params;
|
const lead = await leadsService.readOne(id);
|
||||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
if (!lead || !lead.ai_state)
|
||||||
|
return res.status(400).send({ error: "Lead or AI state not found" });
|
||||||
|
|
||||||
try {
|
const pdfEngine = new PdfEngine();
|
||||||
const lead = await leadsService.readOne(id);
|
const filename = `estimate_${id}_${Date.now()}.pdf`;
|
||||||
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or AI state not found" });
|
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
|
||||||
|
const outputPath = path.join(storageRoot, filename);
|
||||||
|
|
||||||
const pdfEngine = new PdfEngine();
|
await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath);
|
||||||
const filename = `estimate_${id}_${Date.now()}.pdf`;
|
|
||||||
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
|
|
||||||
const outputPath = path.join(storageRoot, filename);
|
|
||||||
|
|
||||||
await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath);
|
await leadsService.updateOne(id, {
|
||||||
|
audit_pdf_path: filename,
|
||||||
|
});
|
||||||
|
|
||||||
await leadsService.updateOne(id, {
|
res.send({ success: true, filename });
|
||||||
audit_pdf_path: filename,
|
} catch (error: any) {
|
||||||
});
|
console.error("PDF Generation failed:", error);
|
||||||
|
res.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
res.send({ success: true, filename });
|
router.post("/estimate-email/:id", async (req: any, res: any) => {
|
||||||
} catch (error: any) {
|
const { id } = req.params;
|
||||||
console.error("PDF Generation failed:", error);
|
const leadsService = new ItemsService("leads", {
|
||||||
res.status(500).send({ error: error.message });
|
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) => {
|
try {
|
||||||
const { id } = req.params;
|
const lead = await leadsService.readOne(id, {
|
||||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
fields: ["*", "company.*", "contact_person.*"],
|
||||||
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
|
});
|
||||||
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
|
if (!lead || !lead.audit_pdf_path)
|
||||||
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
|
return res.status(400).send({ error: "PDF not generated" });
|
||||||
|
|
||||||
try {
|
let recipientEmail = lead.contact_email;
|
||||||
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
|
let companyName = lead.company?.name || lead.company_name;
|
||||||
if (!lead || !lead.audit_pdf_path) return res.status(400).send({ error: "PDF not generated" });
|
|
||||||
|
|
||||||
let recipientEmail = lead.contact_email;
|
if (lead.contact_person) {
|
||||||
let companyName = lead.company?.name || lead.company_name;
|
recipientEmail = lead.contact_person.email || recipientEmail;
|
||||||
|
|
||||||
if (lead.contact_person) {
|
if (lead.contact_person.company) {
|
||||||
recipientEmail = lead.contact_person.email || recipientEmail;
|
const personCompany = await companiesService.readOne(
|
||||||
|
lead.contact_person.company,
|
||||||
if (lead.contact_person.company) {
|
);
|
||||||
const personCompany = await companiesService.readOne(lead.contact_person.company);
|
companyName = personCompany?.name || companyName;
|
||||||
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 (!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(
|
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" },
|
{ stdio: "inherit" },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ const entryPoints = [
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
|
mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
|
||||||
} catch (e) { }
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Building entry point...`);
|
console.log(`Building entry point...`);
|
||||||
|
|
||||||
|
|||||||
@@ -3,91 +3,96 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
export interface AssetMap {
|
export interface AssetMap {
|
||||||
[originalUrl: string]: string;
|
[originalUrl: string]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AssetManager {
|
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") {
|
constructor(
|
||||||
this.userAgent = userAgent;
|
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 {
|
public async processCssRecursively(
|
||||||
return rawPath
|
cssContent: string,
|
||||||
.split("/")
|
cssUrl: string,
|
||||||
.map((p) => p.replace(/[^a-z0-9._-]/gi, "_"))
|
assetsDir: string,
|
||||||
.join("/");
|
urlMap: AssetMap,
|
||||||
}
|
depth = 0,
|
||||||
|
): Promise<string> {
|
||||||
|
if (depth > 5) return cssContent;
|
||||||
|
|
||||||
public async downloadFile(url: string, assetsDir: string): Promise<string | null> {
|
const urlRegex = /(?:url\(["']?|@import\s+["'])([^"')]*)["']?\)?/gi;
|
||||||
if (url.startsWith("//")) url = `https:${url}`;
|
let match;
|
||||||
if (!url.startsWith("http")) return null;
|
let newContent = cssContent;
|
||||||
|
|
||||||
try {
|
while ((match = urlRegex.exec(cssContent)) !== null) {
|
||||||
const u = new URL(url);
|
const originalUrl = match[1];
|
||||||
const relPath = this.sanitizePath(u.hostname + u.pathname);
|
if (originalUrl.startsWith("data:") || originalUrl.startsWith("blob:"))
|
||||||
const dest = path.join(assetsDir, relPath);
|
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, {
|
if (local) {
|
||||||
responseType: "arraybuffer",
|
const u = new URL(cssUrl);
|
||||||
headers: { "User-Agent": this.userAgent },
|
const cssPath = u.hostname + u.pathname;
|
||||||
timeout: 15000,
|
const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
|
||||||
validateStatus: () => true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status !== 200) return null;
|
const rel = path.relative(
|
||||||
|
path.dirname(this.sanitizePath(cssPath)),
|
||||||
|
this.sanitizePath(assetPath),
|
||||||
|
);
|
||||||
|
|
||||||
if (!fs.existsSync(path.dirname(dest)))
|
newContent = newContent.split(originalUrl).join(rel);
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
urlMap[absUrl] = local;
|
||||||
fs.writeFileSync(dest, Buffer.from(res.data));
|
|
||||||
return `./assets/${relPath}`;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return newContent;
|
||||||
public async processCssRecursively(
|
}
|
||||||
cssContent: string,
|
|
||||||
cssUrl: string,
|
|
||||||
assetsDir: string,
|
|
||||||
urlMap: AssetMap,
|
|
||||||
depth = 0,
|
|
||||||
): Promise<string> {
|
|
||||||
if (depth > 5) return cssContent;
|
|
||||||
|
|
||||||
const urlRegex = /(?:url\(["']?|@import\s+["'])([^"'\)]+)["']?\)?/gi;
|
|
||||||
let match;
|
|
||||||
let newContent = cssContent;
|
|
||||||
|
|
||||||
while ((match = urlRegex.exec(cssContent)) !== null) {
|
|
||||||
const originalUrl = match[1];
|
|
||||||
if (originalUrl.startsWith("data:") || originalUrl.startsWith("blob:"))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const absUrl = new URL(originalUrl, cssUrl).href;
|
|
||||||
const local = await this.downloadFile(absUrl, assetsDir);
|
|
||||||
|
|
||||||
if (local) {
|
|
||||||
const u = new URL(cssUrl);
|
|
||||||
const cssPath = u.hostname + u.pathname;
|
|
||||||
const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
|
|
||||||
|
|
||||||
const rel = path.relative(
|
|
||||||
path.dirname(this.sanitizePath(cssPath)),
|
|
||||||
this.sanitizePath(assetPath),
|
|
||||||
);
|
|
||||||
|
|
||||||
newContent = newContent.split(originalUrl).join(rel);
|
|
||||||
urlMap[absUrl] = local;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return newContent;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,184 +1,256 @@
|
|||||||
import { chromium, Browser, BrowserContext, Page } from "playwright";
|
import { chromium } from "playwright";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { AssetManager, AssetMap } from "./AssetManager.js";
|
import { AssetManager, AssetMap } from "./AssetManager.js";
|
||||||
|
|
||||||
export interface PageClonerOptions {
|
export interface PageClonerOptions {
|
||||||
outputDir: string;
|
outputDir: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PageCloner {
|
export class PageCloner {
|
||||||
private options: PageClonerOptions;
|
private options: PageClonerOptions;
|
||||||
private assetManager: AssetManager;
|
private assetManager: AssetManager;
|
||||||
private userAgent: string;
|
private userAgent: string;
|
||||||
|
|
||||||
constructor(options: PageClonerOptions) {
|
constructor(options: PageClonerOptions) {
|
||||||
this.options = options;
|
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.userAgent =
|
||||||
this.assetManager = new AssetManager(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> {
|
public async clone(targetUrl: string): Promise<string> {
|
||||||
const urlObj = new URL(targetUrl);
|
const urlObj = new URL(targetUrl);
|
||||||
const domainSlug = urlObj.hostname.replace("www.", "");
|
const domainSlug = urlObj.hostname.replace("www.", "");
|
||||||
const domainDir = path.resolve(this.options.outputDir, domainSlug);
|
const domainDir = path.resolve(this.options.outputDir, domainSlug);
|
||||||
const assetsDir = path.join(domainDir, "assets");
|
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("-");
|
let pageSlug = urlObj.pathname.split("/").filter(Boolean).join("-");
|
||||||
if (!pageSlug) pageSlug = "index";
|
if (!pageSlug) pageSlug = "index";
|
||||||
const htmlFilename = `${pageSlug}.html`;
|
const htmlFilename = `${pageSlug}.html`;
|
||||||
|
|
||||||
console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`);
|
console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`);
|
||||||
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
const browser = await chromium.launch({ headless: true });
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
userAgent: this.userAgent,
|
userAgent: this.userAgent,
|
||||||
viewport: { width: 1920, height: 1080 },
|
viewport: { width: 1920, height: 1080 },
|
||||||
});
|
});
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
const urlMap: AssetMap = {};
|
const urlMap: AssetMap = {};
|
||||||
const foundAssets = new Set<string>();
|
const foundAssets = new Set<string>();
|
||||||
|
|
||||||
page.on("response", (response) => {
|
page.on("response", (response) => {
|
||||||
if (response.status() === 200) {
|
if (response.status() === 200) {
|
||||||
const url = response.url();
|
const url = response.url();
|
||||||
if (url.match(/\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)/i)) {
|
if (
|
||||||
foundAssets.add(url);
|
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();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 { PlaywrightCrawler, RequestQueue } from "crawlee";
|
||||||
import * as path from 'node:path';
|
import * as path from "node:path";
|
||||||
import * as fs from 'node:fs';
|
import * as fs from "node:fs";
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
export interface WebsiteClonerOptions {
|
export interface WebsiteClonerOptions {
|
||||||
baseOutputDir: string;
|
baseOutputDir: string;
|
||||||
maxRequestsPerCrawl?: number;
|
maxRequestsPerCrawl?: number;
|
||||||
maxConcurrency?: number;
|
maxConcurrency?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WebsiteCloner {
|
export class WebsiteCloner {
|
||||||
private options: WebsiteClonerOptions;
|
private options: WebsiteClonerOptions;
|
||||||
|
|
||||||
constructor(options: WebsiteClonerOptions) {
|
constructor(options: WebsiteClonerOptions) {
|
||||||
this.options = {
|
this.options = {
|
||||||
maxRequestsPerCrawl: 100,
|
maxRequestsPerCrawl: 100,
|
||||||
maxConcurrency: 3,
|
maxConcurrency: 3,
|
||||||
...options
|
...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> {
|
console.log(`🚀 Starting perfect recursive clone of ${targetUrl}...`);
|
||||||
const urlObj = new URL(targetUrl);
|
console.log(`📂 Output: ${baseOutputDir}`);
|
||||||
const domain = urlObj.hostname;
|
|
||||||
const finalOutputDirName = outputDirName || domain.replace(/\./g, '-');
|
|
||||||
const baseOutputDir = path.resolve(this.options.baseOutputDir, finalOutputDirName);
|
|
||||||
|
|
||||||
if (fs.existsSync(baseOutputDir)) {
|
const requestQueue = await RequestQueue.open();
|
||||||
fs.rmSync(baseOutputDir, { recursive: true, force: true });
|
await requestQueue.addRequest({ url: targetUrl });
|
||||||
}
|
|
||||||
fs.mkdirSync(baseOutputDir, { recursive: true });
|
|
||||||
|
|
||||||
console.log(`🚀 Starting perfect recursive clone of ${targetUrl}...`);
|
const crawler = new PlaywrightCrawler({
|
||||||
console.log(`📂 Output: ${baseOutputDir}`);
|
requestQueue,
|
||||||
|
maxRequestsPerCrawl: this.options.maxRequestsPerCrawl,
|
||||||
|
maxConcurrency: this.options.maxConcurrency,
|
||||||
|
|
||||||
const requestQueue = await RequestQueue.open();
|
async requestHandler({ request, enqueueLinks, log }) {
|
||||||
await requestQueue.addRequest({ url: targetUrl });
|
const url = request.url;
|
||||||
|
log.info(`Capturing ${url}...`);
|
||||||
|
|
||||||
const crawler = new PlaywrightCrawler({
|
const u = new URL(url);
|
||||||
requestQueue,
|
let relPath = u.pathname;
|
||||||
maxRequestsPerCrawl: this.options.maxRequestsPerCrawl,
|
if (relPath === "/" || relPath === "") relPath = "/index.html";
|
||||||
maxConcurrency: this.options.maxConcurrency,
|
if (!relPath.endsWith(".html") && !path.extname(relPath))
|
||||||
|
relPath += "/index.html";
|
||||||
|
if (relPath.startsWith("/")) relPath = relPath.substring(1);
|
||||||
|
|
||||||
async requestHandler({ request, enqueueLinks, log }) {
|
const fullPath = path.join(baseOutputDir, relPath);
|
||||||
const url = request.url;
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
log.info(`Capturing ${url}...`);
|
|
||||||
|
|
||||||
const u = new URL(url);
|
try {
|
||||||
let relPath = u.pathname;
|
// Note: This assumes single-file-cli is available in the environment
|
||||||
if (relPath === '/' || relPath === '') relPath = '/index.html';
|
execSync(
|
||||||
if (!relPath.endsWith('.html') && !path.extname(relPath)) relPath += '/index.html';
|
`npx single-file-cli "${url}" "${fullPath}" --browser-headless=true --browser-wait-until=networkidle0`,
|
||||||
if (relPath.startsWith('/')) relPath = relPath.substring(1);
|
{
|
||||||
|
stdio: "inherit",
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
} 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...');
|
console.log("🔗 Rewriting internal links for offline navigation...");
|
||||||
const allFiles = this.getFiles(baseOutputDir).filter(f => f.endsWith('.html'));
|
const allFiles = this.getFiles(baseOutputDir).filter((f) =>
|
||||||
|
f.endsWith(".html"),
|
||||||
|
);
|
||||||
|
|
||||||
for (const file of allFiles) {
|
for (const file of allFiles) {
|
||||||
let content = fs.readFileSync(file, 'utf8');
|
let content = fs.readFileSync(file, "utf8");
|
||||||
const fileRelToRoot = path.relative(baseOutputDir, file);
|
const fileRelToRoot = path.relative(baseOutputDir, file);
|
||||||
|
|
||||||
content = content.replace(/href="([^"]+)"/g, (match, href) => {
|
content = content.replace(/href="([^"]+)"/g, (match, href) => {
|
||||||
if (href.startsWith(targetUrl) || href.startsWith('/') || (!href.includes('://') && !href.startsWith('data:'))) {
|
if (
|
||||||
try {
|
href.startsWith(targetUrl) ||
|
||||||
const linkUrl = new URL(href, targetUrl);
|
href.startsWith("/") ||
|
||||||
if (linkUrl.hostname === domain) {
|
(!href.includes("://") && !href.startsWith("data:"))
|
||||||
let linkPath = linkUrl.pathname;
|
) {
|
||||||
if (linkPath === '/' || linkPath === '') linkPath = '/index.html';
|
try {
|
||||||
if (!linkPath.endsWith('.html') && !path.extname(linkPath)) linkPath += '/index.html';
|
const linkUrl = new URL(href, targetUrl);
|
||||||
if (linkPath.startsWith('/')) linkPath = linkPath.substring(1);
|
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);
|
const relativeLink = path.relative(
|
||||||
return `href="${relativeLink}"`;
|
path.dirname(fileRelToRoot),
|
||||||
}
|
linkPath,
|
||||||
} catch (e) { }
|
);
|
||||||
}
|
return `href="${relativeLink}"`;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
} 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:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.at-mintel-infra-cms.rule=Host(`cms.localhost`)"
|
- "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"
|
- "traefik.docker.network=infra"
|
||||||
|
- "caddy=cms.localhost"
|
||||||
|
- "caddy.reverse_proxy={{upstreams 8055}}"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"up": "npm run build:extensions && docker compose up -d",
|
"dev": "npm run up -- --link",
|
||||||
|
"up": "../../scripts/cms-up.sh",
|
||||||
"down": "docker compose down",
|
"down": "docker compose down",
|
||||||
"logs": "docker compose logs -f",
|
"logs": "docker compose logs -f",
|
||||||
"build:extensions": "../../scripts/sync-extensions.sh",
|
"build:extensions": "../../scripts/sync-extensions.sh",
|
||||||
|
|||||||
@@ -20,9 +20,11 @@
|
|||||||
"build": "directus-extension build",
|
"build": "directus-extension build",
|
||||||
"dev": "directus-extension build -w"
|
"dev": "directus-extension build -w"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mintel/directus-extension-toolkit": "workspace:*"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
|
||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,9 +104,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { useApi } from '@directus/extensions-sdk';
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
import { MintelManagerLayout } from '@mintel/directus-extension-toolkit';
|
import { MintelManagerLayout } from '@mintel/directus-extension-toolkit';
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
const route = useRoute();
|
||||||
const companies = ref([]);
|
const companies = ref([]);
|
||||||
const selectedCompany = ref(null);
|
const selectedCompany = ref(null);
|
||||||
const feedback = 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -20,9 +20,11 @@
|
|||||||
"build": "directus-extension build",
|
"build": "directus-extension build",
|
||||||
"dev": "directus-extension build -w"
|
"dev": "directus-extension build -w"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mintel/directus-extension-toolkit": "workspace:*"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
|
||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,15 +193,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-drawer>
|
</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>
|
</MintelManagerLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useApi } from '@directus/extensions-sdk';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
|
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
const items = ref<any[]>([]);
|
const items = ref<any[]>([]);
|
||||||
const selectedItem = ref<any>(null);
|
const selectedItem = ref<any>(null);
|
||||||
@@ -222,6 +276,12 @@ const drawerUserActive = ref(false);
|
|||||||
const isEditingUser = ref(false);
|
const isEditingUser = ref(false);
|
||||||
const userForm = ref({ id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' });
|
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 = [
|
const tableHeaders = [
|
||||||
{ text: 'Name', value: 'name', sortable: true },
|
{ text: 'Name', value: 'name', sortable: true },
|
||||||
{ text: 'E-Mail', value: 'email', sortable: true },
|
{ text: 'E-Mail', value: 'email', sortable: true },
|
||||||
@@ -284,7 +344,10 @@ function openEditDrawer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveItem() {
|
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;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
@@ -301,7 +364,10 @@ async function saveItem() {
|
|||||||
if (updated) selectItem(updated);
|
if (updated) selectItem(updated);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} 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 {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
@@ -365,9 +431,45 @@ async function inviteUser(user: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openQuickAdd(type: 'company' | 'person') {
|
function openQuickAdd(type: 'company' | 'person') {
|
||||||
// Quick add logic can involve opening another drawer or navigating
|
if (type === 'company') {
|
||||||
// For now, we'll just show a notice
|
quickCompanyForm.value = { name: '', domain: '' };
|
||||||
notice.value = { type: 'info', message: `${type === 'company' ? 'Firma' : 'Person'} im jeweiligen Manager anlegen.` };
|
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) {
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import nextPlugin from "@next/eslint-plugin-next";
|
|||||||
import reactPlugin from "eslint-plugin-react";
|
import reactPlugin from "eslint-plugin-react";
|
||||||
import hooksPlugin from "eslint-plugin-react-hooks";
|
import hooksPlugin from "eslint-plugin-react-hooks";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
import js from "@eslint/js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mintel Next.js ESLint Configuration (Flat Config)
|
* Mintel Next.js ESLint Configuration (Flat Config)
|
||||||
@@ -10,32 +9,30 @@ import js from "@eslint/js";
|
|||||||
* This configuration replaces the legacy 'eslint-config-next' which
|
* This configuration replaces the legacy 'eslint-config-next' which
|
||||||
* relies on @rushstack/eslint-patch and causes issues in ESLint 9.
|
* relies on @rushstack/eslint-patch and causes issues in ESLint 9.
|
||||||
*/
|
*/
|
||||||
export const nextConfig = tseslint.config(
|
export const nextConfig = tseslint.config({
|
||||||
{
|
plugins: {
|
||||||
plugins: {
|
react: reactPlugin,
|
||||||
"react": reactPlugin,
|
"react-hooks": hooksPlugin,
|
||||||
"react-hooks": hooksPlugin,
|
"@next/next": nextPlugin,
|
||||||
"@next/next": nextPlugin,
|
},
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
// Add common browser/node globals if needed,
|
||||||
|
// though usually handled by base configs
|
||||||
},
|
},
|
||||||
languageOptions: {
|
},
|
||||||
globals: {
|
rules: {
|
||||||
// Add common browser/node globals if needed,
|
...reactPlugin.configs.recommended.rules,
|
||||||
// though usually handled by base configs
|
...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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* global module */
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import path from "node:path";
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: [
|
content: [
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* global process */
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const buildLintCommand = (filenames) => {
|
const buildLintCommand = (filenames) => {
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ DB_USER="directus"
|
|||||||
DB_NAME="directus"
|
DB_NAME="directus"
|
||||||
|
|
||||||
echo "🔍 Detecting local database..."
|
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
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,11 @@
|
|||||||
"build": "directus-extension build",
|
"build": "directus-extension build",
|
||||||
"dev": "directus-extension build -w"
|
"dev": "directus-extension build -w"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mintel/directus-extension-toolkit": "workspace:*"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
|
||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,9 +126,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, nextTick } from 'vue';
|
import { ref, onMounted, computed, nextTick } from 'vue';
|
||||||
import { useApi } from '@directus/extensions-sdk';
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
|
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
const route = useRoute();
|
||||||
const people = ref([]);
|
const people = ref([]);
|
||||||
const companies = ref([]);
|
const companies = ref([]);
|
||||||
const selectedPerson = ref(null);
|
const selectedPerson = ref(null);
|
||||||
@@ -256,7 +258,12 @@ function openQuickAdd(type: string) {
|
|||||||
feedback.value = { type: 'info', message: `Firma im Company Manager anlegen.` };
|
feedback.value = { type: 'info', message: `Firma im Company Manager anlegen.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchData);
|
onMounted(async () => {
|
||||||
|
await fetchData();
|
||||||
|
if (route.query.create === 'true') {
|
||||||
|
openCreateDrawer();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="stats-grid">
|
<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-icon"><v-icon name="business" large /></div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<span class="stat-label">Firmen</span>
|
<span class="stat-label">Firmen</span>
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<v-icon name="chevron_right" class="arrow" />
|
<v-icon name="chevron_right" class="arrow" />
|
||||||
</div>
|
</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-icon"><v-icon name="person" large /></div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<span class="stat-label">Personen</span>
|
<span class="stat-label">Personen</span>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<v-icon name="chevron_right" class="arrow" />
|
<v-icon name="chevron_right" class="arrow" />
|
||||||
</div>
|
</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-icon"><v-icon name="auto_awesome" large /></div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<span class="stat-label">Leads</span>
|
<span class="stat-label">Leads</span>
|
||||||
@@ -38,14 +38,18 @@
|
|||||||
<div class="recent-activity">
|
<div class="recent-activity">
|
||||||
<h2 class="section-title">Schnellzugriff</h2>
|
<h2 class="section-title">Schnellzugriff</h2>
|
||||||
<div class="action-grid">
|
<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 />
|
<v-icon name="person_add" left />
|
||||||
Neue Person anlegen
|
Neue Person anlegen
|
||||||
</v-button>
|
</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 />
|
<v-icon name="add_link" left />
|
||||||
Neuen Lead registrieren
|
Neuen Lead registrieren
|
||||||
</v-button>
|
</v-button>
|
||||||
|
<v-button secondary block @click="navigateTo('customer-manager', { create: 'true' })">
|
||||||
|
<v-icon name="handshake" left />
|
||||||
|
Kunden verlinken
|
||||||
|
</v-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,8 +87,8 @@ async function fetchStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateTo(path: string) {
|
function navigateTo(id: string, query?: any) {
|
||||||
router.push(path);
|
router.push({ name: `module-${id}`, query });
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchStats);
|
onMounted(fetchStats);
|
||||||
|
|||||||
148
pnpm-lock.yaml
generated
148
pnpm-lock.yaml
generated
@@ -164,7 +164,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@directus/extensions-sdk':
|
'@directus/extensions-sdk':
|
||||||
specifier: 11.0.2
|
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':
|
'@mintel/mail':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../mail
|
version: link:../mail
|
||||||
@@ -179,13 +179,14 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
packages/acquisition-manager:
|
packages/acquisition-manager:
|
||||||
|
dependencies:
|
||||||
|
'@mintel/directus-extension-toolkit':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../directus-extension-toolkit
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@directus/extensions-sdk':
|
'@directus/extensions-sdk':
|
||||||
specifier: 11.0.2
|
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.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:
|
vue:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.5.28(typescript@5.9.3)
|
version: 3.5.28(typescript@5.9.3)
|
||||||
@@ -249,25 +250,27 @@ importers:
|
|||||||
packages/cms-infra: {}
|
packages/cms-infra: {}
|
||||||
|
|
||||||
packages/company-manager:
|
packages/company-manager:
|
||||||
|
dependencies:
|
||||||
|
'@mintel/directus-extension-toolkit':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../directus-extension-toolkit
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@directus/extensions-sdk':
|
'@directus/extensions-sdk':
|
||||||
specifier: 11.0.2
|
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.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:
|
vue:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.5.28(typescript@5.9.3)
|
version: 3.5.28(typescript@5.9.3)
|
||||||
|
|
||||||
packages/customer-manager:
|
packages/customer-manager:
|
||||||
|
dependencies:
|
||||||
|
'@mintel/directus-extension-toolkit':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../directus-extension-toolkit
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@directus/extensions-sdk':
|
'@directus/extensions-sdk':
|
||||||
specifier: 11.0.2
|
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.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:
|
vue:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.5.28(typescript@5.9.3)
|
version: 3.5.28(typescript@5.9.3)
|
||||||
@@ -440,7 +443,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.0.4
|
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:
|
packages/next-config:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -602,7 +605,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.0.0
|
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:
|
packages/pdf-library:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -639,13 +642,14 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
packages/people-manager:
|
packages/people-manager:
|
||||||
|
dependencies:
|
||||||
|
'@mintel/directus-extension-toolkit':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../directus-extension-toolkit
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@directus/extensions-sdk':
|
'@directus/extensions-sdk':
|
||||||
specifier: 11.0.2
|
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.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:
|
vue:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.5.28(typescript@5.9.3)
|
version: 3.5.28(typescript@5.9.3)
|
||||||
@@ -8822,6 +8826,57 @@ snapshots:
|
|||||||
|
|
||||||
'@directus/constants@11.0.3': {}
|
'@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)':
|
'@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:
|
dependencies:
|
||||||
'@directus/composables': 10.1.12(vue@3.4.21(typescript@5.9.3))
|
'@directus/composables': 10.1.12(vue@3.4.21(typescript@5.9.3))
|
||||||
@@ -8873,6 +8928,32 @@ snapshots:
|
|||||||
- typescript
|
- typescript
|
||||||
- vue-router
|
- 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))':
|
'@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:
|
dependencies:
|
||||||
'@directus/constants': 11.0.3
|
'@directus/constants': 11.0.3
|
||||||
@@ -8916,6 +8997,17 @@ snapshots:
|
|||||||
|
|
||||||
'@directus/system-data@1.0.2': {}
|
'@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))':
|
'@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:
|
dependencies:
|
||||||
'@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
|
'@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
|
||||||
@@ -11007,6 +11099,14 @@ snapshots:
|
|||||||
'@unhead/schema': 1.11.20
|
'@unhead/schema': 1.11.20
|
||||||
packrup: 0.1.2
|
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))':
|
'@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@unhead/schema': 1.11.20
|
'@unhead/schema': 1.11.20
|
||||||
@@ -14439,6 +14539,16 @@ snapshots:
|
|||||||
|
|
||||||
pify@4.0.1: {}
|
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)):
|
pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/devtools-api': 6.6.4
|
'@vue/devtools-api': 6.6.4
|
||||||
@@ -15919,7 +16029,7 @@ snapshots:
|
|||||||
tsx: 4.21.0
|
tsx: 4.21.0
|
||||||
yaml: 2.8.2
|
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:
|
dependencies:
|
||||||
'@vitest/expect': 2.1.9
|
'@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))
|
'@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
|
- supports-color
|
||||||
- terser
|
- 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:
|
dependencies:
|
||||||
'@types/chai': 5.2.3
|
'@types/chai': 5.2.3
|
||||||
'@vitest/expect': 3.2.4
|
'@vitest/expect': 3.2.4
|
||||||
@@ -16040,6 +16150,10 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- 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)):
|
vue-demi@0.14.10(vue@3.5.28(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.28(typescript@5.9.3)
|
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"
|
"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..."
|
echo "🚀 Starting isolated extension sync..."
|
||||||
|
|
||||||
# Ensure target directories exist
|
# Ensure target directories exist
|
||||||
@@ -59,13 +67,22 @@ for PKG in "${EXTENSION_PACKAGES[@]}"; do
|
|||||||
rm -rf "${FINAL_TARGET:?}"/*
|
rm -rf "${FINAL_TARGET:?}"/*
|
||||||
|
|
||||||
# Copy build artifacts
|
# Copy build artifacts
|
||||||
if [ -f "$PKG_PATH/dist/index.js" ]; then
|
if [ "$LINK_MODE" = true ]; then
|
||||||
cp "$PKG_PATH/dist/index.js" "$FINAL_TARGET/index.js"
|
if [ -f "$PKG_PATH/dist/index.js" ]; then
|
||||||
elif [ -f "$PKG_PATH/index.js" ]; then
|
ln -sf "$PKG_PATH/dist/index.js" "$FINAL_TARGET/index.js"
|
||||||
cp "$PKG_PATH/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
|
fi
|
||||||
|
|
||||||
if [ -f "$PKG_PATH/package.json" ]; then
|
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/"
|
cp "$PKG_PATH/package.json" "$FINAL_TARGET/"
|
||||||
# We force the registration path to index.js and ensure host/source are set
|
# We force the registration path to index.js and ensure host/source are set
|
||||||
node -e "
|
node -e "
|
||||||
@@ -88,7 +105,11 @@ for PKG in "${EXTENSION_PACKAGES[@]}"; do
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -d "$PKG_PATH/dist" ]; then
|
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
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -118,38 +139,7 @@ for TARGET_BASE in "${TARGET_DIRS[@]}"; do
|
|||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "🔧 Applying core patch to Directus 11.15.2 bundler..."
|
# Container patching is now handled by scripts/patch-cms.sh
|
||||||
docker exec cms-infra-infra-cms-1 node -e '
|
# which should be run AFTER the containers are up.
|
||||||
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
|
echo "✨ Sync complete! Extensions are in packages/cms-infra/extensions."
|
||||||
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 (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."
|
|
||||||
|
|||||||
Reference in New Issue
Block a user